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 (
|
||||
<View className="flex-1 items-center justify-center bg-secondary">
|
||||
<Text className="text-xl text-charcoal">Map</Text>
|
||||
<Text className="text-gray-500 mt-2">Coming in Sprint 4</Text>
|
||||
<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',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user