From c8fe67f346605764b26f368040d057b5dd082eac Mon Sep 17 00:00:00 2001 From: root Date: Thu, 19 Feb 2026 22:05:40 +0800 Subject: [PATCH] feat: Sprint 5 - Toast, ConfirmDialog, EmptyStates, Settings, Export/Import, Accessibility (TERLOG-52/53/54/55/56/57/58/59) --- app/(tabs)/contacts.tsx | 59 +- app/(tabs)/index.tsx | 87 +- app/(tabs)/settings.tsx | 1093 +++++++++-------- app/(tabs)/territories.tsx | 50 +- app/_layout.tsx | 41 +- app/contact/[id].tsx | 73 +- app/territory/[id].tsx | 65 +- components/contacts/AddContactSheet.tsx | 33 +- components/contacts/ContactCard.tsx | 10 +- components/contacts/EditContactSheet.tsx | 31 +- components/territories/AddTerritorySheet.tsx | 11 +- components/territories/EditTerritorySheet.tsx | 11 +- components/ui/Button.tsx | 4 + components/ui/ConfirmDialog.tsx | 166 +++ components/ui/DatePicker.tsx | 12 +- components/ui/EmptyState.tsx | 107 ++ components/ui/Input.tsx | 2 + components/ui/Toast.tsx | 127 ++ components/visits/LogVisitSheet.tsx | 21 +- components/visits/TopicSelector.tsx | 32 +- 20 files changed, 1416 insertions(+), 619 deletions(-) create mode 100644 components/ui/ConfirmDialog.tsx create mode 100644 components/ui/EmptyState.tsx create mode 100644 components/ui/Toast.tsx diff --git a/app/(tabs)/contacts.tsx b/app/(tabs)/contacts.tsx index f13ba9d..d898124 100644 --- a/app/(tabs)/contacts.tsx +++ b/app/(tabs)/contacts.tsx @@ -7,6 +7,7 @@ import { useContactStore, Contact } from '@/store/useContactStore'; import { getDatabase } from '@/lib/database'; import { ContactCard } from '@/components/contacts/ContactCard'; import { AddContactSheet } from '@/components/contacts/AddContactSheet'; +import { EmptyState } from '@/components/ui/EmptyState'; import { dbToContact } from '@/lib/contactHelpers'; export default function ContactsScreen() { @@ -26,7 +27,6 @@ export default function ContactsScreen() { ); setContacts(rows.map(dbToContact)); - // Load territory codes for filter const tRows = await db.getAllAsync('SELECT territory_code FROM territories ORDER BY territory_code ASC'); setTerritories(tRows.map((r: any) => r.territory_code)); } @@ -41,13 +41,21 @@ export default function ContactsScreen() { return matchSearch && matchStatus && matchTerritory; }); + const isFiltered = search.length > 0 || statusFilter !== 'All' || territoryFilter !== 'All'; + return ( {/* Header */} - Contacts - setShowAdd(true)} className="bg-white/20 rounded-full p-2"> + Contacts + setShowAdd(true)} + className="bg-white/20 rounded-full p-2" + accessibilityRole="button" + accessibilityLabel="Add new contact" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > @@ -60,8 +68,18 @@ export default function ContactsScreen() { placeholderTextColor="rgba(255,255,255,0.6)" value={search} onChangeText={setSearch} + accessibilityLabel="Search contacts" /> - {search ? setSearch('')}> : null} + {search ? ( + setSearch('')} + accessibilityRole="button" + accessibilityLabel="Clear search" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + ) : null} @@ -77,6 +95,10 @@ export default function ContactsScreen() { setStatusFilter(item)} className={`px-3 py-1.5 rounded-full border ${statusFilter === item ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`} + accessibilityRole="button" + accessibilityLabel={`Filter by ${item}`} + accessibilityState={{ selected: statusFilter === item }} + style={{ minHeight: 36 }} > {item} @@ -97,6 +119,10 @@ export default function ContactsScreen() { setTerritoryFilter(item)} className={`px-3 py-1 rounded-full border ${territoryFilter === item ? 'bg-accent border-accent' : 'bg-white border-gray-200'}`} + accessibilityRole="button" + accessibilityLabel={`Filter by territory ${item === 'All' ? 'all' : item}`} + accessibilityState={{ selected: territoryFilter === item }} + style={{ minHeight: 32 }} > {item === 'All' ? 'All Territories' : item} @@ -111,14 +137,23 @@ export default function ContactsScreen() { keyExtractor={(item) => item.id} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 100, paddingTop: 4, gap: 8 }} renderItem={({ item }) => } - ListEmptyComponent={() => ( - - - {search ? 'No contacts found' : 'No contacts yet'} - - {!search && Tap + to add your first contact} - - )} + ListEmptyComponent={() => + isFiltered ? ( + + ) : ( + setShowAdd(true)} + /> + ) + } /> setShowAdd(false)} onSaved={() => { setShowAdd(false); loadContacts(); }} /> diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index c0ae696..937b933 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,11 +1,10 @@ -import { View, Text, ScrollView, TouchableOpacity, Modal, FlatList } from 'react-native'; +import { View, Text, ScrollView, TouchableOpacity, Modal } from 'react-native'; import { useState, useCallback } from 'react'; import { useFocusEffect, router } from 'expo-router'; -import { Plus, Users, Calendar, BookOpen, ChevronRight, X } from 'lucide-react-native'; +import { Plus, ChevronRight } from 'lucide-react-native'; import { useUserStore } from '@/store/useUserStore'; import { getDatabase } from '@/lib/database'; -import { Contact } from '@/store/useContactStore'; -import { dbToContact } from '@/lib/contactHelpers'; +import { EmptyState } from '@/components/ui/EmptyState'; import { formatDate } from '@/lib/visitHelpers'; interface Stats { @@ -29,8 +28,7 @@ export default function HomeScreen() { async function loadDashboard() { const db = await getDatabase(); - const now = Math.floor(Date.now() / 1000); - const todayEnd = now + 86400 * 3; // next 3 days + const todayEnd = Math.floor(Date.now() / 1000) + 86400 * 3; // next 3 days const contacts = await db.getAllAsync('SELECT status FROM contacts WHERE deleted_at IS NULL'); const total = contacts.length; @@ -46,7 +44,7 @@ export default function HomeScreen() { ); setStats({ total, returnVisits, bibleStudies, visitsDue: due.length }); - setDueVisits(due.map((d) => ({ contactId: d.contact_id, contactName: d.full_name, nextVisitDate: d.next_visit_date }))); + setDueVisits(due.map((d: any) => ({ contactId: d.contact_id, contactName: d.full_name, nextVisitDate: d.next_visit_date }))); } useFocusEffect(useCallback(() => { loadDashboard(); }, [])); @@ -81,24 +79,35 @@ export default function HomeScreen() { {/* Return Visits Due */} - {dueVisits.length > 0 && ( - - ⏰ Visits Due Soon - {dueVisits.slice(0, 5).map((v) => ( - router.push({ pathname: '/contact/[id]', params: { id: v.contactId } })} - className="flex-row items-center justify-between py-2 border-b border-gray-50" - > - {v.contactName} - {formatDate(v.nextVisitDate)} - - ))} - {dueVisits.length > 5 && ( - +{dueVisits.length - 5} more - )} - - )} + + ⏰ Visits Due Soon + {dueVisits.length === 0 ? ( + + ) : ( + <> + {dueVisits.slice(0, 5).map((v) => ( + router.push({ pathname: '/contact/[id]', params: { id: v.contactId } })} + className="flex-row items-center justify-between py-2 border-b border-gray-50" + accessibilityRole="button" + accessibilityLabel={`Visit ${v.contactName} on ${formatDate(v.nextVisitDate)}`} + style={{ minHeight: 44 }} + > + {v.contactName} + {formatDate(v.nextVisitDate)} + + ))} + {dueVisits.length > 5 && ( + +{dueVisits.length - 5} more + )} + + )} + {/* Quick actions */} @@ -114,13 +123,21 @@ export default function HomeScreen() { onPress={() => setShowFAB(true)} className="absolute bottom-8 right-6 w-14 h-14 bg-primary rounded-full items-center justify-center shadow-lg" style={{ elevation: 8 }} + accessibilityRole="button" + accessibilityLabel="Quick add" + accessibilityHint="Open quick actions menu to add a contact or log a visit" > {/* FAB Action Sheet */} - setShowFAB(false)}> + setShowFAB(false)} + accessibilityRole="button" + accessibilityLabel="Close menu" + > Quick Add + {icon} {value} {label} @@ -157,7 +174,13 @@ function StatCard({ label, value, color, icon }: { label: string; value: number; function QuickAction({ icon, label, onPress }: { icon: string; label: string; onPress: () => void }) { return ( - + {icon} {label} @@ -167,7 +190,13 @@ function QuickAction({ icon, label, onPress }: { icon: string; label: string; on function FABAction({ icon, label, sub, onPress }: { icon: string; label: string; sub: string; onPress: () => void }) { return ( - + {icon} {label} diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 8a07920..33c25a3 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,574 +1,679 @@ -// app/(tabs)/settings.tsx — Full Settings Screen with PIN setup -import React, { useState, useEffect, useCallback } from 'react'; import { - View, - Text, - StyleSheet, - ScrollView, - Switch, - TouchableOpacity, - Alert, - Modal, - SafeAreaView, - ActivityIndicator, + View, Text, ScrollView, TouchableOpacity, TextInput, + StyleSheet, Switch, } from 'react-native'; -import { router } from 'expo-router'; +import { useState, useCallback } from 'react'; +import { randomUUID } from 'expo-crypto'; +import { useFocusEffect, router } from 'expo-router'; import { - User, - Shield, - Fingerprint, - RefreshCw, - BookOpen, - Download, - Upload, - Trash2, - Info, - ChevronRight, - X, + User, RefreshCw, BookOpen, Shield, Download, Upload, + Info, ChevronRight, Edit2, Plus, Trash2, Lock, Unlock, } from 'lucide-react-native'; -import * as LocalAuthentication from 'expo-local-authentication'; -import { useUserStore } from '@/store/useUserStore'; -import { - isPinEnabled, - setPin, - verifyPin, - disablePin, - isBiometricEnabled, - setBiometricEnabled, -} from '@/lib/pinService'; +import * as Sharing from 'expo-sharing'; +import * as DocumentPicker from 'expo-document-picker'; +import * as FileSystem from 'expo-file-system'; import { getDatabase } from '@/lib/database'; -import PinPad from '@/components/ui/PinPad'; +import { useUserStore } from '@/store/useUserStore'; +import { useToast } from '@/components/ui/Toast'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { Input } from '@/components/ui/Input'; -// ── PIN Flow Modal ──────────────────────────────────────────────────────────── -type PinFlowMode = 'set' | 'verify'; +const APP_VERSION = '1.0.0'; -interface PinFlowProps { - mode: PinFlowMode; - onSuccess: (pin?: string) => void; - onCancel: () => void; - title: string; - subtitle?: string; +interface Topic { + id: string; + name: string; + isDefault: number; } -function PinFlowModal({ mode, onSuccess, onCancel, title, subtitle }: PinFlowProps) { - const [step, setStep] = useState<'enter' | 'confirm'>('enter'); - const [pin, setCurrentPin] = useState(''); - const [firstPin, setFirstPin] = useState(''); - const [error, setError] = useState(''); - - const handlePinChange = async (newPin: string) => { - setCurrentPin(newPin); - setError(''); - - if (newPin.length < 6) return; - - if (mode === 'verify') { - const ok = await verifyPin(newPin); - if (ok) { - onSuccess(newPin); - } else { - setError('Incorrect PIN. Try again.'); - setTimeout(() => setCurrentPin(''), 600); - } - return; - } - - // mode === 'set' - if (step === 'enter') { - setFirstPin(newPin); - setCurrentPin(''); - setStep('confirm'); - } else { - // confirm step - if (newPin === firstPin) { - onSuccess(newPin); - } else { - setError('PINs do not match. Try again.'); - setStep('enter'); - setFirstPin(''); - setTimeout(() => setCurrentPin(''), 400); - } - } - }; - - const displayTitle = mode === 'set' - ? step === 'enter' ? 'Set PIN' : 'Confirm PIN' - : title; - - const displaySubtitle = mode === 'set' - ? step === 'enter' - ? 'Enter a 6-digit PIN' - : 'Re-enter your PIN to confirm' - : subtitle; - - return ( - - - - - - - - - {displayTitle} - {displaySubtitle ? ( - {displaySubtitle} - ) : null} - {error ? {error} : null} - - - - - ); +interface ImportPreview { + contacts: number; + visits: number; + territories: number; + topics: number; + raw: any; } -const pinStyles = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#0F172A' }, - header: { alignItems: 'flex-end', padding: 16 }, - closeBtn: { - width: 36, height: 36, borderRadius: 18, - backgroundColor: 'rgba(255,255,255,0.08)', - justifyContent: 'center', alignItems: 'center', - }, - content: { - flex: 1, alignItems: 'center', justifyContent: 'center', - paddingHorizontal: 24, gap: 28, - }, - title: { fontSize: 24, fontWeight: '700', color: '#F9FAFB' }, - subtitle: { fontSize: 15, color: '#9CA3AF', textAlign: 'center' }, - error: { fontSize: 14, color: '#F87171', textAlign: 'center' }, -}); - -// ── Settings Screen ─────────────────────────────────────────────────────────── - export default function SettingsScreen() { const user = useUserStore((s) => s.user); - const clearUser = useUserStore((s) => s.clearUser); + const { showToast } = useToast(); - const [pinEnabled, setPinEnabledState] = useState(false); - const [biometricEnabled, setBiometricEnabledState] = useState(false); - const [biometricSupported, setBiometricSupported] = useState(false); - const [loading, setLoading] = useState(true); + const [topics, setTopics] = useState([]); + const [editingName, setEditingName] = useState(false); + const [newName, setNewName] = useState(''); + const [addingTopic, setAddingTopic] = useState(false); + const [newTopic, setNewTopic] = useState(''); + const [appLockEnabled, setAppLockEnabled] = useState(false); + const [exportLoading, setExportLoading] = useState(false); + const [importLoading, setImportLoading] = useState(false); - const [pinFlowVisible, setPinFlowVisible] = useState(false); - const [pinFlowMode, setPinFlowMode] = useState<'set' | 'verify'>('set'); - const [pinFlowCallback, setPinFlowCallback] = useState<((pin?: string) => void) | null>(null); - const [pinFlowTitle, setPinFlowTitle] = useState(''); - const [pinFlowSubtitle, setPinFlowSubtitle] = useState(''); + // Confirm dialogs + const [deleteTopicConfirm, setDeleteTopicConfirm] = useState(null); + const [importConfirm, setImportConfirm] = useState(null); - useEffect(() => { - loadSecurityState(); - }, []); - - const loadSecurityState = async () => { - try { - const [pinOn, bioOn, hw, enrolled] = await Promise.all([ - isPinEnabled(), - isBiometricEnabled(), - LocalAuthentication.hasHardwareAsync(), - LocalAuthentication.isEnrolledAsync(), - ]); - setPinEnabledState(pinOn); - setBiometricEnabledState(bioOn); - setBiometricSupported(hw && enrolled); - } catch { - // ignore - } finally { - setLoading(false); - } - }; - - const openPinFlow = useCallback( - ( - mode: 'set' | 'verify', - title: string, - subtitle: string, - callback: (pin?: string) => void - ) => { - setPinFlowMode(mode); - setPinFlowTitle(title); - setPinFlowSubtitle(subtitle); - setPinFlowCallback(() => callback); - setPinFlowVisible(true); - }, - [] - ); - - const handleTogglePin = async (value: boolean) => { - if (value) { - // Turning ON: show Set PIN flow - openPinFlow('set', 'Set PIN', 'Enter a 6-digit PIN', async (pin) => { - if (pin) { - await setPin(pin); - setPinEnabledState(true); - setPinFlowVisible(false); - Alert.alert('PIN Set', 'Your app is now protected with a PIN.'); - } - }); - } else { - // Turning OFF: verify first - openPinFlow( - 'verify', - 'Disable PIN Lock', - 'Enter your current PIN to disable', - async () => { - await disablePin(); - setPinEnabledState(false); - setBiometricEnabledState(false); - setPinFlowVisible(false); - Alert.alert('PIN Disabled', 'App lock has been turned off.'); - } - ); - } - }; - - const handleToggleBiometric = async (value: boolean) => { - await setBiometricEnabled(value); - setBiometricEnabledState(value); - }; - - const handleClearData = () => { - Alert.alert( - 'Clear All Data', - 'This will permanently delete all contacts, visits, territories, and sync history. This cannot be undone.', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete Everything', - style: 'destructive', - onPress: async () => { - try { - const db = await getDatabase(); - await db.execAsync(` - DELETE FROM contacts; - DELETE FROM visits; - DELETE FROM territories; - DELETE FROM sync_log; - `); - Alert.alert('Done', 'All data has been cleared.'); - } catch { - Alert.alert('Error', 'Failed to clear data.'); - } - }, - }, - ] - ); - }; - - const handleExportBackup = () => { - Alert.alert('Export Backup', 'Export feature coming in a future sprint.'); - }; - - const handleImportBackup = () => { - Alert.alert('Import Backup', 'Import feature coming in a future sprint.'); - }; - - if (loading) { - return ( - - - - ); + async function loadTopics() { + const db = await getDatabase(); + const rows = await db.getAllAsync('SELECT id, name, is_default as isDefault FROM topics ORDER BY is_default DESC, name ASC'); + setTopics(rows); } + useFocusEffect(useCallback(() => { loadTopics(); }, [])); + + // ─── Profile ─────────────────────────────────────────────────────────────── + + async function handleSaveName() { + if (!newName.trim()) return; + const db = await getDatabase(); + await db.runAsync('UPDATE users SET display_name = ? WHERE is_self = 1', [newName.trim()]); + const currentUser = useUserStore.getState().user; + if (currentUser) { + useUserStore.getState().setUser({ ...currentUser, displayName: newName.trim() }); + } + setEditingName(false); + showToast('Name updated', 'success'); + } + + // ─── Topics ──────────────────────────────────────────────────────────────── + + async function handleAddTopic() { + if (!newTopic.trim()) return; + const db = await getDatabase(); + const id = randomUUID(); + const now = Math.floor(Date.now() / 1000); + try { + await db.runAsync('INSERT INTO topics (id, name, is_default, created_at) VALUES (?, ?, 0, ?)', [id, newTopic.trim(), now]); + setNewTopic(''); + setAddingTopic(false); + loadTopics(); + showToast('Topic added', 'success'); + } catch { + showToast('Topic name already exists', 'error'); + } + } + + async function handleDeleteTopic(topic: Topic) { + const db = await getDatabase(); + await db.runAsync('DELETE FROM topics WHERE id = ?', [topic.id]); + loadTopics(); + showToast('Topic deleted', 'success'); + } + + // ─── Export ──────────────────────────────────────────────────────────────── + + async function handleExport() { + if (exportLoading) return; + setExportLoading(true); + try { + const db = await getDatabase(); + const [contacts, visits, territories, topicsData, users] = await Promise.all([ + db.getAllAsync('SELECT * FROM contacts'), + db.getAllAsync('SELECT * FROM visits'), + db.getAllAsync('SELECT * FROM territories'), + db.getAllAsync('SELECT * FROM topics'), + db.getAllAsync('SELECT id, display_name, share_id, is_self, created_at FROM users'), + ]); + + const backup = { + version: 1, + appVersion: APP_VERSION, + exportedAt: new Date().toISOString(), + data: { contacts, visits, territories, topics: topicsData, users }, + }; + + const json = JSON.stringify(backup, null, 2); + const filename = `territorylog-backup-${new Date().toISOString().split('T')[0]}.json`; + const path = `${FileSystem.cacheDirectory}${filename}`; + await FileSystem.writeAsStringAsync(path, json, { encoding: FileSystem.EncodingType.UTF8 }); + + const canShare = await Sharing.isAvailableAsync(); + if (!canShare) { + showToast('Sharing is not available on this device', 'error'); + return; + } + + await Sharing.shareAsync(path, { + mimeType: 'application/json', + dialogTitle: 'Export TerritoryLog Backup', + UTI: 'public.json', + }); + showToast('Backup exported successfully', 'success'); + } catch (e: any) { + showToast(`Export failed: ${e?.message ?? 'Unknown error'}`, 'error'); + } finally { + setExportLoading(false); + } + } + + // ─── Import ──────────────────────────────────────────────────────────────── + + async function handlePickImport() { + if (importLoading) return; + setImportLoading(true); + try { + const result = await DocumentPicker.getDocumentAsync({ type: 'application/json', copyToCacheDirectory: true }); + if (result.canceled || !result.assets?.[0]) { + return; + } + const uri = result.assets[0].uri; + const json = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.UTF8 }); + const parsed = JSON.parse(json); + + if (!parsed?.version || !parsed?.data) { + showToast('Invalid backup file format', 'error'); + return; + } + + const preview: ImportPreview = { + contacts: parsed.data.contacts?.length ?? 0, + visits: parsed.data.visits?.length ?? 0, + territories: parsed.data.territories?.length ?? 0, + topics: parsed.data.topics?.filter((t: any) => !t.is_default)?.length ?? 0, + raw: parsed, + }; + setImportConfirm(preview); + } catch (e: any) { + showToast(`Failed to read file: ${e?.message ?? 'Unknown error'}`, 'error'); + } finally { + setImportLoading(false); + } + } + + async function handleImportConfirmed(preview: ImportPreview) { + setImportLoading(true); + try { + const db = await getDatabase(); + const { data } = preview.raw; + let importedContacts = 0, importedVisits = 0, importedTerritories = 0; + + // Merge territories + for (const t of (data.territories ?? [])) { + const existing = await db.getFirstAsync('SELECT id, updated_at FROM territories WHERE id = ?', [t.id]); + if (!existing) { + await db.runAsync( + 'INSERT OR IGNORE INTO territories (id, territory_code, municipality, barangay, area, block, assigned_to, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)', + [t.id, t.territory_code, t.municipality, t.barangay, t.area, t.block, t.assigned_to, t.created_at, t.updated_at] + ); + importedTerritories++; + } else if (t.updated_at > existing.updated_at) { + await db.runAsync( + 'UPDATE territories SET territory_code=?,municipality=?,barangay=?,area=?,block=?,assigned_to=?,updated_at=? WHERE id=?', + [t.territory_code, t.municipality, t.barangay, t.area, t.block, t.assigned_to, t.updated_at, t.id] + ); + importedTerritories++; + } + } + + // Merge contacts + for (const c of (data.contacts ?? [])) { + const existing = await db.getFirstAsync('SELECT id, updated_at FROM contacts WHERE id = ?', [c.id]); + if (!existing) { + await db.runAsync( + `INSERT OR IGNORE INTO contacts + (id, owner_id, full_name, address, household_count, gender, category, status, tags, notes, territory_code, latitude, longitude, created_at, updated_at, deleted_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [c.id, c.owner_id, c.full_name, c.address, c.household_count, c.gender, c.category, c.status, c.tags, c.notes, c.territory_code, c.latitude, c.longitude, c.created_at, c.updated_at, c.deleted_at] + ); + importedContacts++; + } else if (c.updated_at > existing.updated_at) { + await db.runAsync( + `UPDATE contacts SET full_name=?,address=?,household_count=?,gender=?,category=?,status=?,tags=?,notes=?,territory_code=?,latitude=?,longitude=?,updated_at=?,deleted_at=? WHERE id=?`, + [c.full_name, c.address, c.household_count, c.gender, c.category, c.status, c.tags, c.notes, c.territory_code, c.latitude, c.longitude, c.updated_at, c.deleted_at, c.id] + ); + importedContacts++; + } + } + + // Merge visits + for (const v of (data.visits ?? [])) { + const existing = await db.getFirstAsync('SELECT id, updated_at FROM visits WHERE id = ?', [v.id]); + if (!existing) { + await db.runAsync( + `INSERT OR IGNORE INTO visits (id, contact_id, visited_by_name, visited_by_id, visit_date, topic, response, remarks, next_visit_date, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + [v.id, v.contact_id, v.visited_by_name, v.visited_by_id, v.visit_date, v.topic, v.response, v.remarks, v.next_visit_date, v.created_at, v.updated_at] + ); + importedVisits++; + } else if (v.updated_at > existing.updated_at) { + await db.runAsync( + `UPDATE visits SET topic=?,response=?,remarks=?,next_visit_date=?,updated_at=? WHERE id=?`, + [v.topic, v.response, v.remarks, v.next_visit_date, v.updated_at, v.id] + ); + importedVisits++; + } + } + + // Merge custom topics + for (const t of (data.topics ?? [])) { + if (t.is_default) continue; + await db.runAsync( + 'INSERT OR IGNORE INTO topics (id, name, is_default, created_at) VALUES (?,?,0,?)', + [t.id, t.name, t.created_at] + ); + } + + showToast(`Imported: ${importedContacts} contacts, ${importedVisits} visits, ${importedTerritories} territories`, 'success'); + } catch (e: any) { + showToast(`Import failed: ${e?.message ?? 'Unknown error'}`, 'error'); + } finally { + setImportLoading(false); + setImportConfirm(null); + } + } + + // ─── Render ───────────────────────────────────────────────────────────────── + + const customTopics = topics.filter((t) => !t.isDefault); + const defaultTopics = topics.filter((t) => t.isDefault); + return ( - - - {/* Header */} - Settings + + {/* Header */} + + Settings + - {/* ── Profile ── */} - - - - - - - - {user?.displayName ?? '—'} - - Share ID: {user?.shareId ?? '—'} - - - - + - {/* ── Security ── */} - + {/* ── My Profile ── */} + } title="My Profile" /> - } - label="App Lock (PIN)" - right={ - + - } - /> - {pinEnabled && biometricSupported && ( - <> - - } - label="Biometric Unlock" - right={ - - } - /> - + + setEditingName(false)} + accessibilityRole="button" + accessibilityLabel="Cancel name edit" + > + Cancel + + + Save + + + + ) : ( + + + Display Name + {user?.displayName ?? '—'} + Share ID + {user?.shareId ?? '—'} + + { setNewName(user?.displayName ?? ''); setEditingName(true); }} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel="Edit display name" + > + + + )} {/* ── Sync ── */} - + } title="Sync" /> } - label="Sync with Device" - right={} - onPress={() => router.push('/(tabs)/sync')} + label="Sync with Partner" + sub="Share and merge data with another publisher" + onPress={() => router.push('/sync' as any)} + icon={} /> - {/* ── Data ── */} - + {/* ── Topic Library ── */} + } title="Topic Library" /> - } - label="Topic Library" - right={} - onPress={() => - Alert.alert('Topic Library', 'Manage visit topics in a future update.') - } - /> - - } - label="Export Backup" - right={} - onPress={handleExportBackup} - /> - - } - label="Import Backup" - right={} - onPress={handleImportBackup} - /> - - } - label="Clear All Data" - labelStyle={{ color: '#EF4444' }} - right={} - onPress={handleClearData} - /> + Default Topics + {defaultTopics.map((t) => ( + + {t.name} + + Built-in + + + ))} + + + + + Custom Topics + setAddingTopic(true)} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel="Add custom topic" + > + + + + + {addingTopic && ( + + + + { setAddingTopic(false); setNewTopic(''); }} + accessibilityRole="button" + accessibilityLabel="Cancel add topic" + > + Cancel + + + Add + + + + )} + + {customTopics.length === 0 && !addingTopic ? ( + + ) : ( + customTopics.map((t) => ( + + {t.name} + setDeleteTopicConfirm(t)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + accessibilityRole="button" + accessibilityLabel={`Delete topic ${t.name}`} + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > + + + + )) + )} - {/* ── About ── */} - + {/* ── App Lock ── */} + } title="App Lock" /> - } - label="TerritoryLog" - right={v1.0.0 (Sprint 5)} - /> - - - - A local-first ministry record keeping app. All data stays on your device. - + + + PIN Lock + {appLockEnabled ? 'Enabled' : 'Disabled'} + + {appLockEnabled ? : } + { + setAppLockEnabled(val); + if (val) { + showToast('PIN lock feature coming soon', 'warning'); + } + }} + trackColor={{ false: '#D1D5DB', true: '#1A6B72' }} + thumbColor="white" + style={{ marginLeft: 12 }} + accessibilityLabel="Toggle PIN lock" + accessibilityState={{ checked: appLockEnabled }} + /> - + {/* ── Export Backup ── */} + } title="Export Backup" /> + + + Export all your data (contacts, visits, territories, topics) as a JSON file you can store safely or share with a trusted person. + + + + {exportLoading ? 'Exporting...' : 'Export Backup'} + + + + {/* ── Import Backup ── */} + } title="Import Backup" /> + + + Import a previously exported TerritoryLog backup. New records will be added; existing ones are updated only if the backup is newer. + + + + {importLoading ? 'Processing...' : 'Import Backup'} + + + + {/* ── About ── */} + } title="About" /> + + TerritoryLog v{APP_VERSION} + A privacy-first field ministry records app. + + + 🔒 All data is stored locally on your device. Nothing is sent to external servers without your explicit action. + + + - {/* PIN Flow Modal */} - {pinFlowVisible && pinFlowCallback && ( - pinFlowCallback(pin)} - onCancel={() => setPinFlowVisible(false)} + {/* Delete topic confirm */} + { if (deleteTopicConfirm) handleDeleteTopic(deleteTopicConfirm); setDeleteTopicConfirm(null); }} + onCancel={() => setDeleteTopicConfirm(null)} + /> + + {/* Import confirm */} + {importConfirm && ( + handleImportConfirmed(importConfirm)} + onCancel={() => setImportConfirm(null)} /> )} - + ); } -// ── Sub-components ────────────────────────────────────────────────────────── +// ─── Sub-components ────────────────────────────────────────────────────────── -function SectionHeader({ title }: { title: string }) { - return {title}; -} - -function Divider() { - return ; -} - -interface SettingsRowProps { - icon: React.ReactNode; - label: string; - labelStyle?: object; - right?: React.ReactNode; - onPress?: () => void; -} - -function SettingsRow({ icon, label, labelStyle, right, onPress }: SettingsRowProps) { - const Row = onPress ? TouchableOpacity : View; +function SectionHeader({ icon, title }: { icon: React.ReactNode; title: string }) { return ( - - - {icon} - {label} - - {right && {right}} - + + {icon} + {title} + ); } -// ── Styles ────────────────────────────────────────────────────────────────── +function SettingsRow({ + label, sub, onPress, icon, +}: { label: string; sub?: string; onPress: () => void; icon?: React.ReactNode }) { + return ( + + + {label} + {sub && {sub}} + + {icon} + + ); +} + +// ─── Styles ────────────────────────────────────────────────────────────────── const styles = StyleSheet.create({ - safe: { - flex: 1, - backgroundColor: '#F8F4EF', - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#F8F4EF', - }, - container: { - flex: 1, - }, - content: { + header: { + backgroundColor: '#1A6B72', + paddingTop: 56, paddingBottom: 16, + paddingHorizontal: 16, }, - pageTitle: { - fontSize: 28, + headerTitle: { + color: 'white', + fontSize: 24, fontWeight: '700', - color: '#111827', - paddingHorizontal: 20, - paddingTop: 20, - paddingBottom: 4, - }, - sectionHeader: { - fontSize: 12, - fontWeight: '600', - color: '#6B7280', - letterSpacing: 0.8, - textTransform: 'uppercase', - paddingHorizontal: 20, - paddingTop: 20, - paddingBottom: 6, }, card: { - backgroundColor: '#fff', + backgroundColor: 'white', marginHorizontal: 16, + marginBottom: 8, borderRadius: 16, - paddingVertical: 4, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 4, - elevation: 2, - }, - profileRow: { - flexDirection: 'row', - alignItems: 'center', padding: 16, - gap: 14, }, - avatar: { - width: 52, - height: 52, - borderRadius: 26, - backgroundColor: '#EFF9F9', - justifyContent: 'center', + sectionHeader: { + flexDirection: 'row', alignItems: 'center', - borderWidth: 2, - borderColor: '#B2DFDF', + gap: 6, + marginTop: 16, + marginBottom: 6, + marginHorizontal: 16, }, - profileName: { - fontSize: 17, + sectionTitle: { + fontSize: 12, fontWeight: '700', - color: '#111827', - }, - profileShareId: { - fontSize: 13, - color: '#6B7280', - }, - shareIdMono: { - fontFamily: 'monospace', color: '#1A6B72', - fontWeight: '600', + letterSpacing: 0.5, + textTransform: 'uppercase', }, - settingsRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 14, + label: { + fontSize: 12, + color: '#9CA3AF', + marginBottom: 2, }, - settingsRowLeft: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - flex: 1, - }, - rowIcon: { - width: 32, - height: 32, - borderRadius: 8, - backgroundColor: '#F3F4F6', - justifyContent: 'center', - alignItems: 'center', - }, - rowLabel: { + value: { fontSize: 15, - color: '#111827', + color: '#2C3E50', fontWeight: '500', - }, - settingsRowRight: { - flexShrink: 0, - marginLeft: 8, + marginBottom: 2, }, divider: { height: 1, backgroundColor: '#F3F4F6', - marginHorizontal: 16, }, - versionText: { - fontSize: 13, - color: '#9CA3AF', + iconBtn: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#F3F4F6', + alignItems: 'center', + justifyContent: 'center', }, - aboutRow: { + smallBtn: { + paddingVertical: 10, paddingHorizontal: 16, - paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + minHeight: 40, + justifyContent: 'center', }, - aboutText: { - fontSize: 13, - color: '#6B7280', - lineHeight: 20, + actionBtn: { + backgroundColor: '#1A6B72', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 14, + paddingHorizontal: 20, + borderRadius: 12, + minHeight: 48, + }, + actionBtnText: { + color: 'white', + fontWeight: '600', + fontSize: 15, + }, + topicRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: '#F3F4F6', + minHeight: 44, + }, + topicName: { + flex: 1, + fontSize: 14, + color: '#2C3E50', + }, + lockedBadge: { + backgroundColor: '#EFF6FF', + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + }, + lockedText: { + fontSize: 11, + color: '#3B82F6', + fontWeight: '500', + }, + topicInput: { + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 14, + color: '#2C3E50', + minHeight: 44, + }, + settingsRow: { + flexDirection: 'row', + alignItems: 'center', }, }); diff --git a/app/(tabs)/territories.tsx b/app/(tabs)/territories.tsx index 3cad8fa..8e39cf4 100644 --- a/app/(tabs)/territories.tsx +++ b/app/(tabs)/territories.tsx @@ -5,6 +5,7 @@ import { Plus, Search, X, ChevronRight } from 'lucide-react-native'; import { getDatabase } from '@/lib/database'; import { Territory, dbToTerritory } from '@/lib/territoryHelpers'; import { AddTerritorySheet } from '@/components/territories/AddTerritorySheet'; +import { EmptyState } from '@/components/ui/EmptyState'; import { router } from 'expo-router'; export default function TerritoriesScreen() { @@ -30,8 +31,14 @@ export default function TerritoriesScreen() { - Territories - setShowAdd(true)} className="bg-white/20 rounded-full p-2"> + Territories + setShowAdd(true)} + className="bg-white/20 rounded-full p-2" + accessibilityRole="button" + accessibilityLabel="Add new territory" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > @@ -43,8 +50,18 @@ export default function TerritoriesScreen() { placeholderTextColor="rgba(255,255,255,0.6)" value={search} onChangeText={setSearch} + accessibilityLabel="Search territories" /> - {search ? setSearch('')}> : null} + {search ? ( + setSearch('')} + accessibilityRole="button" + accessibilityLabel="Clear search" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + ) : null} @@ -56,6 +73,10 @@ export default function TerritoriesScreen() { router.push({ pathname: '/territory/[id]', params: { id: item.id } })} className="bg-white rounded-2xl p-4 flex-row items-center shadow-sm" + accessibilityRole="button" + accessibilityLabel={`Territory ${item.territoryCode}${item.barangay ? `, ${item.barangay}` : ''}`} + accessibilityHint="Tap to view territory details" + style={{ minHeight: 72 }} > {item.territoryCode} @@ -68,12 +89,23 @@ export default function TerritoriesScreen() { )} - ListEmptyComponent={() => ( - - {search ? 'No territories found' : 'No territories yet'} - {!search && Tap + to add your first territory} - - )} + ListEmptyComponent={() => + search.length > 0 ? ( + + ) : ( + setShowAdd(true)} + /> + ) + } /> setShowAdd(false)} onSaved={() => { setShowAdd(false); loadTerritories(); }} /> diff --git a/app/_layout.tsx b/app/_layout.tsx index 8d7ec09..21bd0c6 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -6,6 +6,7 @@ import { View, ActivityIndicator, AppState, AppStateStatus } from 'react-native' import { useUserStore } from '@/store/useUserStore'; import { getDatabase } from '@/lib/database'; import { isPinEnabled } from '@/lib/pinService'; +import { ToastProvider } from '@/components/ui/Toast'; import '../global.css'; const LOCK_AFTER_SECONDS = 60; @@ -100,25 +101,27 @@ export default function RootLayout() { return ( - - - - - - + + + + + + + + ); } diff --git a/app/contact/[id].tsx b/app/contact/[id].tsx index 40ea03d..3fc427d 100644 --- a/app/contact/[id].tsx +++ b/app/contact/[id].tsx @@ -1,12 +1,15 @@ -import { View, Text, ScrollView, TouchableOpacity, Alert } from 'react-native'; +import { View, Text, ScrollView, TouchableOpacity } from 'react-native'; import { useLocalSearchParams, router, useFocusEffect } from 'expo-router'; import { useState, useCallback } from 'react'; -import { ArrowLeft, Edit2, Trash2, MapPin } from 'lucide-react-native'; +import { ArrowLeft, Edit2, Trash2 } from 'lucide-react-native'; import { getDatabase } from '@/lib/database'; import { dbToContact } from '@/lib/contactHelpers'; import { Contact } from '@/store/useContactStore'; import { EditContactSheet } from '@/components/contacts/EditContactSheet'; import { LogVisitSheet } from '@/components/visits/LogVisitSheet'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { useToast } from '@/components/ui/Toast'; import { Visit, dbToVisit, formatDate } from '@/lib/visitHelpers'; const statusColors: Record = { @@ -23,6 +26,8 @@ export default function ContactDetailScreen() { const [visits, setVisits] = useState([]); const [showEdit, setShowEdit] = useState(false); const [showLogVisit, setShowLogVisit] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const { showToast } = useToast(); async function loadData() { const db = await getDatabase(); @@ -34,18 +39,11 @@ export default function ContactDetailScreen() { useFocusEffect(useCallback(() => { loadData(); }, [id])); - async function handleDelete() { - Alert.alert('Delete Contact', `Are you sure you want to delete ${contact?.fullName}?`, [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', style: 'destructive', - onPress: async () => { - const db = await getDatabase(); - await db.runAsync('UPDATE contacts SET deleted_at = ? WHERE id = ?', [Math.floor(Date.now() / 1000), id]); - router.back(); - } - } - ]); + async function handleDeleteConfirmed() { + const db = await getDatabase(); + await db.runAsync('UPDATE contacts SET deleted_at = ? WHERE id = ?', [Math.floor(Date.now() / 1000), id]); + showToast(`${contact?.fullName} deleted`, 'success'); + router.back(); } if (!contact) return ; @@ -56,14 +54,32 @@ export default function ContactDetailScreen() { - router.back()} className="bg-white/20 rounded-full p-2"> + router.back()} + className="bg-white/20 rounded-full p-2" + accessibilityRole="button" + accessibilityLabel="Go back" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > - setShowEdit(true)} className="bg-white/20 rounded-full p-2"> + setShowEdit(true)} + className="bg-white/20 rounded-full p-2" + accessibilityRole="button" + accessibilityLabel="Edit contact" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > - + setShowDeleteConfirm(true)} + className="bg-white/20 rounded-full p-2" + accessibilityRole="button" + accessibilityLabel="Delete contact" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > @@ -99,12 +115,22 @@ export default function ContactDetailScreen() { Visit History ({visits.length}) - setShowLogVisit(true)} className="bg-primary rounded-lg px-3 py-1.5"> + setShowLogVisit(true)} + className="bg-primary rounded-lg px-3 py-1.5" + accessibilityRole="button" + accessibilityLabel="Log a new visit" + style={{ minHeight: 36 }} + > + Log Visit {visits.length === 0 ? ( - No visits recorded yet + ) : ( visits.map((v) => ( @@ -127,6 +153,15 @@ export default function ContactDetailScreen() { onClose={() => setShowLogVisit(false)} onSaved={() => { setShowLogVisit(false); loadData(); }} /> + { setShowDeleteConfirm(false); handleDeleteConfirmed(); }} + onCancel={() => setShowDeleteConfirm(false)} + /> ); } diff --git a/app/territory/[id].tsx b/app/territory/[id].tsx index 7445cdd..760de44 100644 --- a/app/territory/[id].tsx +++ b/app/territory/[id].tsx @@ -1,4 +1,4 @@ -import { View, Text, ScrollView, TouchableOpacity, Alert } from 'react-native'; +import { View, Text, ScrollView, TouchableOpacity } from 'react-native'; import { useLocalSearchParams, router, useFocusEffect } from 'expo-router'; import { useState, useCallback } from 'react'; import { ArrowLeft, Edit2, Trash2, ChevronRight } from 'lucide-react-native'; @@ -7,12 +7,17 @@ import { Territory, dbToTerritory } from '@/lib/territoryHelpers'; import { Contact } from '@/store/useContactStore'; import { dbToContact } from '@/lib/contactHelpers'; import { EditTerritorySheet } from '@/components/territories/EditTerritorySheet'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { useToast } from '@/components/ui/Toast'; export default function TerritoryDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const [territory, setTerritory] = useState(null); const [contacts, setContacts] = useState([]); const [showEdit, setShowEdit] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const { showToast } = useToast(); async function loadData() { const db = await getDatabase(); @@ -30,15 +35,11 @@ export default function TerritoryDetailScreen() { useFocusEffect(useCallback(() => { loadData(); }, [id])); - async function handleDelete() { - Alert.alert('Delete Territory', `Delete territory ${territory?.territoryCode}? Contacts will keep their territory code but it won't link to a territory record.`, [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Delete', style: 'destructive', onPress: async () => { - const db = await getDatabase(); - await db.runAsync('DELETE FROM territories WHERE id = ?', [id]); - router.back(); - }} - ]); + async function handleDeleteConfirmed() { + const db = await getDatabase(); + await db.runAsync('DELETE FROM territories WHERE id = ?', [id]); + showToast(`Territory ${territory?.territoryCode} deleted`, 'success'); + router.back(); } if (!territory) return ; @@ -47,14 +48,32 @@ export default function TerritoryDetailScreen() { - router.back()} className="bg-white/20 rounded-full p-2"> + router.back()} + className="bg-white/20 rounded-full p-2" + accessibilityRole="button" + accessibilityLabel="Go back" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > - setShowEdit(true)} className="bg-white/20 rounded-full p-2"> + setShowEdit(true)} + className="bg-white/20 rounded-full p-2" + accessibilityRole="button" + accessibilityLabel="Edit territory" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > - + setShowDeleteConfirm(true)} + className="bg-white/20 rounded-full p-2" + accessibilityRole="button" + accessibilityLabel="Delete territory" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > @@ -80,13 +99,20 @@ export default function TerritoryDetailScreen() { Contacts in this territory ({contacts.length}) {contacts.length === 0 ? ( - No contacts assigned to this territory + ) : ( contacts.map((c) => ( router.push({ pathname: '/contact/[id]', params: { id: c.id } })} className="flex-row items-center py-2 border-b border-gray-50" + accessibilityRole="button" + accessibilityLabel={`View contact ${c.fullName}`} + style={{ minHeight: 44 }} > {c.fullName} {c.status} @@ -98,6 +124,17 @@ export default function TerritoryDetailScreen() { setShowEdit(false)} onSaved={() => { setShowEdit(false); loadData(); }} /> + + { setShowDeleteConfirm(false); handleDeleteConfirmed(); }} + onCancel={() => setShowDeleteConfirm(false)} + /> ); } diff --git a/components/contacts/AddContactSheet.tsx b/components/contacts/AddContactSheet.tsx index 1fc87f2..f98adff 100644 --- a/components/contacts/AddContactSheet.tsx +++ b/components/contacts/AddContactSheet.tsx @@ -65,11 +65,16 @@ export function AddContactSheet({ visible, onClose, onSaved }: Props) { {/* Header */} - { reset(); onClose(); }}> + { reset(); onClose(); }} + accessibilityRole="button" + accessibilityLabel="Close" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > New Contact - + @@ -80,7 +85,15 @@ export function AddContactSheet({ visible, onClose, onSaved }: Props) { Status {STATUS_OPTIONS.map((s) => ( - setStatus(s)} className={`px-3 py-1.5 rounded-full border ${status === s ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}> + setStatus(s)} + className={`px-3 py-1.5 rounded-full border ${status === s ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`} + accessibilityRole="button" + accessibilityLabel={`Status: ${s}`} + accessibilityState={{ selected: status === s }} + style={{ minHeight: 36 }} + > {s} ))} @@ -90,7 +103,15 @@ export function AddContactSheet({ visible, onClose, onSaved }: Props) { Category {CATEGORY_OPTIONS.map((c) => ( - setCategory(c)} className={`px-3 py-1.5 rounded-full border ${category === c ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}> + setCategory(c)} + className={`px-3 py-1.5 rounded-full border ${category === c ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`} + accessibilityRole="button" + accessibilityLabel={`Category: ${c}`} + accessibilityState={{ selected: category === c }} + style={{ minHeight: 36 }} + > {c} ))} @@ -101,6 +122,10 @@ export function AddContactSheet({ visible, onClose, onSaved }: Props) { {/* GPS Tag */} { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') { alert('Location permission denied'); return; } diff --git a/components/contacts/ContactCard.tsx b/components/contacts/ContactCard.tsx index fd9fde9..2641c97 100644 --- a/components/contacts/ContactCard.tsx +++ b/components/contacts/ContactCard.tsx @@ -1,4 +1,4 @@ -import { View, Text, TouchableOpacity } from 'react-native'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import { useRouter } from 'expo-router'; import { ChevronRight } from 'lucide-react-native'; import { Contact } from '@/store/useContactStore'; @@ -25,9 +25,13 @@ export function ContactCard({ contact, onRefresh }: Props) { router.push({ pathname: '/contact/[id]', params: { id: contact.id } })} + accessibilityRole="button" + accessibilityLabel={`${contact.fullName}, ${contact.status}${contact.address ? `, ${contact.address}` : ''}`} + accessibilityHint="Tap to view contact details" + style={{ minHeight: 72 }} > - {initials} + {initials} {contact.fullName} @@ -36,7 +40,7 @@ export function ContactCard({ contact, onRefresh }: Props) { {contact.status} - + ); } diff --git a/components/contacts/EditContactSheet.tsx b/components/contacts/EditContactSheet.tsx index 049e358..fa40079 100644 --- a/components/contacts/EditContactSheet.tsx +++ b/components/contacts/EditContactSheet.tsx @@ -55,9 +55,16 @@ export function EditContactSheet({ contact, visible, onClose, onSaved }: Props) - + + + Edit Contact - + @@ -65,7 +72,15 @@ export function EditContactSheet({ contact, visible, onClose, onSaved }: Props) Status {STATUS_OPTIONS.map((s) => ( - setStatus(s as any)} className={`px-3 py-1.5 rounded-full border ${status === s ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}> + setStatus(s as any)} + className={`px-3 py-1.5 rounded-full border ${status === s ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`} + accessibilityRole="button" + accessibilityLabel={`Status: ${s}`} + accessibilityState={{ selected: status === s }} + style={{ minHeight: 36 }} + > {s} ))} @@ -73,7 +88,15 @@ export function EditContactSheet({ contact, visible, onClose, onSaved }: Props) Category {CATEGORY_OPTIONS.map((c) => ( - setCategory(c as any)} className={`px-3 py-1.5 rounded-full border ${category === c ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}> + setCategory(c as any)} + className={`px-3 py-1.5 rounded-full border ${category === c ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`} + accessibilityRole="button" + accessibilityLabel={`Category: ${c}`} + accessibilityState={{ selected: category === c }} + style={{ minHeight: 36 }} + > {c} ))} diff --git a/components/territories/AddTerritorySheet.tsx b/components/territories/AddTerritorySheet.tsx index 66007ac..ea6c714 100644 --- a/components/territories/AddTerritorySheet.tsx +++ b/components/territories/AddTerritorySheet.tsx @@ -50,9 +50,16 @@ export function AddTerritorySheet({ visible, onClose, onSaved }: Props) { - { reset(); onClose(); }}> + { reset(); onClose(); }} + accessibilityRole="button" + accessibilityLabel="Close" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > + + New Territory - + { setCode(t); setErrors((e) => ({ ...e, code: '' })); }} error={errors.code} autoCapitalize="characters" /> diff --git a/components/territories/EditTerritorySheet.tsx b/components/territories/EditTerritorySheet.tsx index 5fe539c..9e34b2d 100644 --- a/components/territories/EditTerritorySheet.tsx +++ b/components/territories/EditTerritorySheet.tsx @@ -40,9 +40,16 @@ export function EditTerritorySheet({ territory, visible, onClose, onSaved }: Pro - + + + Edit Territory - + diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index ff35e50..81ea13d 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -26,6 +26,10 @@ export function Button({ label, onPress, variant = 'primary', loading, disabled className={`${base} ${variants[variant]} ${disabled || loading ? 'opacity-50' : ''}`} onPress={onPress} disabled={disabled || loading} + accessibilityRole="button" + accessibilityLabel={label} + accessibilityState={{ disabled: !!(disabled || loading) }} + style={{ minHeight: 52 }} > {loading ? ( diff --git a/components/ui/ConfirmDialog.tsx b/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..0169960 --- /dev/null +++ b/components/ui/ConfirmDialog.tsx @@ -0,0 +1,166 @@ +import { Modal, View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { AlertTriangle } from 'lucide-react-native'; + +interface Props { + visible: boolean; + title: string; + message: string; + confirmText?: string; + confirmStyle?: 'danger' | 'default'; + onConfirm: () => void; + onCancel: () => void; + impactCount?: number; +} + +export function ConfirmDialog({ + visible, + title, + message, + confirmText = 'Confirm', + confirmStyle = 'default', + onConfirm, + onCancel, + impactCount, +}: Props) { + const isDanger = confirmStyle === 'danger'; + + return ( + + + + {isDanger && ( + + + + )} + + {title} + {message} + + {impactCount !== undefined && impactCount > 0 && ( + + + This will affect {impactCount} record{impactCount !== 1 ? 's' : ''} + + + )} + + + + Cancel + + + + {confirmText} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + dialog: { + backgroundColor: 'white', + borderRadius: 20, + padding: 24, + width: '100%', + maxWidth: 400, + shadowColor: '#000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.2, + shadowRadius: 16, + elevation: 10, + }, + iconWrap: { + alignItems: 'center', + marginBottom: 12, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#2C3E50', + textAlign: 'center', + marginBottom: 8, + }, + message: { + fontSize: 14, + color: '#6B7280', + textAlign: 'center', + lineHeight: 20, + marginBottom: 12, + }, + impactBadge: { + backgroundColor: '#FEF3C7', + borderRadius: 8, + paddingVertical: 8, + paddingHorizontal: 12, + marginBottom: 20, + alignItems: 'center', + }, + impactText: { + fontSize: 12, + color: '#92400E', + fontWeight: '500', + }, + buttons: { + flexDirection: 'row', + gap: 12, + marginTop: 4, + }, + btn: { + flex: 1, + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + minHeight: 48, + justifyContent: 'center', + }, + cancelBtn: { + backgroundColor: '#F3F4F6', + }, + confirmBtn: { + backgroundColor: '#1A6B72', + }, + dangerBtn: { + backgroundColor: '#C0392B', + }, + cancelText: { + fontSize: 15, + fontWeight: '600', + color: '#374151', + }, + confirmText: { + fontSize: 15, + fontWeight: '600', + color: 'white', + }, +}); diff --git a/components/ui/DatePicker.tsx b/components/ui/DatePicker.tsx index a2bd7d8..a9324a9 100644 --- a/components/ui/DatePicker.tsx +++ b/components/ui/DatePicker.tsx @@ -40,6 +40,10 @@ export function DatePicker({ label, value, onChange }: Props) { setShow(true)} className="flex-row items-center border border-gray-200 rounded-xl px-4 py-3 bg-white" + accessibilityRole="button" + accessibilityLabel={label ? `${label}: ${displayDate}` : displayDate} + accessibilityHint="Opens date picker" + style={{ minHeight: 48 }} > {displayDate} @@ -88,7 +92,13 @@ export function DatePicker({ label, value, onChange }: Props) { - + Confirm diff --git a/components/ui/EmptyState.tsx b/components/ui/EmptyState.tsx new file mode 100644 index 0000000..41febc1 --- /dev/null +++ b/components/ui/EmptyState.tsx @@ -0,0 +1,107 @@ +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { + Users, MapPin, Map, Calendar, BookOpen, Settings, + Inbox, FileText, Bell, Star, Heart, Search, + Package, Archive, List, Clipboard, Tag, Shield, + type LucideIcon, +} from 'lucide-react-native'; + +// Map of icon name strings to actual components +const ICON_MAP: Record = { + Users, + MapPin, + Map, + Calendar, + BookOpen, + Settings, + Inbox, + FileText, + Bell, + Star, + Heart, + Search, + Package, + Archive, + List, + Clipboard, + Tag, + Shield, +}; + +interface Props { + icon?: string; + title: string; + message?: string; + actionLabel?: string; + onAction?: () => void; +} + +export function EmptyState({ icon, title, message, actionLabel, onAction }: Props) { + const IconComponent = icon ? ICON_MAP[icon] ?? Inbox : Inbox; + + return ( + + + + + {title} + {message ? {message} : null} + {actionLabel && onAction ? ( + + {actionLabel} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + paddingHorizontal: 32, + }, + iconWrap: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#F3F4F6', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + }, + title: { + fontSize: 17, + fontWeight: '600', + color: '#374151', + textAlign: 'center', + marginBottom: 6, + }, + message: { + fontSize: 14, + color: '#9CA3AF', + textAlign: 'center', + lineHeight: 20, + marginBottom: 20, + }, + button: { + backgroundColor: '#1A6B72', + paddingVertical: 12, + paddingHorizontal: 28, + borderRadius: 12, + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + }, + buttonText: { + color: 'white', + fontWeight: '600', + fontSize: 14, + }, +}); diff --git a/components/ui/Input.tsx b/components/ui/Input.tsx index a641ec9..6003911 100644 --- a/components/ui/Input.tsx +++ b/components/ui/Input.tsx @@ -14,6 +14,8 @@ export function Input({ label, error, ...props }: InputProps) { error ? 'border-danger' : 'border-gray-200' }`} placeholderTextColor="#9CA3AF" + accessibilityLabel={label} + style={{ minHeight: 48 }} {...props} /> {error && {error}} diff --git a/components/ui/Toast.tsx b/components/ui/Toast.tsx new file mode 100644 index 0000000..cb27405 --- /dev/null +++ b/components/ui/Toast.tsx @@ -0,0 +1,127 @@ +import React, { createContext, useContext, useState, useCallback, useRef } from 'react'; +import { View, Text, Animated, TouchableOpacity, StyleSheet } from 'react-native'; +import { CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react-native'; + +export type ToastType = 'success' | 'warning' | 'error'; + +interface Toast { + id: string; + message: string; + type: ToastType; +} + +interface ToastContextValue { + showToast: (message: string, type?: ToastType) => void; +} + +const ToastContext = createContext({ showToast: () => {} }); + +const COLORS: Record = { + success: '#4CAF7D', + warning: '#D4A843', + error: '#C0392B', +}; + +function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) { + const opacity = useRef(new Animated.Value(0)).current; + const translateY = useRef(new Animated.Value(-20)).current; + + React.useEffect(() => { + Animated.parallel([ + Animated.timing(opacity, { toValue: 1, duration: 250, useNativeDriver: true }), + Animated.timing(translateY, { toValue: 0, duration: 250, useNativeDriver: true }), + ]).start(); + + const timer = setTimeout(() => dismiss(), 3000); + return () => clearTimeout(timer); + }, []); + + function dismiss() { + Animated.parallel([ + Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }), + Animated.timing(translateY, { toValue: -20, duration: 200, useNativeDriver: true }), + ]).start(() => onDismiss(toast.id)); + } + + const color = COLORS[toast.type]; + const Icon = toast.type === 'success' ? CheckCircle : toast.type === 'warning' ? AlertTriangle : XCircle; + + return ( + + + {toast.message} + + + + + ); +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const counterRef = useRef(0); + + const showToast = useCallback((message: string, type: ToastType = 'success') => { + const id = `toast-${++counterRef.current}-${Date.now()}`; + setToasts((prev) => [...prev, { id, message, type }]); + }, []); + + const dismissToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return ( + + {children} + + {toasts.map((toast) => ( + + ))} + + + ); +} + +export function useToast(): ToastContextValue { + return useContext(ToastContext); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + bottom: 90, + left: 16, + right: 16, + gap: 8, + zIndex: 9999, + }, + toast: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 14, + gap: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 6, + }, + message: { + flex: 1, + color: 'white', + fontSize: 14, + fontWeight: '500', + lineHeight: 20, + }, +}); diff --git a/components/visits/LogVisitSheet.tsx b/components/visits/LogVisitSheet.tsx index 251d8c5..1cb93e6 100644 --- a/components/visits/LogVisitSheet.tsx +++ b/components/visits/LogVisitSheet.tsx @@ -64,9 +64,16 @@ export function LogVisitSheet({ contactId, contactName, visible, onClose, onSave - { reset(); onClose(); }}> + { reset(); onClose(); }} + accessibilityRole="button" + accessibilityLabel="Close" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > + + Log Visit - + @@ -78,7 +85,15 @@ export function LogVisitSheet({ contactId, contactName, visible, onClose, onSave Response {RESPONSE_OPTIONS.map((r) => ( - setResponse(r === response ? '' : r)} className={`px-3 py-1.5 rounded-full border ${response === r ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}> + setResponse(r === response ? '' : r)} + className={`px-3 py-1.5 rounded-full border ${response === r ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`} + accessibilityRole="button" + accessibilityLabel={r} + accessibilityState={{ selected: response === r }} + style={{ minHeight: 36 }} + > {r} ))} diff --git a/components/visits/TopicSelector.tsx b/components/visits/TopicSelector.tsx index 08d57cc..31082a9 100644 --- a/components/visits/TopicSelector.tsx +++ b/components/visits/TopicSelector.tsx @@ -40,7 +40,14 @@ export function TopicSelector({ value, onChange }: Props) { return ( Topic - setShow(true)} className="flex-row items-center border border-gray-200 rounded-xl px-4 py-3 bg-white"> + setShow(true)} + className="flex-row items-center border border-gray-200 rounded-xl px-4 py-3 bg-white" + accessibilityRole="button" + accessibilityLabel={value ? `Selected topic: ${value}` : 'Select topic'} + accessibilityHint="Opens topic selector" + style={{ minHeight: 48 }} + > {value || 'Select topic...'} @@ -48,9 +55,16 @@ export function TopicSelector({ value, onChange }: Props) { - setShow(false)}> + setShow(false)} + accessibilityRole="button" + accessibilityLabel="Close topic selector" + style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }} + > + + Select Topic - + @@ -66,7 +80,13 @@ export function TopicSelector({ value, onChange }: Props) { onChangeText={setNewTopic} onSubmitEditing={addCustomTopic} /> - + @@ -80,6 +100,10 @@ export function TopicSelector({ value, onChange }: Props) { { onChange(item.name); setShow(false); }} className={`bg-white rounded-xl px-4 py-3 flex-row items-center justify-between ${value === item.name ? 'border-2 border-primary' : 'border border-gray-100'}`} + accessibilityRole="button" + accessibilityLabel={item.name} + accessibilityState={{ selected: value === item.name }} + style={{ minHeight: 48 }} > {item.name} {item.is_default === 1 && Default}