feat: Sprint 4 — Map View (OSM tiles, pins, status colors, territory filter, no-GPS list)
This commit is contained in:
@@ -1,10 +1,579 @@
|
|||||||
import { View, Text } from 'react-native';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import MapView, { Marker, UrlTile, PROVIDER_DEFAULT } from 'react-native-maps';
|
||||||
|
import { Contact } from '@/store/useContactStore';
|
||||||
|
import { getAllContactsForMap } from '@/lib/database';
|
||||||
|
|
||||||
export default function MapScreen() {
|
// ── Status colour map ────────────────────────────────────────────────────────
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
Active: '#1A6B72',
|
||||||
|
'Return Visit': '#E65100',
|
||||||
|
'Bible Study': '#2E7D32',
|
||||||
|
'Not Interested': '#6B7280',
|
||||||
|
'Do Not Call': '#C62828',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PIN_COLOR = '#1A6B72';
|
||||||
|
|
||||||
|
// ── Cluster helpers (manual — no external package required) ──────────────────
|
||||||
|
const CLUSTER_THRESHOLD = 0.0005; // ~55 m
|
||||||
|
|
||||||
|
interface ClusterGroup {
|
||||||
|
key: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
contacts: Contact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clusterContacts(contacts: Contact[]): ClusterGroup[] {
|
||||||
|
const groups: ClusterGroup[] = [];
|
||||||
|
const assigned = new Set<string>();
|
||||||
|
|
||||||
|
for (const c of contacts) {
|
||||||
|
if (assigned.has(c.id) || c.latitude == null || c.longitude == null) continue;
|
||||||
|
const group: Contact[] = [c];
|
||||||
|
assigned.add(c.id);
|
||||||
|
|
||||||
|
for (const other of contacts) {
|
||||||
|
if (assigned.has(other.id) || other.latitude == null || other.longitude == null) continue;
|
||||||
|
const dLat = Math.abs((c.latitude ?? 0) - (other.latitude ?? 0));
|
||||||
|
const dLng = Math.abs((c.longitude ?? 0) - (other.longitude ?? 0));
|
||||||
|
if (dLat < CLUSTER_THRESHOLD && dLng < CLUSTER_THRESHOLD) {
|
||||||
|
group.push(other);
|
||||||
|
assigned.add(other.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
key: c.id,
|
||||||
|
latitude: c.latitude ?? 0,
|
||||||
|
longitude: c.longitude ?? 0,
|
||||||
|
contacts: group,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mini contact card ────────────────────────────────────────────────────────
|
||||||
|
interface MiniCardProps {
|
||||||
|
contact: Contact;
|
||||||
|
onClose: () => void;
|
||||||
|
onViewFull: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniCard({ contact, onClose, onViewFull }: MiniCardProps) {
|
||||||
|
const pinColor = STATUS_COLORS[contact.status] ?? DEFAULT_PIN_COLOR;
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center bg-secondary">
|
<View style={styles.miniCard}>
|
||||||
<Text className="text-xl text-charcoal">Map</Text>
|
<View style={styles.miniCardRow}>
|
||||||
<Text className="text-gray-500 mt-2">Coming in Sprint 4</Text>
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.miniCardName} numberOfLines={1}>
|
||||||
|
{contact.fullName}
|
||||||
|
</Text>
|
||||||
|
{contact.territoryCode ? (
|
||||||
|
<Text style={styles.miniCardSub}>Territory: {contact.territoryCode}</Text>
|
||||||
|
) : null}
|
||||||
|
<Text style={styles.miniCardSub}>Last visit: —</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<View style={[styles.statusBadge, { backgroundColor: pinColor }]}>
|
||||||
|
<Text style={styles.statusBadgeText}>{contact.status}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.miniCardActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.viewBtn}
|
||||||
|
onPress={() => onViewFull(contact.id)}
|
||||||
|
>
|
||||||
|
<Text style={styles.viewBtnText}>View Full Record →</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
||||||
|
<Text style={styles.closeBtnText}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── No-GPS row ───────────────────────────────────────────────────────────────
|
||||||
|
interface NoGpsRowProps {
|
||||||
|
contact: Contact;
|
||||||
|
onPress: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoGpsRow({ contact, onPress }: NoGpsRowProps) {
|
||||||
|
const initials = contact.fullName
|
||||||
|
.split(' ')
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
const pinColor = STATUS_COLORS[contact.status] ?? DEFAULT_PIN_COLOR;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.noGpsRow} onPress={() => onPress(contact.id)}>
|
||||||
|
<View style={[styles.avatar, { backgroundColor: pinColor }]}>
|
||||||
|
<Text style={styles.avatarText}>{initials}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.noGpsName} numberOfLines={1}>
|
||||||
|
{contact.fullName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.noGpsSub}>
|
||||||
|
{contact.status}
|
||||||
|
{contact.territoryCode ? ` — ${contact.territoryCode}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.noGpsChevron}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Screen ──────────────────────────────────────────────────────────────
|
||||||
|
export default function MapScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const mapRef = useRef<MapView>(null);
|
||||||
|
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [territories, setTerritories] = useState<string[]>([]);
|
||||||
|
const [selectedTerritory, setSelectedTerritory] = useState<string>('All');
|
||||||
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||||||
|
|
||||||
|
// Load on mount
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const all = await getAllContactsForMap();
|
||||||
|
setContacts(all);
|
||||||
|
|
||||||
|
// Unique territory codes from GPS contacts
|
||||||
|
const codes = Array.from(
|
||||||
|
new Set(
|
||||||
|
all
|
||||||
|
.filter((c) => c.latitude != null && c.longitude != null && c.territoryCode)
|
||||||
|
.map((c) => c.territoryCode as string)
|
||||||
|
)
|
||||||
|
).sort();
|
||||||
|
setTerritories(codes);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Map load error', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Derived lists
|
||||||
|
const gpsContacts = contacts.filter((c) => c.latitude != null && c.longitude != null);
|
||||||
|
const noGpsContacts = contacts.filter((c) => c.latitude == null || c.longitude == null);
|
||||||
|
|
||||||
|
const filteredGps =
|
||||||
|
selectedTerritory === 'All'
|
||||||
|
? gpsContacts
|
||||||
|
: gpsContacts.filter((c) => c.territoryCode === selectedTerritory);
|
||||||
|
|
||||||
|
const clusters = clusterContacts(filteredGps);
|
||||||
|
|
||||||
|
// Auto-zoom when contacts load
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredGps.length === 0 || !mapRef.current) return;
|
||||||
|
const coords = filteredGps.map((c) => ({
|
||||||
|
latitude: c.latitude as number,
|
||||||
|
longitude: c.longitude as number,
|
||||||
|
}));
|
||||||
|
setTimeout(() => {
|
||||||
|
mapRef.current?.fitToCoordinates(coords, {
|
||||||
|
edgePadding: { top: 80, right: 40, bottom: 300, left: 40 },
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
}, 600);
|
||||||
|
}, [filteredGps.length, selectedTerritory]);
|
||||||
|
|
||||||
|
const handlePinPress = useCallback((contact: Contact) => {
|
||||||
|
setSelectedContact(contact);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewFull = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setSelectedContact(null);
|
||||||
|
router.push(`/contact/${id}` as any);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNoGpsPress = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
router.push(`/contact/${id}` as any);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#1A6B72" />
|
||||||
|
<Text style={styles.loadingText}>Loading map…</Text>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
|
{/* ── Territory filter chips ── */}
|
||||||
|
<View style={styles.chipBar}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.chipScroll}
|
||||||
|
>
|
||||||
|
{['All', ...territories].map((t) => {
|
||||||
|
const active = selectedTerritory === t;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={t}
|
||||||
|
style={[styles.chip, active && styles.chipActive]}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedTerritory(t);
|
||||||
|
setSelectedContact(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[styles.chipText, active && styles.chipTextActive]}>{t}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Map ── */}
|
||||||
|
<View style={styles.mapContainer}>
|
||||||
|
<MapView
|
||||||
|
ref={mapRef}
|
||||||
|
provider={PROVIDER_DEFAULT}
|
||||||
|
mapType="none"
|
||||||
|
style={StyleSheet.absoluteFillObject}
|
||||||
|
initialRegion={{
|
||||||
|
latitude: 14.5995,
|
||||||
|
longitude: 120.9842,
|
||||||
|
latitudeDelta: 0.05,
|
||||||
|
longitudeDelta: 0.05,
|
||||||
|
}}
|
||||||
|
onPress={() => setSelectedContact(null)}
|
||||||
|
>
|
||||||
|
<UrlTile
|
||||||
|
urlTemplate="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
maximumZ={19}
|
||||||
|
flipY={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{clusters.map((group) => {
|
||||||
|
const isCluster = group.contacts.length > 1;
|
||||||
|
const rep = group.contacts[0];
|
||||||
|
const pinColor = STATUS_COLORS[rep.status] ?? DEFAULT_PIN_COLOR;
|
||||||
|
|
||||||
|
if (isCluster) {
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={group.key}
|
||||||
|
coordinate={{ latitude: group.latitude, longitude: group.longitude }}
|
||||||
|
onPress={() => handlePinPress(rep)}
|
||||||
|
>
|
||||||
|
<View style={[styles.clusterMarker, { backgroundColor: pinColor }]}>
|
||||||
|
<Text style={styles.clusterCount}>{group.contacts.length}</Text>
|
||||||
|
</View>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={group.key}
|
||||||
|
coordinate={{ latitude: group.latitude, longitude: group.longitude }}
|
||||||
|
pinColor={pinColor}
|
||||||
|
title={rep.fullName}
|
||||||
|
onPress={() => handlePinPress(rep)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MapView>
|
||||||
|
|
||||||
|
{/* Pin count badge */}
|
||||||
|
<View style={styles.pinCountBadge}>
|
||||||
|
<Text style={styles.pinCountText}>
|
||||||
|
{filteredGps.length} pin{filteredGps.length !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Mini card overlay */}
|
||||||
|
{selectedContact && (
|
||||||
|
<MiniCard
|
||||||
|
contact={selectedContact}
|
||||||
|
onClose={() => setSelectedContact(null)}
|
||||||
|
onViewFull={handleViewFull}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── No GPS section ── */}
|
||||||
|
{noGpsContacts.length > 0 && (
|
||||||
|
<View style={styles.noGpsSection}>
|
||||||
|
<View style={styles.noGpsHeader}>
|
||||||
|
<View style={styles.noGpsDivider} />
|
||||||
|
<Text style={styles.noGpsTitle}>
|
||||||
|
{noGpsContacts.length} contact{noGpsContacts.length !== 1 ? 's' : ''} without GPS
|
||||||
|
</Text>
|
||||||
|
<View style={styles.noGpsDivider} />
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
data={noGpsContacts}
|
||||||
|
keyExtractor={(c) => c.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<NoGpsRow contact={item} onPress={handleNoGpsPress} />
|
||||||
|
)}
|
||||||
|
style={styles.noGpsList}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F8F4EF',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#F8F4EF',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 12,
|
||||||
|
color: '#2C3E50',
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Chip bar
|
||||||
|
chipBar: {
|
||||||
|
backgroundColor: '#F8F4EF',
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: '#D0CCC6',
|
||||||
|
},
|
||||||
|
chipScroll: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: '#1A6B72',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
chipActive: {
|
||||||
|
backgroundColor: '#1A6B72',
|
||||||
|
},
|
||||||
|
chipText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1A6B72',
|
||||||
|
},
|
||||||
|
chipTextActive: {
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Map
|
||||||
|
mapContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pin count badge
|
||||||
|
pinCountBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 10,
|
||||||
|
right: 12,
|
||||||
|
backgroundColor: 'rgba(26,107,114,0.88)',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
pinCountText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cluster marker
|
||||||
|
clusterMarker: {
|
||||||
|
minWidth: 36,
|
||||||
|
minHeight: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
},
|
||||||
|
clusterCount: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '800',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mini card
|
||||||
|
miniCard: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 16,
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
miniCardRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
miniCardName: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2C3E50',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
miniCardSub: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginTop: 1,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
statusBadgeText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
miniCardActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
viewBtn: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#1A6B72',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
viewBtnText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
closeBtn: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
closeBtnText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
|
||||||
|
// No GPS section
|
||||||
|
noGpsSection: {
|
||||||
|
maxHeight: 220,
|
||||||
|
backgroundColor: '#F8F4EF',
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: '#D0CCC6',
|
||||||
|
},
|
||||||
|
noGpsHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
noGpsDivider: {
|
||||||
|
flex: 1,
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
backgroundColor: '#B0AAA4',
|
||||||
|
},
|
||||||
|
noGpsTitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6B7280',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
noGpsList: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
noGpsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
gap: 12,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: '#E5E0DA',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '800',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
noGpsName: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#2C3E50',
|
||||||
|
},
|
||||||
|
noGpsSub: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginTop: 1,
|
||||||
|
},
|
||||||
|
noGpsChevron: {
|
||||||
|
fontSize: 20,
|
||||||
|
color: '#B0AAA4',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import * as SQLite from 'expo-sqlite';
|
import * as SQLite from 'expo-sqlite';
|
||||||
|
import { Contact } from '@/store/useContactStore';
|
||||||
|
import { dbToContact } from '@/lib/contactHelpers';
|
||||||
|
|
||||||
let db: SQLite.SQLiteDatabase | null = null;
|
let db: SQLite.SQLiteDatabase | null = null;
|
||||||
|
|
||||||
@@ -112,3 +114,21 @@ async function initSchema(database: SQLite.SQLiteDatabase): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Map helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getContactsWithGPS(): Promise<Contact[]> {
|
||||||
|
const database = await getDatabase();
|
||||||
|
const result = await database.getAllAsync<any>(
|
||||||
|
`SELECT * FROM contacts WHERE latitude IS NOT NULL AND longitude IS NOT NULL AND deleted_at IS NULL ORDER BY full_name`
|
||||||
|
);
|
||||||
|
return result.map(dbToContact);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllContactsForMap(): Promise<Contact[]> {
|
||||||
|
const database = await getDatabase();
|
||||||
|
const result = await database.getAllAsync<any>(
|
||||||
|
`SELECT * FROM contacts WHERE deleted_at IS NULL ORDER BY full_name`
|
||||||
|
);
|
||||||
|
return result.map(dbToContact);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@
|
|||||||
"zustand": "^5.0.0",
|
"zustand": "^5.0.0",
|
||||||
"nativewind": "^4.0.0",
|
"nativewind": "^4.0.0",
|
||||||
"lucide-react-native": "^0.460.0",
|
"lucide-react-native": "^0.460.0",
|
||||||
"react-native-svg": "15.8.0"
|
"react-native-svg": "15.8.0",
|
||||||
|
"react-native-maps": "1.27.1",
|
||||||
|
"react-native-map-clustering": "^1.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.0",
|
"@babel/core": "^7.25.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user