Files
territory-log/app/(tabs)/map.tsx

580 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
},
});