580 lines
16 KiB
TypeScript
580 lines
16 KiB
TypeScript
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';
|
||
|
||
// ── 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 (
|
||
<View style={styles.miniCard}>
|
||
<View style={styles.miniCardRow}>
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// ── 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',
|
||
},
|
||
});
|