feat: Sprint 4 — Map View (OSM tiles, pins, status colors, territory filter, no-GPS list)

This commit is contained in:
2026-02-18 17:09:30 +08:00
parent 9bd02b1a17
commit 123b21f4db
3 changed files with 597 additions and 6 deletions

View File

@@ -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',
},
});