680 lines
26 KiB
TypeScript
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',
|
|
},
|
|
});
|