diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index dc8d944..284c4ce 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,7 +1,18 @@
-import { Tabs } from 'expo-router';
+// app/(tabs)/_layout.tsx
+import { Tabs, router } from 'expo-router';
+import { useEffect } from 'react';
import { Home, Users, Map, Settings } from 'lucide-react-native';
+import { useUserStore } from '@/store/useUserStore';
export default function TabLayout() {
+ const user = useUserStore((s) => s.user);
+
+ useEffect(() => {
+ if (!user) {
+ router.replace('/onboarding');
+ }
+ }, [user]);
+
return (
- ,
- }}
- />
- ,
- }}
- />
- ,
- }}
- />
- ,
- }}
- />
+ }} />
+ }} />
+ }} />
+ }} />
);
}
diff --git a/app/(tabs)/contacts.tsx b/app/(tabs)/contacts.tsx
index 4c84da1..f7c104f 100644
--- a/app/(tabs)/contacts.tsx
+++ b/app/(tabs)/contacts.tsx
@@ -1,10 +1,99 @@
-import { View, Text } from 'react-native';
+// app/(tabs)/contacts.tsx
+import { View, Text, FlatList, TouchableOpacity, TextInput } from 'react-native';
+import { useState, useCallback } from 'react';
+import { useFocusEffect } from 'expo-router';
+import { Plus, Search, X } from 'lucide-react-native';
+import { useContactStore, Contact } from '@/store/useContactStore';
+import { getDatabase } from '@/lib/database';
+import { ContactCard } from '@/components/contacts/ContactCard';
+import { AddContactSheet } from '@/components/contacts/AddContactSheet';
+import { dbToContact } from '@/lib/contactHelpers';
export default function ContactsScreen() {
+ const { contacts, setContacts } = useContactStore();
+ const [search, setSearch] = useState('');
+ const [statusFilter, setStatusFilter] = useState('All');
+ const [showAdd, setShowAdd] = useState(false);
+
+ const statusOptions = ['All', 'Active', 'Return Visit', 'Bible Study', 'Not Interested', 'Do Not Call'];
+
+ async function loadContacts() {
+ const db = await getDatabase();
+ const rows = await db.getAllAsync(
+ 'SELECT * FROM contacts WHERE deleted_at IS NULL ORDER BY full_name ASC'
+ );
+ setContacts(rows.map(dbToContact));
+ }
+
+ useFocusEffect(useCallback(() => { loadContacts(); }, []));
+
+ const filtered = contacts.filter((c) => {
+ const matchSearch = c.fullName.toLowerCase().includes(search.toLowerCase()) ||
+ (c.address?.toLowerCase().includes(search.toLowerCase()) ?? false);
+ const matchStatus = statusFilter === 'All' || c.status === statusFilter;
+ return matchSearch && matchStatus;
+ });
+
return (
-
- Contacts
- Coming in Sprint 1
+
+ {/* Header */}
+
+
+ Contacts
+ setShowAdd(true)} className="bg-white/20 rounded-full p-2">
+
+
+
+ {/* Search */}
+
+
+
+ {search ? setSearch('')}> : null}
+
+
+
+ {/* Status Filter */}
+
+ item}
+ renderItem={({ item }) => (
+ setStatusFilter(item)}
+ className={`px-3 py-1.5 rounded-full border ${statusFilter === item ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}
+ >
+ {item}
+
+ )}
+ />
+
+
+ {/* List */}
+ 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}
+
+ )}
+ />
+
+ setShowAdd(false)} onSaved={() => { setShowAdd(false); loadContacts(); }} />
);
}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 3ba7271..826c645 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,11 +1,49 @@
+// app/_layout.tsx
import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import { useEffect, useState } from 'react';
+import { View, ActivityIndicator } from 'react-native';
+import { useUserStore } from '@/store/useUserStore';
+import { getDatabase } from '@/lib/database';
import '../global.css';
export default function RootLayout() {
+ const [loading, setLoading] = useState(true);
+ const setUser = useUserStore((s) => s.setUser);
+
+ useEffect(() => {
+ async function bootstrap() {
+ try {
+ const db = await getDatabase();
+ const user = await db.getFirstAsync<{ id: string; display_name: string; share_id: string }>(
+ 'SELECT id, display_name, share_id FROM users WHERE is_self = 1 LIMIT 1'
+ );
+ if (user) {
+ setUser({ id: user.id, displayName: user.display_name, shareId: user.share_id });
+ }
+ } catch (e) {
+ console.error('Bootstrap error:', e);
+ } finally {
+ setLoading(false);
+ }
+ }
+ bootstrap();
+ }, []);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
return (
-
+
+
+
+
);
}
diff --git a/app/contact/[id].tsx b/app/contact/[id].tsx
new file mode 100644
index 0000000..796ddee
--- /dev/null
+++ b/app/contact/[id].tsx
@@ -0,0 +1,113 @@
+import { View, Text, ScrollView, TouchableOpacity, Alert } from 'react-native';
+import { useLocalSearchParams, router } from 'expo-router';
+import { useState, useCallback } from 'react';
+import { useFocusEffect } from 'expo-router';
+import { ArrowLeft, Edit2, Trash2, MapPin, Phone } 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';
+
+const statusColors: Record = {
+ 'Active': '#4CAF7D',
+ 'Return Visit': '#2196F3',
+ 'Bible Study': '#9C27B0',
+ 'Not Interested': '#9CA3AF',
+ 'Do Not Call': '#C0392B',
+};
+
+export default function ContactDetailScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const [contact, setContact] = useState(null);
+ const [showEdit, setShowEdit] = useState(false);
+
+ async function loadContact() {
+ const db = await getDatabase();
+ const row = await db.getFirstAsync('SELECT * FROM contacts WHERE id = ?', [id]);
+ if (row) setContact(dbToContact(row));
+ }
+
+ useFocusEffect(useCallback(() => { loadContact(); }, [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();
+ const now = Math.floor(Date.now() / 1000);
+ await db.runAsync('UPDATE contacts SET deleted_at = ? WHERE id = ?', [now, id]);
+ router.back();
+ }
+ }
+ ]);
+ }
+
+ if (!contact) return ;
+
+ const statusColor = statusColors[contact.status] ?? '#9CA3AF';
+
+ return (
+
+ {/* Header */}
+
+
+ router.back()} className="bg-white/20 rounded-full p-2">
+
+
+
+ setShowEdit(true)} className="bg-white/20 rounded-full p-2">
+
+
+
+
+
+
+
+
+
+
+ {contact.fullName.split(' ').map((n) => n[0]).join('').substring(0, 2).toUpperCase()}
+
+
+ {contact.fullName}
+
+ {contact.status}
+
+
+
+
+
+ {/* Info Card */}
+
+ Information
+
+ {contact.address &&
} />}
+ {contact.territoryCode &&
}
+
+
+ {contact.notes && (
+
+ Notes
+ {contact.notes}
+
+ )}
+
+
+ setShowEdit(false)} onSaved={() => { setShowEdit(false); loadContact(); }} />
+
+ );
+}
+
+function Row({ label, value, icon }: { label: string; value: string; icon?: React.ReactNode }) {
+ return (
+
+ {label}
+
+ {icon}
+ {value}
+
+
+ );
+}
diff --git a/app/onboarding.tsx b/app/onboarding.tsx
new file mode 100644
index 0000000..bc8db92
--- /dev/null
+++ b/app/onboarding.tsx
@@ -0,0 +1,70 @@
+// app/onboarding.tsx
+import { View, Text, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
+import { useState } from 'react';
+import { router } from 'expo-router';
+import { Input } from '@/components/ui/Input';
+import { Button } from '@/components/ui/Button';
+import { useUserStore } from '@/store/useUserStore';
+import { getDatabase } from '@/lib/database';
+import * as Crypto from 'expo-crypto';
+
+export default function OnboardingScreen() {
+ const [name, setName] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const setUser = useUserStore((s) => s.setUser);
+
+ async function handleContinue() {
+ if (!name.trim()) {
+ setError('Please enter your name');
+ return;
+ }
+ setLoading(true);
+ try {
+ const id = Crypto.randomUUID();
+ const shareId = Crypto.randomUUID().replace(/-/g, '').substring(0, 12).toUpperCase();
+ const now = Math.floor(Date.now() / 1000);
+ const db = await getDatabase();
+ await db.runAsync(
+ 'INSERT INTO users (id, display_name, share_id, is_self, created_at) VALUES (?, ?, ?, 1, ?)',
+ [id, name.trim(), shareId, now]
+ );
+ setUser({ id, displayName: name.trim(), shareId });
+ router.replace('/(tabs)');
+ } catch (e) {
+ setError('Something went wrong. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+ TerritoryLog
+ Your ministry field records, private and organized.
+
+
+ What should we call you?
+ { setName(t); setError(''); }}
+ error={error}
+ autoFocus
+ returnKeyType="done"
+ onSubmitEditing={handleContinue}
+ />
+
+
+
+ Your data stays on your device. We never collect or share it.
+
+
+
+
+ );
+}
diff --git a/components/contacts/AddContactSheet.tsx b/components/contacts/AddContactSheet.tsx
new file mode 100644
index 0000000..ffcd95b
--- /dev/null
+++ b/components/contacts/AddContactSheet.tsx
@@ -0,0 +1,106 @@
+import { View, Text, Modal, ScrollView, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
+import { useState } from 'react';
+import { X } from 'lucide-react-native';
+import { Input } from '@/components/ui/Input';
+import { Button } from '@/components/ui/Button';
+import { useUserStore } from '@/store/useUserStore';
+import { getDatabase } from '@/lib/database';
+import { newContactId } from '@/lib/contactHelpers';
+
+const STATUS_OPTIONS = ['Active', 'Return Visit', 'Bible Study', 'Not Interested', 'Do Not Call'];
+const CATEGORY_OPTIONS = ['Adult', 'Teenager', 'Kid'];
+
+interface Props {
+ visible: boolean;
+ onClose: () => void;
+ onSaved: () => void;
+}
+
+export function AddContactSheet({ visible, onClose, onSaved }: Props) {
+ const user = useUserStore((s) => s.user);
+ const [fullName, setFullName] = useState('');
+ const [address, setAddress] = useState('');
+ const [status, setStatus] = useState('Active');
+ const [category, setCategory] = useState('Adult');
+ const [notes, setNotes] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [errors, setErrors] = useState>({});
+
+ function reset() {
+ setFullName(''); setAddress(''); setStatus('Active');
+ setCategory('Adult'); setNotes(''); setErrors({});
+ }
+
+ async function handleSave() {
+ const errs: Record = {};
+ if (!fullName.trim()) errs.fullName = 'Name is required';
+ if (Object.keys(errs).length) { setErrors(errs); return; }
+
+ setLoading(true);
+ try {
+ const db = await getDatabase();
+ const now = Math.floor(Date.now() / 1000);
+ const id = newContactId();
+ await db.runAsync(
+ `INSERT INTO contacts (id, owner_id, full_name, address, category, status, tags, notes, household_count, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, '[]', ?, 1, ?, ?)`,
+ [id, user?.id ?? 'unknown', fullName.trim(), address.trim() || null, category, status, notes.trim() || null, now, now]
+ );
+ reset();
+ onSaved();
+ } catch (e) {
+ console.error('Save contact error:', e);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ {/* Header */}
+
+ { reset(); onClose(); }}>
+
+
+ New Contact
+
+
+
+
+ { setFullName(t); setErrors((e) => ({ ...e, fullName: '' })); }} error={errors.fullName} />
+
+
+ {/* Status */}
+ 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'}`}>
+ {s}
+
+ ))}
+
+
+ {/* Category */}
+ 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'}`}>
+ {c}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/contacts/ContactCard.tsx b/components/contacts/ContactCard.tsx
new file mode 100644
index 0000000..fd9fde9
--- /dev/null
+++ b/components/contacts/ContactCard.tsx
@@ -0,0 +1,42 @@
+import { View, Text, TouchableOpacity } from 'react-native';
+import { useRouter } from 'expo-router';
+import { ChevronRight } from 'lucide-react-native';
+import { Contact } from '@/store/useContactStore';
+
+const statusColors: Record = {
+ 'Active': 'bg-green-100 text-green-700',
+ 'Return Visit': 'bg-blue-100 text-blue-700',
+ 'Bible Study': 'bg-purple-100 text-purple-700',
+ 'Not Interested': 'bg-gray-100 text-gray-500',
+ 'Do Not Call': 'bg-red-100 text-red-600',
+};
+
+interface Props {
+ contact: Contact;
+ onRefresh: () => void;
+}
+
+export function ContactCard({ contact, onRefresh }: Props) {
+ const router = useRouter();
+ const initials = contact.fullName.split(' ').map((n) => n[0]).join('').substring(0, 2).toUpperCase();
+ const statusStyle = statusColors[contact.status] ?? 'bg-gray-100 text-gray-500';
+
+ return (
+ router.push({ pathname: '/contact/[id]', params: { id: contact.id } })}
+ >
+
+ {initials}
+
+
+ {contact.fullName}
+ {contact.address ? {contact.address} : null}
+
+ {contact.status}
+
+
+
+
+ );
+}
diff --git a/components/contacts/EditContactSheet.tsx b/components/contacts/EditContactSheet.tsx
new file mode 100644
index 0000000..07f9d70
--- /dev/null
+++ b/components/contacts/EditContactSheet.tsx
@@ -0,0 +1,89 @@
+import { View, Text, Modal, ScrollView, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
+import { useState, useEffect } from 'react';
+import { X } from 'lucide-react-native';
+import { Input } from '@/components/ui/Input';
+import { Button } from '@/components/ui/Button';
+import { getDatabase } from '@/lib/database';
+import { Contact } from '@/store/useContactStore';
+
+const STATUS_OPTIONS = ['Active', 'Return Visit', 'Bible Study', 'Not Interested', 'Do Not Call'];
+const CATEGORY_OPTIONS = ['Adult', 'Teenager', 'Kid'];
+
+interface Props {
+ contact: Contact;
+ visible: boolean;
+ onClose: () => void;
+ onSaved: () => void;
+}
+
+export function EditContactSheet({ contact, visible, onClose, onSaved }: Props) {
+ const [fullName, setFullName] = useState(contact.fullName);
+ const [address, setAddress] = useState(contact.address ?? '');
+ const [status, setStatus] = useState(contact.status);
+ const [category, setCategory] = useState(contact.category);
+ const [notes, setNotes] = useState(contact.notes ?? '');
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ setFullName(contact.fullName);
+ setAddress(contact.address ?? '');
+ setStatus(contact.status);
+ setCategory(contact.category);
+ setNotes(contact.notes ?? '');
+ }, [contact]);
+
+ async function handleSave() {
+ if (!fullName.trim()) return;
+ setLoading(true);
+ try {
+ const db = await getDatabase();
+ const now = Math.floor(Date.now() / 1000);
+ await db.runAsync(
+ 'UPDATE contacts SET full_name=?, address=?, status=?, category=?, notes=?, updated_at=? WHERE id=?',
+ [fullName.trim(), address.trim() || null, status, category, notes.trim() || null, now, contact.id]
+ );
+ onSaved();
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+ Edit Contact
+
+
+
+
+
+ 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'}`}>
+ {s}
+
+ ))}
+
+ 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'}`}>
+ {c}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/lib/contactHelpers.ts b/lib/contactHelpers.ts
new file mode 100644
index 0000000..df430e2
--- /dev/null
+++ b/lib/contactHelpers.ts
@@ -0,0 +1,26 @@
+import { Contact } from '@/store/useContactStore';
+import * as Crypto from 'expo-crypto';
+
+export function dbToContact(row: any): Contact {
+ return {
+ id: row.id,
+ ownerId: row.owner_id,
+ fullName: row.full_name,
+ address: row.address,
+ householdCount: row.household_count ?? 1,
+ gender: row.gender,
+ category: row.category,
+ status: row.status,
+ tags: JSON.parse(row.tags ?? '[]'),
+ notes: row.notes,
+ territoryCode: row.territory_code,
+ latitude: row.latitude,
+ longitude: row.longitude,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ };
+}
+
+export function newContactId(): string {
+ return Crypto.randomUUID();
+}
diff --git a/package.json b/package.json
index eadf87f..bc6e307 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
},
"dependencies": {
"expo": "~52.0.0",
+ "expo-crypto": "~13.0.0",
"expo-router": "~4.0.0",
"expo-sqlite": "~15.0.0",
"expo-location": "~18.0.0",