import { View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Switch, } from 'react-native'; import { useState, useCallback } from 'react'; import { randomUUID } from 'expo-crypto'; import { useFocusEffect, router } from 'expo-router'; import { User, RefreshCw, BookOpen, Shield, Download, Upload, Info, ChevronRight, Edit2, Plus, Trash2, Lock, Unlock, } from 'lucide-react-native'; 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 { 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'; const APP_VERSION = '1.0.0'; interface Topic { id: string; name: string; isDefault: number; } interface ImportPreview { contacts: number; visits: number; territories: number; topics: number; raw: any; } export default function SettingsScreen() { const user = useUserStore((s) => s.user); const { showToast } = useToast(); 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); // Confirm dialogs const [deleteTopicConfirm, setDeleteTopicConfirm] = useState(null); const [importConfirm, setImportConfirm] = useState(null); 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 {/* ── My Profile ── */} } title="My Profile" /> {editingName ? ( 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" /> router.push('/sync' as any)} icon={} /> {/* ── Topic Library ── */} } title="Topic Library" /> 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' }} > )) )} {/* ── App Lock ── */} } title="App Lock" /> 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. {/* Delete topic confirm */} { if (deleteTopicConfirm) handleDeleteTopic(deleteTopicConfirm); setDeleteTopicConfirm(null); }} onCancel={() => setDeleteTopicConfirm(null)} /> {/* Import confirm */} {importConfirm && ( handleImportConfirmed(importConfirm)} onCancel={() => setImportConfirm(null)} /> )} ); } // ─── Sub-components ────────────────────────────────────────────────────────── function SectionHeader({ icon, title }: { icon: React.ReactNode; title: string }) { return ( {icon} {title} ); } 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({ header: { backgroundColor: '#1A6B72', paddingTop: 56, paddingBottom: 16, paddingHorizontal: 16, }, headerTitle: { color: 'white', fontSize: 24, fontWeight: '700', }, card: { backgroundColor: 'white', marginHorizontal: 16, marginBottom: 8, borderRadius: 16, padding: 16, }, sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 16, marginBottom: 6, marginHorizontal: 16, }, sectionTitle: { fontSize: 12, fontWeight: '700', color: '#1A6B72', letterSpacing: 0.5, textTransform: 'uppercase', }, label: { fontSize: 12, color: '#9CA3AF', marginBottom: 2, }, value: { fontSize: 15, color: '#2C3E50', fontWeight: '500', marginBottom: 2, }, divider: { height: 1, backgroundColor: '#F3F4F6', }, iconBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: '#F3F4F6', alignItems: 'center', justifyContent: 'center', }, smallBtn: { paddingVertical: 10, paddingHorizontal: 16, borderRadius: 10, alignItems: 'center', minHeight: 40, justifyContent: 'center', }, 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', }, });