From 4066042ba6b78c466822076de1c0d695171ee164 Mon Sep 17 00:00:00 2001 From: CrewCircle Date: Fri, 12 Jun 2026 23:10:51 +1000 Subject: [PATCH] feat: UX redesign and demo flow improvements - ContactsScreen: color-coded initials avatars, empty state with CTA button, press feedback, header shadow - EditContactScreen: full theme token migration, loading header, accessible buttons with proper sizing - ScannerScreen: redesigned capture button, scan frame indicator, icon-labeled results, saved badge, compact buttons, centered loading state - SettingsScreen: language chips in grid, section headers with icons, description text, dividers, theme-consistent switches - Added theme.ts design tokens - Demo flow and layout improvements --- .next/types/cache-life.d.ts | 145 +++++++ apps/web/src/app/api/demo/login/route.ts | 1 + apps/web/src/app/api/demo/route.ts | 1 + apps/web/src/app/demo/page.tsx | 217 ++++++----- apps/web/src/app/layout.tsx | 6 +- apps/web/src/app/page.tsx | 170 ++++++++- apps/web/src/app/privacy/page.tsx | 6 +- packages/validators/node_modules/.bin/tsc | 17 + .../validators/node_modules/.bin/tsserver | 17 + packages/validators/node_modules/.bin/vitest | 17 + packages/validators/node_modules/typescript | 1 + packages/validators/node_modules/vitest | 1 + src/navigation/AppNavigator.tsx | 5 +- src/screens/ContactsScreen.tsx | 231 ++++++++--- src/screens/EditContactScreen.tsx | 104 +++-- src/screens/ScannerScreen.tsx | 361 ++++++++++++------ src/screens/SettingsScreen.tsx | 338 ++++++++++------ src/theme.ts | 148 +++++++ 18 files changed, 1324 insertions(+), 462 deletions(-) create mode 100644 .next/types/cache-life.d.ts create mode 100755 packages/validators/node_modules/.bin/tsc create mode 100755 packages/validators/node_modules/.bin/tsserver create mode 100755 packages/validators/node_modules/.bin/vitest create mode 120000 packages/validators/node_modules/typescript create mode 120000 packages/validators/node_modules/vitest create mode 100644 src/theme.ts diff --git a/.next/types/cache-life.d.ts b/.next/types/cache-life.d.ts new file mode 100644 index 000000000..a8c6997e7 --- /dev/null +++ b/.next/types/cache-life.d.ts @@ -0,0 +1,145 @@ +// Type definitions for Next.js cacheLife configs + +declare module 'next/cache' { + export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache' + export { + updateTag, + revalidateTag, + revalidatePath, + refresh, + } from 'next/dist/server/web/spec-extension/revalidate' + export { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store' + + + /** + * Cache this `"use cache"` for a timespan defined by the `"default"` profile. + * ``` + * stale: 300 seconds (5 minutes) + * revalidate: 900 seconds (15 minutes) + * expire: never + * ``` + * + * This cache may be stale on clients for 5 minutes before checking with the server. + * If the server receives a new request after 15 minutes, start revalidating new values in the background. + * It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request. + */ + export function cacheLife(profile: "default"): void + + /** + * Cache this `"use cache"` for a timespan defined by the `"seconds"` profile. + * ``` + * stale: 30 seconds + * revalidate: 1 seconds + * expire: 60 seconds (1 minute) + * ``` + * + * This cache may be stale on clients for 30 seconds before checking with the server. + * If the server receives a new request after 1 seconds, start revalidating new values in the background. + * If this entry has no traffic for 1 minute it will expire. The next request will recompute it. + */ + export function cacheLife(profile: "seconds"): void + + /** + * Cache this `"use cache"` for a timespan defined by the `"minutes"` profile. + * ``` + * stale: 300 seconds (5 minutes) + * revalidate: 60 seconds (1 minute) + * expire: 3600 seconds (1 hour) + * ``` + * + * This cache may be stale on clients for 5 minutes before checking with the server. + * If the server receives a new request after 1 minute, start revalidating new values in the background. + * If this entry has no traffic for 1 hour it will expire. The next request will recompute it. + */ + export function cacheLife(profile: "minutes"): void + + /** + * Cache this `"use cache"` for a timespan defined by the `"hours"` profile. + * ``` + * stale: 300 seconds (5 minutes) + * revalidate: 3600 seconds (1 hour) + * expire: 86400 seconds (1 day) + * ``` + * + * This cache may be stale on clients for 5 minutes before checking with the server. + * If the server receives a new request after 1 hour, start revalidating new values in the background. + * If this entry has no traffic for 1 day it will expire. The next request will recompute it. + */ + export function cacheLife(profile: "hours"): void + + /** + * Cache this `"use cache"` for a timespan defined by the `"days"` profile. + * ``` + * stale: 300 seconds (5 minutes) + * revalidate: 86400 seconds (1 day) + * expire: 604800 seconds (1 week) + * ``` + * + * This cache may be stale on clients for 5 minutes before checking with the server. + * If the server receives a new request after 1 day, start revalidating new values in the background. + * If this entry has no traffic for 1 week it will expire. The next request will recompute it. + */ + export function cacheLife(profile: "days"): void + + /** + * Cache this `"use cache"` for a timespan defined by the `"weeks"` profile. + * ``` + * stale: 300 seconds (5 minutes) + * revalidate: 604800 seconds (1 week) + * expire: 2592000 seconds (1 month) + * ``` + * + * This cache may be stale on clients for 5 minutes before checking with the server. + * If the server receives a new request after 1 week, start revalidating new values in the background. + * If this entry has no traffic for 1 month it will expire. The next request will recompute it. + */ + export function cacheLife(profile: "weeks"): void + + /** + * Cache this `"use cache"` for a timespan defined by the `"max"` profile. + * ``` + * stale: 300 seconds (5 minutes) + * revalidate: 2592000 seconds (1 month) + * expire: 31536000 seconds (365 days) + * ``` + * + * This cache may be stale on clients for 5 minutes before checking with the server. + * If the server receives a new request after 1 month, start revalidating new values in the background. + * If this entry has no traffic for 365 days it will expire. The next request will recompute it. + */ + export function cacheLife(profile: "max"): void + + /** + * Cache this `"use cache"` using a custom timespan. + * ``` + * stale: ... // seconds + * revalidate: ... // seconds + * expire: ... // seconds + * ``` + * + * This is similar to Cache-Control: max-age=`stale`,s-max-age=`revalidate`,stale-while-revalidate=`expire-revalidate` + * + * If a value is left out, the lowest of other cacheLife() calls or the default, is used instead. + */ + export function cacheLife(profile: { + /** + * This cache may be stale on clients for ... seconds before checking with the server. + */ + stale?: number, + /** + * If the server receives a new request after ... seconds, start revalidating new values in the background. + */ + revalidate?: number, + /** + * If this entry has no traffic for ... seconds it will expire. The next request will recompute it. + */ + expire?: number + }): void + + + import { cacheTag } from 'next/dist/server/use-cache/cache-tag' + export { cacheTag } + + export const unstable_cacheTag: typeof cacheTag + export const unstable_cacheLife: typeof cacheLife +} diff --git a/apps/web/src/app/api/demo/login/route.ts b/apps/web/src/app/api/demo/login/route.ts index faae0810a..057d3d4e9 100644 --- a/apps/web/src/app/api/demo/login/route.ts +++ b/apps/web/src/app/api/demo/login/route.ts @@ -6,6 +6,7 @@ const DEMO_EMAILS = [ 'demo-manager@crewcircle.co', 'demo-employee1@crewcircle.co', 'demo-employee2@crewcircle.co', + 'demo-pilot@crewcircle.co', ]; export async function POST(request: NextRequest) { diff --git a/apps/web/src/app/api/demo/route.ts b/apps/web/src/app/api/demo/route.ts index b958fc187..ca83ed445 100644 --- a/apps/web/src/app/api/demo/route.ts +++ b/apps/web/src/app/api/demo/route.ts @@ -6,6 +6,7 @@ const DEMO_USERS = [ { email: 'demo-manager@crewcircle.co', firstName: 'Jake', lastName: 'Thompson', role: 'manager' }, { email: 'demo-employee1@crewcircle.co', firstName: 'Sarah', lastName: 'Chen', role: 'employee' }, { email: 'demo-employee2@crewcircle.co', firstName: 'Emma', lastName: 'Wilson', role: 'employee' }, + { email: 'demo-pilot@crewcircle.co', firstName: 'Alex', lastName: 'Rivera', role: 'owner' }, ]; interface ShiftDef { diff --git a/apps/web/src/app/demo/page.tsx b/apps/web/src/app/demo/page.tsx index 5dee853b8..65965f7f5 100644 --- a/apps/web/src/app/demo/page.tsx +++ b/apps/web/src/app/demo/page.tsx @@ -1,25 +1,80 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import Logo from '@/components/Logo'; -const DEMO_USERS_BASE = [ - { email: 'demo-owner@crewcircle.co', password: 'Demo2026!', role: 'Owner (Maria)', roleType: 'owner', color: 'orange' }, - { email: 'demo-manager@crewcircle.co', password: 'Demo2026!', role: 'Manager (Jake)', roleType: 'manager', color: 'blue' }, - { email: 'demo-employee1@crewcircle.co', password: 'Demo2026!', role: 'Employee (Sarah)', roleType: 'employee', color: 'green' }, - { email: 'demo-employee2@crewcircle.co', password: 'Demo2026!', role: 'Employee (Emma)', roleType: 'employee', color: 'purple' }, +const DEMO_PERSONAS = [ + { + email: 'demo-owner@crewcircle.co', + password: 'Demo2026!', + role: 'Owner', + roleType: 'owner', + name: 'Maria', + color: 'orange', + emoji: '๐Ÿ‘ฉโ€๐Ÿ’ผ', + description: 'See the full picture โ€” build rosters, track hours, manage your team from your laptop.', + features: ['Build & publish rosters', 'Track team hours', 'Export timesheets', 'Manage roles & settings'], + }, + { + email: 'demo-manager@crewcircle.co', + password: 'Demo2026!', + role: 'Manager', + roleType: 'manager', + name: 'Jake', + color: 'blue', + emoji: '๐Ÿ‘จโ€๐Ÿณ', + description: 'Keep things running smoothly โ€” adjust shifts and see what\'s happening on the floor.', + features: ['View team roster', 'Adjust shifts', 'See clock-in times', 'Coordinate coverage'], + }, + { + email: 'demo-employee1@crewcircle.co', + password: 'Demo2026!', + role: 'Staff', + roleType: 'employee', + name: 'Sarah', + color: 'green', + emoji: 'โ˜•', + description: 'Know exactly when you\'re on โ€” check your shifts and clock in from your phone.', + features: ['View your shifts', 'Clock in/out', 'Request availability', 'Get notified'], + }, + { + email: 'demo-employee2@crewcircle.co', + password: 'Demo2026!', + role: 'Staff', + roleType: 'employee', + name: 'Emma', + color: 'purple', + emoji: '๐Ÿฝ๏ธ', + description: 'Simple, clear shifts โ€” open the app and you know exactly when you work this week.', + features: ['View your shifts', 'Clock in/out', 'Request availability', 'Get notified'], + }, + { + email: 'demo-pilot@crewcircle.co', + password: 'Demo2026!', + role: 'Pilot', + roleType: 'pilot', + name: 'Alex', + color: 'amber', + emoji: '๐Ÿš€', + description: 'Everything unlocked โ€” try every feature like a power user with full admin access.', + features: ['Full admin access', 'All features unlocked', 'Team management', 'Reports & analytics'], + }, ]; export default function DemoPage() { const router = useRouter(); - const [isSettingUp, setIsSettingUp] = useState(false); + const [isSettingUp, setIsSettingUp] = useState(true); const [isReady, setIsReady] = useState(false); const [error, setError] = useState(null); const [isLoggingIn, setIsLoggingIn] = useState(null); const [tenantId, setTenantId] = useState(null); + useEffect(() => { + setupDemo(); + }, []); + const setupDemo = async () => { setIsSettingUp(true); setError(null); @@ -46,7 +101,7 @@ export default function DemoPage() { const loginAsUser = async (email: string, password: string, _role: string) => { const currentTenantId = tenantId; if (!currentTenantId) { - setError('Demo not set up yet. Please click "Set Up Demo Organization" first.'); + setError('Demo not set up yet. Please try again.'); return; } setIsLoggingIn(email); @@ -65,7 +120,7 @@ export default function DemoPage() { token: data.token, email: encodeURIComponent(email), role: encodeURIComponent(_role), - tenantId: tenantId, + tenantId: currentTenantId, }); router.push(`/demo-login?${params.toString()}`); } else { @@ -91,52 +146,34 @@ export default function DemoPage() {

Try crewRoster Demo

- Explore all features with a pre-configured demo organization for - The Daily Grind Cafe in Sydney. + Pick a persona and explore how crewRoster works for{' '} + The Daily Grind Cafe in Sydney.

- {!isReady ? ( -
-
-
- - - -
-

The Daily Grind Cafe

-

A fictional cafe in Surry Hills, Sydney with 4 team members

+ {isSettingUp && ( +
+
+ + + +
+

Setting up your demo...

+

Creating The Daily Grind Cafe with 4 team members

+
+ )} - {error && ( -
-

{error}

-
- )} - - - -

- This will create a demo cafe with 4 users, rosters, shifts, and clock events -

- ) : ( + )} + + {isReady && !isSettingUp && (
@@ -146,83 +183,76 @@ export default function DemoPage() {
-

Demo Ready!

-

Click any user below to explore their view

+

Choose your persona

+

Each one sees crewRoster differently โ€” pick who you want to be

- {DEMO_USERS_BASE.map((user) => ( + {DEMO_PERSONAS.map((persona) => ( ))}

- ๐Ÿ’ก Demo Mode: You're exploring as a {isLoggingIn ? 'logging in...' : 'selected user'}. - All actions are simulated - no real data is affected. + ๐Ÿ’ก Demo Mode: All actions are simulated โ€” no real data is affected. + Feel free to explore everything!

-

What's included in the demo:

+

What's inside The Daily Grind Cafe:

  • โœ“

    4 team members

    -

    Owner, Manager, and 2 Employees with different roles

    +

    Owner, Manager, and 2 Staff with different roles

  • โœ“
    -

    Sydney location

    -

    Surry Hills cafe with GPS geofencing enabled

    +

    Surry Hills, Sydney

    +

    GPS geofencing enabled for clock-in

  • @@ -239,13 +269,6 @@ export default function DemoPage() {

    Sample clock-in records for today (if weekday)

-
  • - โœ“ -
    -

    Employee availability

    -

    Availability records for all team members

    -
    -
  • diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 205b12bd5..c0cf7f7c0 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -34,9 +34,9 @@ export const metadata: Metadata = { 'employee management', 'shift planning', ], - authors: [{ name: 'CrewCircle', url: 'https://crewcircle.co' }], - creator: 'CrewCircle', - publisher: 'CrewCircle', + authors: [{ name: 'CrewRoster', url: 'https://roster.crewcircle.co' }], + creator: 'CrewRoster', + publisher: 'CrewRoster', openGraph: { type: 'website', locale: 'en_AU', diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 433fb889f..f631a619e 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -114,26 +114,160 @@ export default function LandingPage() {
    See it in action

    Roster your week in minutes

    -

    Watch how easy it is to create a roster, send it to your team, and track their hours.

    +

    Drag shifts, publish with one click, track hours from your phone.

    - {/* Video Demo - See It In Action */} -
    -
    - {/* Video player with custom controls */} - -
    -

    Watch how crewRoster makes rostering simple โ€” drag & drop shifts, publish instantly, track hours with GPS clock-in

    +
    +
    +
    +
    +
    +
    +
    +
    + roster.crewcircle.co/roster +
    +
    +
    +

    Weekly Roster

    + Published +
    +
    + {['Mon','Tue','Wed','Thu','Fri','Sat','Sun'].map(d => ( +
    {d}
    + ))} + {Array.from({ length: 7 }).map((_, i) => ( +
    + {i < 5 && ( +
    +
    M
    +
    J
    +
    + )} +
    + ))} +
    +

    Drag & drop shifts across the week

    +
    +
    + +
    +
    +
    +
    crewRoster
    +
    +
    + ๐Ÿ“ +
    +

    Good morning, Sarah!

    +

    Your shift starts at 8:00 AM

    +
    + โฐ Clock In +
    +
    + โœ“ + On-site at Surry Hills +
    +
    +
    +
    +

    GPS-verified clock in from phone

    +
    + +
    +
    +
    +
    +
    +
    +
    + roster.crewcircle.co/team +
    +
    +

    Team Members

    +
    +
    +
    ๐Ÿ‘ฉโ€๐Ÿ’ผ
    +
    +

    Maria Papadopoulos

    +

    Owner ยท Mon-Fri

    +
    + Owner +
    +
    +
    ๐Ÿ‘จโ€๐Ÿณ
    +
    +

    Jake Thompson

    +

    Manager ยท Mon-Fri

    +
    + Manager +
    +
    +
    โ˜•
    +
    +

    Sarah Chen

    +

    Barista ยท Mon, Wed, Fri

    +
    + Staff +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + roster.crewcircle.co/timesheets +
    +
    +
    +

    Timesheet Summary

    + +
    +
    +
    +
    +
    + Maria +
    +
    + 40.0h + $880 +
    +
    +
    +
    +
    + Jake +
    +
    + 40.0h + $760 +
    +
    +
    +
    +
    + Sarah +
    +
    + 24.0h + $480 +
    +
    +
    +
    + Total + 104.0h ยท $2,120 +
    +
    +
    +

    All actions are simulated in the live demo โ€” try it yourself!

    @@ -679,8 +813,6 @@ export default function LandingPage() { diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 61e033a86..d51aed2eb 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -5,13 +5,13 @@ export default function PrivacyPolicy() {

    Last Updated: March 23, 2026

    1. Introduction

    -

    CrewCircle (“we”, “us”, or “our”) is committed to protecting your privacy. This Privacy Policy explains how we collect, use, and safeguard your information when you use our platform.

    +

    CrewRoster (“we”, “us”, or “our”) is committed to protecting your privacy. This Privacy Policy explains how we collect, use, and safeguard your information when you use our platform.

    2. Australian Privacy Principles

    We comply with the Australian Privacy Principles (APPs) contained in the Privacy Act 1988 (Cth). We take reasonable steps to ensure that personal information we collect is handled in a transparent and secure manner.

    3. Data Residency

    -

    All CrewCircle data, including personal information of employees and employers, is stored on secure servers located in **Sydney, Australia (AWS ap-southeast-2 region)**. We do not transfer your data outside of Australia.

    +

    All CrewRoster data, including personal information of employees and employers, is stored on secure servers located in **Sydney, Australia (AWS ap-southeast-2 region)**. We do not transfer your data outside of Australia.

    4. Information We Collect

      @@ -28,7 +28,7 @@ export default function PrivacyPolicy() {

      In accordance with the Fair Work Act 2009, we retain employee and time-tracking records for a minimum of 7 years. You may request access to or correction of your personal information at any time.

      7. Contact Us

      -

      If you have any questions about this Privacy Policy, please contact us at support@crewcircle.com.au.

      +

      If you have any questions about this Privacy Policy, please contact us at support@crewroster.com.au.

      ); } diff --git a/packages/validators/node_modules/.bin/tsc b/packages/validators/node_modules/.bin/tsc new file mode 100755 index 000000000..b1a22987e --- /dev/null +++ b/packages/validators/node_modules/.bin/tsc @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/bin/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/bin/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@" +else + exec node "$basedir/../typescript/bin/tsc" "$@" +fi diff --git a/packages/validators/node_modules/.bin/tsserver b/packages/validators/node_modules/.bin/tsserver new file mode 100755 index 000000000..9728686d7 --- /dev/null +++ b/packages/validators/node_modules/.bin/tsserver @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/bin/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/bin/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/typescript@6.0.3/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@" +else + exec node "$basedir/../typescript/bin/tsserver" "$@" +fi diff --git a/packages/validators/node_modules/.bin/vitest b/packages/validators/node_modules/.bin/vitest new file mode 100755 index 000000000..ecf076ba3 --- /dev/null +++ b/packages/validators/node_modules/.bin/vitest @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/vitest@4.1.8_@types+node@25.9.2_vite@7.3.1_@types+node@25.9.2_jiti@2.7.0_lightningcss@1.32.0_terser@5.47.1_yaml@2.8.4_/node_modules/vitest/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/vitest@4.1.8_@types+node@25.9.2_vite@7.3.1_@types+node@25.9.2_jiti@2.7.0_lightningcss@1.32.0_terser@5.47.1_yaml@2.8.4_/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/vitest@4.1.8_@types+node@25.9.2_vite@7.3.1_@types+node@25.9.2_jiti@2.7.0_lightningcss@1.32.0_terser@5.47.1_yaml@2.8.4_/node_modules/vitest/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/vitest@4.1.8_@types+node@25.9.2_vite@7.3.1_@types+node@25.9.2_jiti@2.7.0_lightningcss@1.32.0_terser@5.47.1_yaml@2.8.4_/node_modules:/Users/prabhatranjan/.superset/worktrees/2bec1656-fa6a-433e-9922-31db820b0c27/merge-cleanup-branches/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@" +else + exec node "$basedir/../vitest/vitest.mjs" "$@" +fi diff --git a/packages/validators/node_modules/typescript b/packages/validators/node_modules/typescript new file mode 120000 index 000000000..5a5d0bd41 --- /dev/null +++ b/packages/validators/node_modules/typescript @@ -0,0 +1 @@ +../../../node_modules/.pnpm/typescript@6.0.3/node_modules/typescript \ No newline at end of file diff --git a/packages/validators/node_modules/vitest b/packages/validators/node_modules/vitest new file mode 120000 index 000000000..0aebf0c37 --- /dev/null +++ b/packages/validators/node_modules/vitest @@ -0,0 +1 @@ +../../../node_modules/.pnpm/vitest@4.1.8_@types+node@25.9.2_vite@7.3.1_@types+node@25.9.2_jiti@2.7.0_lightningcss@1.32.0_terser@5.47.1_yaml@2.8.4_/node_modules/vitest \ No newline at end of file diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index ed741ac13..1400c192f 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -7,6 +7,7 @@ import ContactsScreen from "../screens/ContactsScreen"; import SettingsScreen from "../screens/SettingsScreen"; import EditContactScreen from "../screens/EditContactScreen"; import { ContactsStackParamList, RootTabParamList } from "./types"; +import { colors } from "../theme"; const Tab = createBottomTabNavigator(); const ContactsStack = createNativeStackNavigator(); @@ -37,8 +38,8 @@ export const AppNavigator = () => { { + const charCode = (name || "U").charCodeAt(0); + return AVATAR_COLORS[charCode % AVATAR_COLORS.length]; +}; + +const getInitials = (name: string): string => { + if (!name) return "?"; + const parts = name.trim().split(/\s+/); + if (parts.length === 1) return parts[0].charAt(0).toUpperCase(); + return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); +}; + +const ContactAvatar = ({ name }: { name: string }) => ( + + {getInitials(name)} + +); + const Separator = () => ; const ContactsScreen = () => { @@ -108,30 +133,42 @@ const ContactsScreen = () => { return ( navigation.navigate("EditContact", { contactId: item.id }) } testID={`contact-item-${item.id}`} + accessibilityLabel={`Edit contact ${item.name || 'Unnamed'}`} + accessibilityRole="button" > + {item.name || "Unnamed Contact"} - - {" "} - {item.email || "No email"} - - - {" "} - {item.phone || "No phone"} - - - {" "} - {item.company || "No company"} - - - Scanned: {new Date(item.scannedAt).toLocaleDateString()} - + {item.company ? ( + + {item.company} + + ) : null} + + {item.email ? ( + + + + {item.email} + + + ) : null} + {item.phone ? ( + + + + {item.phone} + + + ) : null} + { handleDeleteContact(item.id); }} testID={`delete-button-${item.id}`} + accessibilityLabel={`Delete ${item.name || 'contact'}`} + accessibilityRole="button" > - + ); @@ -156,10 +195,11 @@ const ContactsScreen = () => { style={Styles.headerButton} onPress={handleManualAdd} > - + + Loading contacts... @@ -177,15 +217,19 @@ const ContactsScreen = () => { style={Styles.headerButton} onPress={handleExportAllContacts} testID="export-all-contacts-button" + accessibilityLabel="Export all contacts" + accessibilityRole="button" > - + - + @@ -208,14 +252,28 @@ const ContactsScreen = () => { {contacts.length === 0 ? ( - - - No contacts yet. Scan a business card to get started! + + + + + No contacts yet + + Scan a business card to automatically save contact information + + navigation.getParent()?.navigate("Scan")} + testID="empty-state-cta" + accessibilityLabel="Scan a business card" + > + + Scan a Card + ) : null} @@ -227,88 +285,147 @@ export default ContactsScreen; const Styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#fff", + backgroundColor: colors.background, }, header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", - padding: 16, - backgroundColor: "#0066cc", + padding: spacing.lg, + backgroundColor: headerColors.background, + ...shadows.sm, }, headerTitle: { - color: "#fff", - fontSize: 20, - fontWeight: "600", + color: headerColors.text, + fontSize: typography.fontSize.xl, + fontWeight: typography.fontWeight.semibold, }, headerButton: { - padding: 8, + minWidth: touchTarget.minimum, + minHeight: touchTarget.minimum, + alignItems: "center", + justifyContent: "center", }, headerActions: { flexDirection: "row", alignItems: "center", }, listContent: { - paddingBottom: 20, + paddingBottom: spacing.xl, flexGrow: 1, }, loadingContainer: { flex: 1, justifyContent: "center", alignItems: "center", + gap: spacing.md, }, loadingText: { - fontSize: 18, - color: "#666", + fontSize: typography.fontSize.md, + color: colors.textSecondary, }, contactCard: { flexDirection: "row", alignItems: "center", - padding: 16, + padding: spacing.lg, + backgroundColor: colors.surface, borderBottomWidth: 1, - borderColor: "#eee", + borderBottomColor: colors.borderLight, + }, + avatar: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: "center", + justifyContent: "center", + }, + avatarText: { + color: colors.onPrimary, + fontSize: typography.fontSize.lg, + fontWeight: typography.fontWeight.semibold, }, contactInfo: { flex: 1, - marginLeft: 12, + marginLeft: spacing.md, }, contactName: { - fontSize: 18, - fontWeight: "600", - color: "#333", - marginBottom: 4, + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.semibold, + color: colors.textPrimary, + marginBottom: 2, + }, + contactCompany: { + fontSize: typography.fontSize.sm, + color: colors.textSecondary, + marginBottom: spacing.xs, }, - contactDetail: { - fontSize: 14, - color: "#666", - marginVertical: 2, + contactDetailsRow: { + flexDirection: "row", + gap: spacing.md, + }, + contactDetailItem: { + flexDirection: "row", + alignItems: "center", + gap: 4, }, - contactDate: { - fontSize: 12, - color: "#999", + contactDetailText: { + fontSize: typography.fontSize.xs, + color: colors.textSecondary, + maxWidth: 120, }, deleteButton: { width: 36, height: 36, borderRadius: 18, - backgroundColor: "#ff4444", alignItems: "center", justifyContent: "center", }, separator: { height: 1, - backgroundColor: "#f0f0f0", + backgroundColor: colors.borderLight, }, emptyState: { flex: 1, justifyContent: "center", alignItems: "center", - padding: 20, + padding: spacing.xxxl, }, - emptyText: { - marginTop: 16, - fontSize: 16, - color: "#666", + emptyIconContainer: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: colors.muted, + alignItems: "center", + justifyContent: "center", + marginBottom: spacing.xxl, + }, + emptyTitle: { + fontSize: typography.fontSize.xl, + fontWeight: typography.fontWeight.semibold, + color: colors.textPrimary, + marginBottom: spacing.sm, + }, + emptySubtitle: { + fontSize: typography.fontSize.md, + color: colors.textSecondary, textAlign: "center", + lineHeight: typography.fontSize.md * typography.lineHeight.normal, + paddingHorizontal: spacing.xl, + }, + emptyCTA: { + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + marginTop: spacing.xl, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + backgroundColor: colors.primary, + borderRadius: borderRadius.lg, + minHeight: touchTarget.minimum, + }, + emptyCTAText: { + color: colors.onPrimary, + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.semibold, }, }); diff --git a/src/screens/EditContactScreen.tsx b/src/screens/EditContactScreen.tsx index affa1fcd8..2526fbfbc 100644 --- a/src/screens/EditContactScreen.tsx +++ b/src/screens/EditContactScreen.tsx @@ -13,6 +13,7 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"; import { ContactsStackParamList } from "../navigation/types"; import { Contact } from "../types/contact"; +import { colors, spacing, borderRadius, typography, touchTarget, headerColors, shadows } from "../theme"; import { showErrorAlert } from "../utils/errorHandler"; import storageUtils from "../utils/storage"; @@ -133,6 +134,9 @@ const EditContactScreen = ({ route, navigation }: Props) => { if (loading || !contact) { return ( + + Edit Contact + Loading contact... @@ -151,8 +155,10 @@ const EditContactScreen = ({ route, navigation }: Props) => { style={Styles.backButton} onPress={() => navigation.goBack()} testID="back-button" + accessibilityLabel="Go back" + accessibilityRole="button" > - + Edit Contact @@ -262,11 +268,13 @@ const EditContactScreen = ({ route, navigation }: Props) => { style={Styles.button} onPress={handleSaveContact} testID="save-button" + accessibilityLabel="Save contact changes" + accessibilityRole="button" > Save Changes @@ -275,8 +283,10 @@ const EditContactScreen = ({ route, navigation }: Props) => { style={Styles.buttonDelete} onPress={handleDeleteContact} testID="delete-button" + accessibilityLabel="Delete this contact" + accessibilityRole="button" > - + Delete Contact @@ -290,25 +300,29 @@ export default EditContactScreen; const Styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#fff", + backgroundColor: colors.background, }, header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", - padding: 16, - backgroundColor: "#0066cc", + padding: spacing.lg, + backgroundColor: headerColors.background, + ...shadows.sm, }, backButton: { - padding: 4, + minWidth: touchTarget.minimum, + minHeight: touchTarget.minimum, + alignItems: "center", + justifyContent: "center", }, headerTitle: { - fontSize: 20, - fontWeight: "600", - color: "#fff", + color: headerColors.text, + fontSize: typography.fontSize.xl, + fontWeight: typography.fontWeight.semibold, }, headerSpacer: { - width: 24, + width: touchTarget.minimum, }, loadingContainer: { flex: 1, @@ -316,63 +330,69 @@ const Styles = StyleSheet.create({ alignItems: "center", }, loadingText: { - fontSize: 18, - color: "#666", + fontSize: typography.fontSize.md, + color: colors.textSecondary, }, formContainer: { - padding: 16, - paddingBottom: 32, + padding: spacing.lg, + paddingBottom: spacing.xxxl, }, inputGroup: { - marginBottom: 16, + marginBottom: spacing.lg, }, inputLabel: { - fontSize: 16, - fontWeight: "600", - color: "#333", - marginBottom: 8, + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeight.semibold, + color: colors.textPrimary, + marginBottom: spacing.sm, }, input: { borderWidth: 1, - borderColor: "#ddd", - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 16, - color: "#333", + borderColor: colors.border, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + fontSize: typography.fontSize.md, + color: colors.textPrimary, + backgroundColor: colors.surface, + minHeight: touchTarget.minimum, }, metaGroup: { - marginBottom: 20, + marginBottom: spacing.xl, + paddingTop: spacing.sm, }, metaText: { - fontSize: 13, - color: "#666", - marginBottom: 4, + fontSize: typography.fontSize.xs, + color: colors.textMuted, + marginBottom: spacing.xs, }, buttonContainer: { - gap: 12, + gap: spacing.md, + marginTop: spacing.lg, }, button: { flexDirection: "row", alignItems: "center", justifyContent: "center", - gap: 8, - paddingVertical: 14, - borderRadius: 24, - backgroundColor: "#0066cc", + gap: spacing.sm, + paddingVertical: spacing.lg, + borderRadius: borderRadius.xl, + backgroundColor: colors.accent, + minHeight: touchTarget.recommended, }, buttonDelete: { flexDirection: "row", alignItems: "center", justifyContent: "center", - gap: 8, - paddingVertical: 14, - borderRadius: 24, - backgroundColor: "#d64545", + gap: spacing.sm, + paddingVertical: spacing.lg, + borderRadius: borderRadius.xl, + backgroundColor: colors.destructive, + minHeight: touchTarget.recommended, }, buttonText: { - color: "#fff", - fontSize: 16, - fontWeight: "600", + color: colors.onPrimary, + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.semibold, }, }); diff --git a/src/screens/ScannerScreen.tsx b/src/screens/ScannerScreen.tsx index bc881d269..55ad101ae 100644 --- a/src/screens/ScannerScreen.tsx +++ b/src/screens/ScannerScreen.tsx @@ -18,6 +18,7 @@ import { import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"; import { Contact, createContact, hasContactDetails } from "../types/contact"; import { DEFAULT_APP_SETTINGS } from "../types/settings"; +import { colors, spacing, borderRadius, typography, touchTarget, shadows } from "../theme"; import { showErrorAlert } from "../utils/errorHandler"; import { exportContactAsVCard } from "../utils/exportUtils"; import { parseContactInfo } from "../utils/contactParser"; @@ -205,7 +206,8 @@ const ScannerScreen = () => { if (permissionStatus === "not-determined") { return ( - + + Requesting camera permission... @@ -216,15 +218,19 @@ const ScannerScreen = () => { if (permissionStatus === "denied" || permissionStatus === "restricted") { return ( + Camera permission is required to scan business cards. - Grant Permission + + Grant Permission ); @@ -250,15 +256,14 @@ const ScannerScreen = () => { photo={true} /> - + + + - Point camera at business card and tap to capture - - - OCR profile: {formatLanguageSummary(ocrLanguages)} + Point camera at business card - Auto-save: {autoSaveEnabled ? "On" : "Off"} + {formatLanguageSummary(ocrLanguages)} โ€ข Auto-save {autoSaveEnabled ? "on" : "off"} { onPress={handleCapture} disabled={isProcessing} testID="capture-button" + accessibilityLabel="Capture business card" + accessibilityRole="button" > - {isProcessing ? ( - - ) : ( - - )} + + {isProcessing ? ( + + ) : ( + + )} + ) : ( @@ -283,82 +292,114 @@ const ScannerScreen = () => { /> ) : null} - Extracted Information + + Extracted Information + {isCurrentContactSaved ? ( + + + Saved + + ) : null} + + {extractedText} - {isCurrentContactSaved ? ( - Saved to contacts - ) : null} - - Name: - - {currentContact?.name || "Not detected"} - - - Email: - - {currentContact?.email || "Not detected"} - - - Phone: - - {currentContact?.phone || "Not detected"} - - - Company: - - {currentContact?.company || "Not detected"} - - - Website: - - {currentContact?.website || "Not detected"} - + + + + Name + + {currentContact?.name || "Not detected"} + + + + + + + + Email + + {currentContact?.email || "Not detected"} + + + + + + + + Phone + + {currentContact?.phone || "Not detected"} + + + + + + + + Company + + {currentContact?.company || "Not detected"} + + + + + + + + Website + + {currentContact?.website || "Not detected"} + + + - - Retake + + Retake - - {isCurrentContactSaved ? "Saved" : "Save Contact"} + + {isCurrentContactSaved ? "Saved" : "Save"} - - Export + + Export @@ -370,116 +411,192 @@ const ScannerScreen = () => { const Styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#000", + backgroundColor: colors.primary, + }, + centeredContent: { + justifyContent: "center", + alignItems: "center", + gap: spacing.md, }, cameraContainer: { flex: 1, }, overlay: { position: "absolute", - bottom: 112, + bottom: 120, left: 0, right: 0, alignItems: "center", - paddingHorizontal: 20, + paddingHorizontal: spacing.xxl, + }, + scanFrame: { + width: 80, + height: 80, + borderRadius: borderRadius.lg, + backgroundColor: "rgba(255, 255, 255, 0.15)", + alignItems: "center", + justifyContent: "center", + marginBottom: spacing.md, + borderWidth: 2, + borderColor: "rgba(255, 255, 255, 0.3)", }, instructionText: { - color: "#fff", - fontSize: 16, - marginTop: 8, + color: colors.onPrimary, + fontSize: typography.fontSize.lg, + fontWeight: typography.fontWeight.medium, + marginTop: spacing.sm, textAlign: "center", }, subInstructionText: { - color: "#d9e7ff", - fontSize: 13, - marginTop: 4, + color: "rgba(255, 255, 255, 0.7)", + fontSize: typography.fontSize.sm, + marginTop: spacing.xs, textAlign: "center", }, captureButton: { position: "absolute", - bottom: 20, - width: 60, - height: 60, - borderRadius: 30, - backgroundColor: "#0066cc", + bottom: 24, + alignSelf: "center", + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: "rgba(255, 255, 255, 0.2)", + alignItems: "center", + justifyContent: "center", + borderWidth: 3, + borderColor: colors.onPrimary, + }, + captureButtonInner: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: colors.accent, alignItems: "center", justifyContent: "center", - alignSelf: "center", }, permissionText: { textAlign: "center", - marginTop: 40, - color: "#fff", - fontSize: 18, - paddingHorizontal: 20, + marginTop: spacing.xxl, + color: colors.onPrimary, + fontSize: typography.fontSize.lg, + paddingHorizontal: spacing.xxxl, + lineHeight: typography.fontSize.lg * typography.lineHeight.normal, }, - button: { - marginVertical: 20, - paddingHorizontal: 20, - paddingVertical: 15, - backgroundColor: "#0066cc", - borderRadius: 25, + permissionButton: { + flexDirection: "row", alignItems: "center", justifyContent: "center", - minWidth: 100, - }, - buttonDisabled: { - backgroundColor: "#5f8cbf", + gap: spacing.sm, + marginTop: spacing.xxl, + marginHorizontal: spacing.xxxl, + paddingHorizontal: spacing.xxl, + paddingVertical: spacing.lg, + backgroundColor: colors.accent, + borderRadius: borderRadius.xl, + minHeight: touchTarget.recommended, }, - buttonText: { - color: "#fff", - fontSize: 16, - fontWeight: "600", + permissionButtonText: { + color: colors.onPrimary, + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.semibold, }, resultsContainer: { flex: 1, - backgroundColor: "#fff", - padding: 20, + backgroundColor: colors.background, + padding: spacing.lg, }, capturedImage: { width: "100%", - height: 300, - marginBottom: 20, + height: 200, + borderRadius: borderRadius.lg, + marginBottom: spacing.lg, + }, + resultsHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: spacing.md, }, resultsTitle: { - fontSize: 20, - fontWeight: "600", - marginBottom: 10, - color: "#333", + fontSize: typography.fontSize.xl, + fontWeight: typography.fontWeight.semibold, + color: colors.textPrimary, }, - resultsText: { - fontSize: 14, - color: "#666", - marginBottom: 20, - padding: 10, - backgroundColor: "#f5f5f5", - borderRadius: 8, + savedBadge: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + backgroundColor: colors.successLight, + borderRadius: borderRadius.full, + }, + savedBadgeText: { + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.medium, + color: colors.success, }, - savedBanner: { - fontSize: 14, - fontWeight: "600", - color: "#1b6f3a", - marginBottom: 16, + resultsText: { + fontSize: typography.fontSize.sm, + color: colors.textSecondary, + marginBottom: spacing.lg, + padding: spacing.md, + backgroundColor: colors.muted, + borderRadius: borderRadius.md, + lineHeight: typography.fontSize.sm * typography.lineHeight.relaxed, }, contactInfoContainer: { - marginBottom: 20, + marginBottom: spacing.xl, + backgroundColor: colors.surface, + borderRadius: borderRadius.lg, + padding: spacing.lg, + ...shadows.sm, + }, + contactInfoRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: spacing.md, + marginBottom: spacing.md, + }, + contactInfoTextContainer: { + flex: 1, }, contactInfoLabel: { - fontSize: 16, - fontWeight: "600", - color: "#333", - marginBottom: 4, + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.medium, + color: colors.textMuted, + marginBottom: 2, }, contactInfoValue: { - fontSize: 16, - color: "#666", - marginBottom: 12, + fontSize: typography.fontSize.md, + color: colors.textPrimary, + fontWeight: typography.fontWeight.medium, }, buttonContainer: { flexDirection: "row", - justifyContent: "space-around", - flexWrap: "wrap", - gap: 12, + gap: spacing.sm, + }, + resultButton: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: spacing.xs, + paddingVertical: spacing.md, + borderRadius: borderRadius.lg, + backgroundColor: colors.secondary, + minHeight: touchTarget.recommended, + }, + resultButtonPrimary: { + backgroundColor: colors.accent, + }, + resultButtonDisabled: { + backgroundColor: colors.success, + }, + resultButtonText: { + color: colors.onPrimary, + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeight.semibold, }, }); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index e3677f090..1f156b06a 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -14,6 +14,7 @@ import { DEFAULT_APP_SETTINGS, } from "../types/settings"; import { createContact } from "../types/contact"; +import { colors, spacing, borderRadius, typography, touchTarget, shadows } from "../theme"; import { showErrorAlert } from "../utils/errorHandler"; import storageUtils from "../utils/storage"; import { shouldDisableCameraForE2ESync } from "../utils/launchArgs"; @@ -284,68 +285,95 @@ const SettingsScreen = () => { - - OCR Settings + + + + OCR Languages + + + + Select languages for business card text recognition - {AVAILABLE_LANGUAGES.map((language) => ( - + + {AVAILABLE_LANGUAGES.map((language) => ( toggleLanguage(language)} testID={`language-toggle-${language}`} + accessibilityLabel={`${ocrLanguages.includes(language) ? "Deselect" : "Select"} ${LANGUAGE_NAMES[language] ?? language}`} + accessibilityRole="button" + accessibilityState={{ selected: ocrLanguages.includes(language) }} > {LANGUAGE_NAMES[language] ?? language} - - ))} + ))} + - - General Settings - - - - Auto-save Contacts + + + + General + + + + + Auto-save Contacts + + + Automatically save scanned contacts + + + - - Notifications - + + + Notifications + + + Receive alerts for contact updates + + + - - Data Usage - + + + Data Usage + + + Control network usage for OCR + + { ]} onPress={() => handleDataUsageChange("wifi-only")} testID="wifi-only-option" + accessibilityLabel="Wi-Fi only" + accessibilityRole="button" + accessibilityState={{ selected: dataUsage === "wifi-only" }} > - - Wi-Fi Only + + + Wi-Fi { ]} onPress={() => handleDataUsageChange("cellular")} testID="cellular-option" + accessibilityLabel="Cellular" + accessibilityRole="button" + accessibilityState={{ selected: dataUsage === "cellular" }} > - - Cellular + + + All @@ -376,63 +432,71 @@ const SettingsScreen = () => { - - Data Management - + + + + Data Management + + - - + + Export Data + + - - + + Import Data + + - - + + Reset App + {__DEV__ || isE2E ? ( - QA Tools + + + QA Tools + - - Load Sample Contacts + + Load Sample Contacts + ) : null} @@ -445,29 +509,38 @@ export default SettingsScreen; const Styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#fff", + backgroundColor: colors.background, }, header: { - padding: 16, - backgroundColor: "#0066cc", - borderBottomWidth: 1, - borderColor: "#eee", + padding: spacing.lg, + backgroundColor: headerColors.background, + ...shadows.sm, }, headerTitle: { - color: "#fff", - fontSize: 20, - fontWeight: "600", + color: headerColors.text, + fontSize: typography.fontSize.xl, + fontWeight: typography.fontWeight.semibold, }, section: { - padding: 16, - marginBottom: 16, - backgroundColor: "#fff", + padding: spacing.lg, + marginBottom: spacing.sm, + backgroundColor: colors.surface, + }, + sectionHeader: { + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + marginBottom: spacing.xs, }, sectionTitle: { - fontSize: 18, - fontWeight: "600", - color: "#333", - marginBottom: 12, + fontSize: typography.fontSize.lg, + fontWeight: typography.fontWeight.semibold, + color: colors.textPrimary, + }, + sectionDescription: { + fontSize: typography.fontSize.sm, + color: colors.textSecondary, + marginBottom: spacing.md, }, loadingContainer: { flex: 1, @@ -475,67 +548,98 @@ const Styles = StyleSheet.create({ alignItems: "center", }, loadingText: { - fontSize: 18, - color: "#666", + fontSize: typography.fontSize.md, + color: colors.textSecondary, }, - languageRow: { - marginBottom: 10, + languageGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: spacing.sm, }, - languageButton: { - paddingHorizontal: 12, - paddingVertical: 10, + languageChip: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, borderWidth: 1, - borderColor: "#d0d7e2", - borderRadius: 8, + borderColor: colors.border, + borderRadius: borderRadius.full, + backgroundColor: colors.surface, + minHeight: touchTarget.minimum, + alignItems: "center", + justifyContent: "center", }, - selectedLanguage: { - backgroundColor: "#e6f0ff", - borderColor: "#0066cc", + languageChipSelected: { + backgroundColor: colors.accent, + borderColor: colors.accent, }, - languageText: { - fontSize: 15, - color: "#333", + languageChipText: { + fontSize: typography.fontSize.sm, + color: colors.textPrimary, + }, + languageChipTextSelected: { + color: colors.onPrimary, + fontWeight: typography.fontWeight.medium, }, settingRow: { - marginBottom: 16, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: spacing.md, + minHeight: touchTarget.recommended, + }, + settingTextContainer: { + flex: 1, + marginRight: spacing.md, }, settingLabel: { - fontSize: 16, - color: "#333", - marginBottom: 8, + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.medium, + color: colors.textPrimary, + }, + settingDescription: { + fontSize: typography.fontSize.xs, + color: colors.textSecondary, + marginTop: 2, + }, + settingDivider: { + height: 1, + backgroundColor: colors.borderLight, }, dataUsageOptions: { flexDirection: "row", - gap: 12, + gap: spacing.sm, }, dataUsageOption: { - paddingHorizontal: 12, - paddingVertical: 10, + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, borderWidth: 1, - borderColor: "#d0d7e2", - borderRadius: 8, + borderColor: colors.border, + borderRadius: borderRadius.full, + minHeight: touchTarget.minimum, }, selectedDataUsage: { - backgroundColor: "#e6f0ff", - borderColor: "#0066cc", + backgroundColor: colors.accent, + borderColor: colors.accent, }, dataUsageText: { - fontSize: 15, - color: "#333", + fontSize: typography.fontSize.sm, + color: colors.textPrimary, + }, + dataUsageTextSelected: { + color: colors.onPrimary, }, - button: { + managementButton: { flexDirection: "row", alignItems: "center", - justifyContent: "center", - gap: 8, - marginBottom: 12, - paddingVertical: 14, - borderRadius: 24, - backgroundColor: "#0066cc", + gap: spacing.md, + paddingVertical: spacing.md, + minHeight: touchTarget.recommended, }, - buttonText: { - color: "#fff", - fontSize: 16, - fontWeight: "600", + managementButtonText: { + flex: 1, + fontSize: typography.fontSize.md, + color: colors.textPrimary, }, }); diff --git a/src/theme.ts b/src/theme.ts new file mode 100644 index 000000000..518779394 --- /dev/null +++ b/src/theme.ts @@ -0,0 +1,148 @@ +/** + * CrewCircle Design Tokens + * Based on UI/UX Pro Max recommendations for business card scanner app + * Style: Flat Design Mobile (Touch-First) + */ + +export const colors = { + // Primary palette + primary: '#1E293B', + onPrimary: '#FFFFFF', + secondary: '#334155', + accent: '#2563EB', // Scan blue - used for CTAs and active states + + // Backgrounds + background: '#F8FAFC', + surface: '#FFFFFF', + muted: '#F1F2F3', + + // Text + foreground: '#0F172A', + textPrimary: '#0F172A', + textSecondary: '#64748B', + textMuted: '#94A3B8', + + // Borders + border: '#E4E5E7', + borderLight: '#F1F5F9', + + // Semantic + destructive: '#DC2626', + destructiveLight: '#FEE2E2', + success: '#16A34A', + successLight: '#DCFCE7', + warning: '#D97706', + warningLight: '#FEF3C7', + info: '#2563EB', + infoLight: '#DBEAFE', + + // Interactive states + focusRing: '#1E293B', + pressed: '#0F172A', + + // Overlay + overlay: 'rgba(15, 23, 42, 0.5)', +} as const; + +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 20, + xxl: 24, + xxxl: 32, +} as const; + +export const borderRadius = { + sm: 4, + md: 8, + lg: 12, + xl: 16, + full: 9999, +} as const; + +export const typography = { + fontFamily: { + regular: 'Inter', + medium: 'Inter', + semibold: 'Inter', + bold: 'Inter', + }, + fontSize: { + xs: 12, + sm: 14, + md: 16, + lg: 18, + xl: 20, + xxl: 24, + xxxl: 32, + }, + fontWeight: { + regular: '400' as const, + medium: '500' as const, + semibold: '600' as const, + bold: '700' as const, + }, + lineHeight: { + tight: 1.25, + normal: 1.5, + relaxed: 1.75, + }, +} as const; + +export const touchTarget = { + minimum: 44, // 44x44pt minimum per Apple HIG + recommended: 48, // 48x48dp recommended per Material Design +} as const; + +export const shadows = { + none: undefined, + sm: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + md: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + lg: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 5, + }, +} as const; + +// Screen-specific header colors (maintaining existing blue header pattern) +export const headerColors = { + background: colors.accent, // #2563EB + text: colors.onPrimary, +} as const; + +export type Theme = { + colors: typeof colors; + spacing: typeof spacing; + borderRadius: typeof borderRadius; + typography: typeof typography; + touchTarget: typeof touchTarget; + shadows: typeof shadows; + headerColors: typeof headerColors; +}; + +export const theme: Theme = { + colors, + spacing, + borderRadius, + typography, + touchTarget, + shadows, + headerColors, +};