Files
territory-log/app/(tabs)/settings.tsx

680 lines
26 KiB
TypeScript

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<Topic[]>([]);
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<Topic | null>(null);
const [importConfirm, setImportConfirm] = useState<ImportPreview | null>(null);
async function loadTopics() {
const db = await getDatabase();
const rows = await db.getAllAsync<Topic>('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<any>('SELECT * FROM contacts'),
db.getAllAsync<any>('SELECT * FROM visits'),
db.getAllAsync<any>('SELECT * FROM territories'),
db.getAllAsync<any>('SELECT * FROM topics'),
db.getAllAsync<any>('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<any>('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<any>('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<any>('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 (
<View style={{ flex: 1, backgroundColor: '#F8F4EF' }}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle} accessibilityRole="header">Settings</Text>
</View>
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 100 }}>
{/* ── My Profile ── */}
<SectionHeader icon={<User size={16} color="#1A6B72" />} title="My Profile" />
<View style={styles.card}>
{editingName ? (
<View>
<Input
label="Display Name"
value={newName}
onChangeText={setNewName}
placeholder="Enter your name"
autoFocus
/>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 8 }}>
<TouchableOpacity
style={[styles.smallBtn, { backgroundColor: '#F3F4F6', flex: 1 }]}
onPress={() => setEditingName(false)}
accessibilityRole="button"
accessibilityLabel="Cancel name edit"
>
<Text style={{ color: '#374151', fontWeight: '600' }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.smallBtn, { backgroundColor: '#1A6B72', flex: 1 }]}
onPress={handleSaveName}
accessibilityRole="button"
accessibilityLabel="Save new name"
>
<Text style={{ color: 'white', fontWeight: '600' }}>Save</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Display Name</Text>
<Text style={styles.value}>{user?.displayName ?? '—'}</Text>
<Text style={styles.label}>Share ID</Text>
<Text style={[styles.value, { fontFamily: 'monospace', fontSize: 12 }]}>{user?.shareId ?? '—'}</Text>
</View>
<TouchableOpacity
onPress={() => { setNewName(user?.displayName ?? ''); setEditingName(true); }}
style={styles.iconBtn}
accessibilityRole="button"
accessibilityLabel="Edit display name"
>
<Edit2 size={16} color="#1A6B72" />
</TouchableOpacity>
</View>
)}
</View>
{/* ── Sync ── */}
<SectionHeader icon={<RefreshCw size={16} color="#1A6B72" />} title="Sync" />
<View style={styles.card}>
<SettingsRow
label="Sync with Partner"
sub="Share and merge data with another publisher"
onPress={() => router.push('/sync' as any)}
icon={<ChevronRight size={18} color="#9CA3AF" />}
/>
</View>
{/* ── Topic Library ── */}
<SectionHeader icon={<BookOpen size={16} color="#1A6B72" />} title="Topic Library" />
<View style={styles.card}>
<Text style={[styles.label, { marginBottom: 6 }]}>Default Topics</Text>
{defaultTopics.map((t) => (
<View key={t.id} style={styles.topicRow}>
<Text style={styles.topicName}>{t.name}</Text>
<View style={styles.lockedBadge}>
<Text style={styles.lockedText}>Built-in</Text>
</View>
</View>
))}
<View style={[styles.divider, { marginVertical: 12 }]} />
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text style={styles.label}>Custom Topics</Text>
<TouchableOpacity
onPress={() => setAddingTopic(true)}
style={styles.iconBtn}
accessibilityRole="button"
accessibilityLabel="Add custom topic"
>
<Plus size={16} color="#1A6B72" />
</TouchableOpacity>
</View>
{addingTopic && (
<View style={{ marginBottom: 12 }}>
<TextInput
style={styles.topicInput}
placeholder="Topic name..."
value={newTopic}
onChangeText={setNewTopic}
autoFocus
accessibilityLabel="New topic name"
/>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 6 }}>
<TouchableOpacity
style={[styles.smallBtn, { backgroundColor: '#F3F4F6', flex: 1 }]}
onPress={() => { setAddingTopic(false); setNewTopic(''); }}
accessibilityRole="button"
accessibilityLabel="Cancel add topic"
>
<Text style={{ color: '#374151', fontWeight: '600', fontSize: 13 }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.smallBtn, { backgroundColor: '#1A6B72', flex: 1 }]}
onPress={handleAddTopic}
accessibilityRole="button"
accessibilityLabel="Save topic"
>
<Text style={{ color: 'white', fontWeight: '600', fontSize: 13 }}>Add</Text>
</TouchableOpacity>
</View>
</View>
)}
{customTopics.length === 0 && !addingTopic ? (
<EmptyState
icon="Tag"
title="No custom topics yet"
message="Add topics you commonly discuss in your ministry."
/>
) : (
customTopics.map((t) => (
<View key={t.id} style={styles.topicRow}>
<Text style={styles.topicName}>{t.name}</Text>
<TouchableOpacity
onPress={() => 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' }}
>
<Trash2 size={15} color="#C0392B" />
</TouchableOpacity>
</View>
))
)}
</View>
{/* ── App Lock ── */}
<SectionHeader icon={<Shield size={16} color="#1A6B72" />} title="App Lock" />
<View style={styles.card}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<Text style={styles.value}>PIN Lock</Text>
<Text style={styles.label}>{appLockEnabled ? 'Enabled' : 'Disabled'}</Text>
</View>
{appLockEnabled ? <Lock size={18} color="#1A6B72" /> : <Unlock size={18} color="#9CA3AF" />}
<Switch
value={appLockEnabled}
onValueChange={(val) => {
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 }}
/>
</View>
</View>
{/* ── Export Backup ── */}
<SectionHeader icon={<Download size={16} color="#1A6B72" />} title="Export Backup" />
<View style={styles.card}>
<Text style={[styles.label, { marginBottom: 12 }]}>
Export all your data (contacts, visits, territories, topics) as a JSON file you can store safely or share with a trusted person.
</Text>
<TouchableOpacity
style={[styles.actionBtn, exportLoading && { opacity: 0.6 }]}
onPress={handleExport}
disabled={exportLoading}
accessibilityRole="button"
accessibilityLabel="Export backup"
accessibilityHint="Exports all data as a JSON file and opens the share sheet"
>
<Download size={16} color="white" />
<Text style={styles.actionBtnText}>{exportLoading ? 'Exporting...' : 'Export Backup'}</Text>
</TouchableOpacity>
</View>
{/* ── Import Backup ── */}
<SectionHeader icon={<Upload size={16} color="#1A6B72" />} title="Import Backup" />
<View style={styles.card}>
<Text style={[styles.label, { marginBottom: 12 }]}>
Import a previously exported TerritoryLog backup. New records will be added; existing ones are updated only if the backup is newer.
</Text>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: '#2A8B94' }, importLoading && { opacity: 0.6 }]}
onPress={handlePickImport}
disabled={importLoading}
accessibilityRole="button"
accessibilityLabel="Import backup"
accessibilityHint="Opens file picker to select a backup JSON file"
>
<Upload size={16} color="white" />
<Text style={styles.actionBtnText}>{importLoading ? 'Processing...' : 'Import Backup'}</Text>
</TouchableOpacity>
</View>
{/* ── About ── */}
<SectionHeader icon={<Info size={16} color="#1A6B72" />} title="About" />
<View style={styles.card}>
<Text style={{ fontSize: 16, fontWeight: '700', color: '#2C3E50', marginBottom: 4 }}>TerritoryLog v{APP_VERSION}</Text>
<Text style={styles.label}>A privacy-first field ministry records app.</Text>
<View style={[styles.divider, { marginVertical: 10 }]} />
<Text style={[styles.label, { fontSize: 11, lineHeight: 16 }]}>
🔒 All data is stored locally on your device. Nothing is sent to external servers without your explicit action.
</Text>
</View>
</ScrollView>
{/* Delete topic confirm */}
<ConfirmDialog
visible={!!deleteTopicConfirm}
title="Delete Topic"
message={`Delete "${deleteTopicConfirm?.name}"? It will be removed from the topic list but existing visit records won't be affected.`}
confirmText="Delete"
confirmStyle="danger"
onConfirm={() => { if (deleteTopicConfirm) handleDeleteTopic(deleteTopicConfirm); setDeleteTopicConfirm(null); }}
onCancel={() => setDeleteTopicConfirm(null)}
/>
{/* Import confirm */}
{importConfirm && (
<ConfirmDialog
visible={!!importConfirm}
title="Import Backup"
message={`Found ${importConfirm.contacts} contacts, ${importConfirm.visits} visits, ${importConfirm.territories} territories, and ${importConfirm.topics} custom topics. Merge into your current data?`}
confirmText="Import"
confirmStyle="default"
impactCount={importConfirm.contacts + importConfirm.visits + importConfirm.territories}
onConfirm={() => handleImportConfirmed(importConfirm)}
onCancel={() => setImportConfirm(null)}
/>
)}
</View>
);
}
// ─── Sub-components ──────────────────────────────────────────────────────────
function SectionHeader({ icon, title }: { icon: React.ReactNode; title: string }) {
return (
<View style={styles.sectionHeader}>
{icon}
<Text style={styles.sectionTitle}>{title}</Text>
</View>
);
}
function SettingsRow({
label, sub, onPress, icon,
}: { label: string; sub?: string; onPress: () => void; icon?: React.ReactNode }) {
return (
<TouchableOpacity
style={[styles.settingsRow, { minHeight: 56 }]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint={sub}
>
<View style={{ flex: 1 }}>
<Text style={styles.value}>{label}</Text>
{sub && <Text style={styles.label}>{sub}</Text>}
</View>
{icon}
</TouchableOpacity>
);
}
// ─── 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',
},
});