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} + /> + +