From 123b21f4dbbfd33dfd92bc926502194a176f1258 Mon Sep 17 00:00:00 2001 From: Kibin Date: Wed, 18 Feb 2026 17:09:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=204=20=E2=80=94=20Map=20View=20(?= =?UTF-8?q?OSM=20tiles,=20pins,=20status=20colors,=20territory=20filter,?= =?UTF-8?q?=20no-GPS=20list)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/map.tsx | 579 ++++++++++++++++++++++++++++++++++++++++++++- lib/database.ts | 20 ++ package.json | 4 +- 3 files changed, 597 insertions(+), 6 deletions(-) diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx index bf68c76..2e7feac 100644 --- a/app/(tabs)/map.tsx +++ b/app/(tabs)/map.tsx @@ -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 = { + 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(); + + 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 ( - - Map - Coming in Sprint 4 + + + + + {contact.fullName} + + {contact.territoryCode ? ( + Territory: {contact.territoryCode} + ) : null} + Last visit: — + + + + {contact.status} + + + + + onViewFull(contact.id)} + > + View Full Record → + + + + + ); } + +// ── 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 ( + onPress(contact.id)}> + + {initials} + + + + {contact.fullName} + + + {contact.status} + {contact.territoryCode ? ` — ${contact.territoryCode}` : ''} + + + + + ); +} + +// ── Main Screen ────────────────────────────────────────────────────────────── +export default function MapScreen() { + const router = useRouter(); + const mapRef = useRef(null); + + const [contacts, setContacts] = useState([]); + const [loading, setLoading] = useState(true); + const [territories, setTerritories] = useState([]); + const [selectedTerritory, setSelectedTerritory] = useState('All'); + const [selectedContact, setSelectedContact] = useState(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 ( + + + Loading map… + + ); + } + + return ( + + {/* ── Territory filter chips ── */} + + + {['All', ...territories].map((t) => { + const active = selectedTerritory === t; + return ( + { + setSelectedTerritory(t); + setSelectedContact(null); + }} + > + {t} + + ); + })} + + + + {/* ── Map ── */} + + setSelectedContact(null)} + > + + + {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 ( + handlePinPress(rep)} + > + + {group.contacts.length} + + + ); + } + + return ( + handlePinPress(rep)} + /> + ); + })} + + + {/* Pin count badge */} + + + {filteredGps.length} pin{filteredGps.length !== 1 ? 's' : ''} + + + + {/* Mini card overlay */} + {selectedContact && ( + setSelectedContact(null)} + onViewFull={handleViewFull} + /> + )} + + + {/* ── No GPS section ── */} + {noGpsContacts.length > 0 && ( + + + + + {noGpsContacts.length} contact{noGpsContacts.length !== 1 ? 's' : ''} without GPS + + + + c.id} + renderItem={({ item }) => ( + + )} + style={styles.noGpsList} + showsVerticalScrollIndicator={false} + /> + + )} + + ); +} + +// ── 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', + }, +}); diff --git a/lib/database.ts b/lib/database.ts index 80f626a..6894554 100644 --- a/lib/database.ts +++ b/lib/database.ts @@ -1,4 +1,6 @@ import * as SQLite from 'expo-sqlite'; +import { Contact } from '@/store/useContactStore'; +import { dbToContact } from '@/lib/contactHelpers'; let db: SQLite.SQLiteDatabase | null = null; @@ -112,3 +114,21 @@ async function initSchema(database: SQLite.SQLiteDatabase): Promise { ); } } + +// ── Map helpers ────────────────────────────────────────────────────────────── + +export async function getContactsWithGPS(): Promise { + const database = await getDatabase(); + const result = await database.getAllAsync( + `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 { + const database = await getDatabase(); + const result = await database.getAllAsync( + `SELECT * FROM contacts WHERE deleted_at IS NULL ORDER BY full_name` + ); + return result.map(dbToContact); +} diff --git a/package.json b/package.json index bc6e307..964a0d3 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "zustand": "^5.0.0", "nativewind": "^4.0.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": { "@babel/core": "^7.25.0",