// map.tsx import React, { useEffect, useState, useRef, useCallback } from 'react'; import { StyleSheet, View, Text, ActivityIndicator, Platform, Linking, TouchableOpacity, Modal, Dimensions, ScrollView, Image, Alert } from 'react-native'; import MapView, { Camera, Marker, Region } from 'react-native-maps'; import { useLocalSearchParams, useFocusEffect, useRouter } from 'expo-router'; import * as Location from 'expo-location'; import { Ionicons } from '@expo/vector-icons'; import SearchComponent from '../SearchComponent'; // Import the new SearchComponent import { getListItemStyle } from './mapList'; // Interface for prayer spaces on map (MATCHES BACKEND'S CAPITALIZED KEYS AND ALL REQUIRED FIELDS) interface MapPrayerSpace { ID: string; Name: string; Latitude: number; Longitude: number; Address: string; LocationType: 'Mosque' | 'Prayer Room' | 'Community Space' | 'other'; WomensSpace: boolean; Wudu: boolean; OpeningHours: string; Notes?: string; Website?: string; Clean: number; CleanWudu: number; Quiet: number; Privateness: number; ChildFriendly: number; Safe: number; NumberOfReviews: number; Reviews: any[]; Images: any[]; } // ImageObject and Review Interfaces (copied from PrayerSpacesListPage for consistency) export interface ImageObject { id?: string; url: string; note?: string; } export interface Review { ID: string; Rating?: number; Quiet: number; Clean: number; Private: number; Comment?: string; User?: string; CleanWudu?: number; ChildFriendly?: number; Safe?: number; } const KAABA_COORDS = { latitude: 21.4225, longitude: 39.8262, }; const BACKEND_URL = 'http://132.145.65.145:8080'; // Your backend URL const defaultRegion: Region = { latitude: 51.5074, // London longitude: -0.1278, latitudeDelta: 0.0922, longitudeDelta: 0.0421, }; const TARGET_LATITUDE_DELTA = 0.005; // Zoom level for specific locations const TARGET_LONGITUDE_DELTA = 0.005; const USER_LATITUDE_DELTA = 0.02; // Zoom level for user's location const USER_LONGITUDE_DELTA = 0.02; // Image URL Conversion (copied from PrayerSpacesListPage) function convertToFullImageUrl(imageUrl: string): string { if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { return imageUrl; } return `http://132.145.65.145:8080${imageUrl}`; } // Emoji Rating Helper (copied from PrayerSpacesListPage) function averageRatingToEmoji(avg: number) { if (avg < 1.66) { return "😞"; } else if (avg < 2.33) { return "😐"; } else { return "😊"; } } // Prayer Space Type Map (copied from PrayerSpacesListPage) function prayerSpaceTypeMap(str: string) { switch (str) { case "mosque": return "Mosque" default: return "Other" } } export default function MapScreen() { const params = useLocalSearchParams<{ latitude?: string; longitude?: string; name?: string }>(); const router = useRouter(); // mapRegionToSet now primarily drives the initial position via initialRegion const [mapRegionToSet, setMapRegionToSet] = useState(null); const [userCoords, setUserCoords] = useState(null); const [permissionStatus, setPermissionStatus] = useState(null); const [isLoading, setIsLoading] = useState(true); const [mapBearing, setMapBearing] = useState(0); const [prayerSpaces, setPrayerSpaces] = useState([]); const updateBearingTimeoutRef = useRef(null); const [isInfoModalVisible, setIsInfoModalVisible] = useState(false); const [selectedMosqueInfo, setSelectedMosqueInfo] = useState(null); const mapRef = useRef(null); const hasAppliedInitialRegionLogic = useRef(false); const fetchPrayerSpaces = useCallback(async () => { try { const response = await fetch(`${BACKEND_URL}/places/query`); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } const rawData: any[] = await response.json(); const processedData: MapPrayerSpace[] = rawData.map(item => ({ ID: item.ID, Name: item.Name, Latitude: item.Latitude, Longitude: item.Longitude, Address: item.Address, LocationType: prayerSpaceTypeMap(item.LocationType), WomensSpace: item.WomensSpace, Wudu: item.Wudu, OpeningHours: item.OpeningHours || '', Notes: item.Notes, Website: item.WebsiteURL, Reviews: (item.Reviews || []).map((r: any) => ({ ID: r.ID, Rating: (r.Quiet + r.Clean + r.Private) / 3.0, Quiet: r.Quiet, Clean: r.Clean, Private: r.Private, CleanWudu: r.CleanWudu, ChildFriendly: r.ChildFriendly, Safe: r.Safe, Comment: r.Comment, User: r.User, })), Images: (item.Images || []).map((img: any) => ({ id: img.ID, url: convertToFullImageUrl(img.ImageURL), note: img.Notes, })), Clean: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.Clean || 0), 0) / item.Reviews.length : 0, CleanWudu: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.CleanWudu || 0), 0) / item.Reviews.length : 0, Quiet: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.Quiet || 0), 0) / item.Reviews.length : 0, Privateness: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.Private || 0), 0) / item.Reviews.length : 0, ChildFriendly: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.ChildFriendly || 0), 0) / item.Reviews.length : 0, Safe: item.Reviews && item.Reviews.length > 0 ? item.Reviews.reduce((sum: number, r: any) => sum + (r.Safe || 0), 0) / item.Reviews.length : 0, NumberOfReviews: item.Reviews ? item.Reviews.length : 0, })); console.log('[DEBUG] Fetched & processed prayer spaces for map:', processedData); setPrayerSpaces(processedData); } catch (error) { console.error('Failed to fetch prayer spaces for map:', error); } }, []); useFocusEffect( useCallback(() => { fetchPrayerSpaces(); return () => { // Optional cleanup }; }, [fetchPrayerSpaces]) ); const getAdjustedQiblaDirection = (): number => { return (0 - mapBearing + 360) % 360; }; const PrayerSpaceMarker = () => ( ); // 1. Effect for Location Permission and Fetching User Coords (runs once) useEffect(() => { let isMounted = true; const requestLocation = async () => { const { status } = await Location.requestForegroundPermissionsAsync(); if (isMounted) setPermissionStatus(status); if (status === 'granted') { try { const location = await Location.getLastKnownPositionAsync(); if (isMounted && location) { setMapRegionToSet({ latitude: location.coords.latitude, longitude: location.coords.longitude, latitudeDelta: USER_LATITUDE_DELTA, longitudeDelta: USER_LONGITUDE_DELTA, }); setUserCoords(location.coords); } } catch (error) { console.error("Error fetching user location:", error); } } }; requestLocation(); return () => { isMounted = false; }; }, []); // Handler for when a place is selected from SearchComponent // This now *only* calls animateToRegion, not setMapRegionToSet. const handleSelectedPlaceFromSearch = useCallback((lat: number, lng: number, name: string) => { const newRegion: Region = { latitude: lat, longitude: lng, latitudeDelta: TARGET_LATITUDE_DELTA, longitudeDelta: TARGET_LONGITUDE_DELTA, }; if (mapRef.current) { mapRef.current.animateToRegion(newRegion, 1000); // Direct animation } else { console.warn("Map reference not available to animate to selected place."); } // IMPORTANT: DO NOT set mapRegionToSet here if you want animateToRegion to be the sole driver for dynamic movement. // The map will still correctly move via animateToRegion. }, []); // 2. Main effect to determine the map region based on params or user location // This useFocusEffect is primarily for setting the *initial* region for `initialRegion` prop. useFocusEffect( useCallback(() => { let isMounted = true; setIsLoading(true); hasAppliedInitialRegionLogic.current = false; let newRegion: Region | null = null; if (params.latitude && params.longitude) { const targetLat = parseFloat(params.latitude); const targetLng = parseFloat(params.longitude); if (!isNaN(targetLat) && !isNaN(targetLng)) { newRegion = { latitude: targetLat, longitude: targetLng, latitudeDelta: TARGET_LATITUDE_DELTA, longitudeDelta: TARGET_LONGITUDE_DELTA, }; } } else if (userCoords) { newRegion = { latitude: userCoords.latitude, longitude: userCoords.longitude, latitudeDelta: USER_LATITUDE_DELTA, longitudeDelta: USER_LONGITUDE_DELTA, }; } else if (permissionStatus && permissionStatus !== 'granted') { newRegion = defaultRegion; } else if (permissionStatus === 'granted' && !userCoords && !params.latitude) { // Permission granted, but still waiting for userCoords. Keep loading. } else if (!permissionStatus) { // Permission status not yet determined. Keep loading. } else { // Any other unhandled case, fallback to default. newRegion = defaultRegion; } if (newRegion && isMounted) { setMapRegionToSet(newRegion); // Set state for initialRegion hasAppliedInitialRegionLogic.current = true; } if (isMounted && (newRegion || (permissionStatus && permissionStatus !== 'granted') || (permissionStatus === 'granted' && !userCoords && !params.latitude) ) ) { if (permissionStatus === 'granted' && !userCoords && !params.latitude) { // Still waiting for user location to be fetched by the other useEffect } else { setIsLoading(false); } } return () => { isMounted = false; }; }, [params.latitude, params.longitude, userCoords, permissionStatus]) ); // This useEffect ensures the map animates initially, based on mapRegionToSet. // It is NOT used for subsequent search-initiated animations. useEffect(() => { if (mapRegionToSet && mapRef.current && hasAppliedInitialRegionLogic.current) { mapRef.current.animateToRegion(mapRegionToSet, 1000); // Reset this flag so it only animates once on initial load/focus hasAppliedInitialRegionLogic.current = false; } }, [mapRegionToSet]); const openDirections = (lat: number, lng: number, placeName?: string) => { const scheme = Platform.select({ ios: 'maps:0,0?q=', android: 'geo:0,0?q=' }); const latLng = `${lat},${lng}`; const label = placeName || 'Destination'; const url = Platform.select({ios: `${scheme}${label}@${latLng}`, android: `${scheme}${latLng}(${label})`}); if (url) Linking.openURL(url).catch((err: any) => console.error("Failed to open maps link:", err)); }; const handleAddMosque = () => { router.push('/add_mosque'); }; const handleMapPress = async (event: any) => { const { coordinate } = event.nativeEvent; console.log(coordinate); let address = ''; try { const geocode = await Location.reverseGeocodeAsync({ latitude: coordinate.latitude, longitude: coordinate.longitude, }); if (geocode && geocode.length > 0) { const g = geocode[0]; address = [g.name, g.street, g.city, g.region, g.postalCode, g.country].filter(Boolean).join(', '); } } catch (err) { console.warn('Reverse geocoding failed:', err); } router.push(`/add_mosque?lat=${coordinate.latitude}&lng=${coordinate.longitude}&address=${encodeURIComponent(address)}`); }; const handlePoiClick = async (event: any) => { const { coordinate, name } = event.nativeEvent; let address = ''; let mainName = name; if (typeof name === 'string') { mainName = name.split(/\n/)[0].trim(); } try { const geocode = await Location.reverseGeocodeAsync({ latitude: coordinate.latitude, longitude: coordinate.longitude, }); if (geocode && geocode.length > 0) { const g = geocode[0]; address = [g.name, g.street, g.city, g.region, g.postalCode, g.country].filter(Boolean).join(', '); } } catch (err) { address = mainName; } router.push(`/add_mosque?lat=${coordinate.latitude}&lng=${coordinate.longitude}&address=${encodeURIComponent(address)}&name=${encodeURIComponent(mainName)}`); }; // handleMarkerPress to open info modal const handleMarkerPress = (place: MapPrayerSpace) => { setSelectedMosqueInfo(place); setIsInfoModalVisible(true); }; if (isLoading && !mapRegionToSet) { return ( Loading map... ); } // Use mapRegionToSet for initialRegion, fallback to default if null const currentMapRegion = mapRegionToSet || defaultRegion; const updateMapBearing = async () => { if (mapRef.current) { try { const camera = await mapRef.current.getCamera(); setMapBearing(camera.heading || 0); } catch (error) { console.log('Error getting camera:', error); } } }; const debouncedUpdateMapBearing = () => { if (updateBearingTimeoutRef.current) { clearTimeout(updateBearingTimeoutRef.current); } updateBearingTimeoutRef.current = setTimeout(() => { updateMapBearing(); }, 20); // Update every 100ms max during interaction }; return ( {/* Search Bar is now a separate component */} {/* MapView: Uses initialRegion for initial position, then animateToRegion for movement */} { debouncedUpdateMapBearing(); }} onRegionChangeComplete={() => { updateMapBearing(); }}> {prayerSpaces.map((place) => ( handleMarkerPress(place)} anchor={{ x: 0.5, y: 1 }} > ))} {/* Remove user location marker */} Add Prayer Space {/* MosqueInfoModal */} setIsInfoModalVisible(false)} mosque={selectedMosqueInfo} /> ); } // --- MosqueInfoModal Component (Inline) --- interface MosqueInfoModalProps { visible: boolean; onClose: () => void; mosque: MapPrayerSpace | null; } const { width: modalWidth, height: modalHeight } = Dimensions.get('window'); const MosqueInfoModal: React.FC = ({ visible, onClose, mosque }) => { const [isReviewsModalVisible, setIsReviewsModalVisible] = useState(false); const router = useRouter(); if (!mosque) { return null; } const averageRatingToEmoji = (avg: number) => { if (avg < 1.66) { return "😞"; } else if (avg < 2.33) { return "😐"; } else { return "😊"; } }; const openDirectionsInMaps = () => { if (mosque.Latitude && mosque.Longitude) { const scheme = Platform.select({ ios: 'maps:0,0?q=', android: 'geo:0,0?q=' }); const latLng = `${mosque.Latitude},${mosque.Longitude}`; const label = mosque.Name || 'Destination'; const url = Platform.select({ios: `${scheme}${label}@${latLng}`, android: `${scheme}${latLng}(${label})`}); if (url) Linking.openURL(url).catch((err: any) => console.error("Failed to open maps link:", err)); } }; const openWebsite = () => { if (mosque.Website) { const formattedWebsite = mosque.Website.startsWith('http') ? mosque.Website : `http://${mosque.Website}`; Linking.openURL(formattedWebsite).catch((err: any) => console.error("Failed to open website link:", err)); } }; const handleAddReviewFromModal = () => { onClose(); router.push({ pathname: '/add_review', params: { placeId: mosque.ID, placeName: mosque.Name } }); }; const handleAddPhotoFromModal = () => { onClose(); router.push({ pathname: '/upload', params: { placeId: mosque.ID, placeName: mosque.Name } }); }; const handleShowReviewsFromModal = () => { setIsReviewsModalVisible(true); }; return ( {mosque.Images && mosque.Images.length > 0 && ( {mosque.Images.map((imgObj, index) => ( ))} )} {mosque.Name} {mosque.Website && ( {mosque.Website} )} Type: {prayerSpaceTypeMap(mosque.LocationType)} Address: {mosque.Address} {mosque.OpeningHours && Hours: {mosque.OpeningHours}} {mosque.NumberOfReviews === 0 ? ( No Reviews ) : ( <> Cleanliness: {typeof mosque.Clean === 'number' ? averageRatingToEmoji(mosque.Clean) : '—'} Wudu Cleanliness: {typeof mosque.CleanWudu === 'number' ? averageRatingToEmoji(mosque.CleanWudu) : '—'} Quietness: {typeof mosque.Quiet === 'number' ? averageRatingToEmoji(mosque.Quiet) : '—'} Privacy: {typeof mosque.Privateness === 'number' ? averageRatingToEmoji(mosque.Privateness) : '—'} Child Friendliness: {typeof mosque.ChildFriendly === 'number' ? averageRatingToEmoji(mosque.ChildFriendly) : '—'} Safety: {typeof mosque.Safe === 'number' ? averageRatingToEmoji(mosque.Safe) : '—'} )} {mosque.WomensSpace && 🚺 Women's Space} {mosque.Wudu && 💧 Wudu Facilities} {mosque.Notes && Notes: {mosque.Notes}} Show Reviews Get Directions Add a Review Add a Photo {/* ReviewsModal - Now inline within MosqueInfoModal */} setIsReviewsModalVisible(false)} reviews={mosque.Reviews} spaceName={mosque.Name} /> ); }; // --- ReviewsModal Component (Inline) --- interface ReviewsModalProps { visible: boolean; onClose: () => void; reviews: Review[]; spaceName: string; } const ReviewsModal: React.FC = ({ visible, onClose, reviews, spaceName }) => { return ( Reviews for {spaceName} {reviews.length === 0 ? ( No reviews yet. ) : ( reviews.map((review, index) => ( {review.Rating !== undefined && review.Rating !== null && ( Overall Rating: {averageRatingToEmoji(review.Rating)} )} Cleanliness: {typeof review.Clean === 'number' ? averageRatingToEmoji(review.Clean) : '—'} Wudu Cleanliness: {typeof review.CleanWudu === 'number' ? averageRatingToEmoji(review.CleanWudu) : '—'} Quietness: {typeof review.Quiet === 'number' ? averageRatingToEmoji(review.Quiet) : '—'} Privacy: {typeof review.Private === 'number' ? averageRatingToEmoji(review.Private) : '—'} Child Friendliness: {typeof review.ChildFriendly === 'number' ? averageRatingToEmoji(review.ChildFriendly) : '—'} Safety: {typeof review.Safe === 'number' ? averageRatingToEmoji(review.Safe) : '—'} {review.Comment && "{review.Comment}"} {review.User && — {review.User}} )) )} Close ); }; const infoModalStyles = StyleSheet.create({ centeredView: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.6)', }, modalView: { margin: 20, backgroundColor: 'white', borderRadius: 10, padding: 15, alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 4, elevation: 5, width: modalWidth * 0.9, maxHeight: modalHeight * 0.9, }, closeButtonTopRight: { position: 'absolute', top: 5, right: 5, zIndex: 1, padding: 5, }, modalTitle: { fontSize: 22, fontWeight: 'bold', marginBottom: 8, color: '#007bff', textAlign: 'center', }, infoScrollView: { width: '100%', maxHeight: 'auto', marginBottom: 15, }, thumbnailScrollView: { marginBottom: 12, maxHeight: 100, width: '100%', }, thumbnailScrollViewContent: { paddingVertical: 2, }, thumbnailImage: { width: 100, height: 80, borderRadius: 6, marginRight: 8, backgroundColor: '#e0e0e0', }, listItemName: { fontSize: 18, fontWeight: 'bold', marginBottom: 8, color: '#007bff', textAlign: 'center', }, listItemText: { fontSize: 14, color: '#555', marginBottom: 4, lineHeight: 20, }, infoText: { fontSize: 14, color: '#555', marginBottom: 4, lineHeight: 20, }, facilitiesIconsContainer: { flexDirection: 'row', flexWrap: 'wrap', marginVertical: 8, justifyContent: 'center', }, facilityIcon: { fontSize: 14, color: '#333', backgroundColor: '#e0e0e0', borderRadius: 5, paddingHorizontal: 8, paddingVertical: 4, margin: 4, }, noReviewsPlaceholderText: { fontSize: 16, color: '#777', textAlign: 'center', marginVertical: 8, fontStyle: 'italic', }, buttonRowContainer: { flexDirection: 'row', marginTop: 12, borderWidth: 1, borderColor: '#007bff', borderRadius: 5, overflow: 'hidden', width: '100%', }, splitButtonBase: { flex: 1, paddingVertical: 10, alignItems: 'center', justifyContent: 'center', backgroundColor: '#007bff', }, splitButtonLeft: {}, splitButtonRight: {}, buttonSeparator: { width: 1, backgroundColor: '#fff', }, splitButtonText: { color: 'white', fontSize: 14, fontWeight: '600', }, addReviewButton: { backgroundColor: '#28a745', borderRadius: 5, paddingVertical: 10, alignItems: 'center', marginTop: 10, width: '100%', }, addReviewButtonText: { color: 'white', fontSize: 15, fontWeight: '600', }, addPhotoButton: { backgroundColor: '#6c757d', borderRadius: 5, paddingVertical: 10, alignItems: 'center', marginTop: 10, width: '100%', }, addPhotoButtonText: { color: 'white', fontSize: 15, fontWeight: '600', }, websiteText: { fontSize: 14, color: 'blue', textDecorationLine: 'underline', marginTop: 4, textAlign: 'center', marginBottom: 8, }, directionsButton: { backgroundColor: '#007bff', borderRadius: 8, paddingVertical: 12, paddingHorizontal: 25, marginTop: 20, minWidth: 150, alignItems: 'center', width: '100%', }, sectionTitle: { fontSize: 16, fontWeight: 'bold', marginTop: 15, marginBottom: 8, color: '#333', textAlign: 'center', }, reviewItem: { backgroundColor: '#f9f9f9', padding: 15, borderRadius: 8, marginBottom: 10, borderWidth: 1, borderColor: '#eee', }, reviewRating: { fontSize: 16, fontWeight: 'bold', marginBottom: 5, color: '#007bff', }, reviewDetail: { fontSize: 14, color: '#555', marginBottom: 2, }, reviewComment: { fontSize: 14, fontStyle: 'italic', marginTop: 8, color: '#666', borderLeftWidth: 3, borderLeftColor: '#ccc', paddingLeft: 10, }, reviewUser: { fontSize: 12, fontStyle: 'italic', textAlign: 'right', marginTop: 5, color: '#888', }, }); const styles = StyleSheet.create({ buttonContainer: { position: 'absolute', bottom: 20, right: 20, left: 20, // This ensures the button doesn't go edge-to-edge alignItems: 'flex-end', // Aligns the button to the right }, addMosqueButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: '#007AFF', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 25, elevation: 5, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 3.84, // Remove fixed width and height to allow the button to expand minWidth: 200, // Minimum width to ensure it's not too narrow }, addMosqueText: { color: 'white', marginLeft: 8, // Add some space between icon and text fontSize: 16, // Adjust as needed }, container: { flex: 1 }, map: { flex: 1 }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20, }, prayerMarker: { width: 30, height: 40, alignItems: 'center', justifyContent: 'flex-end', paddingBottom: 5, }, prayerDot: { width: 16, height: 16, borderRadius: 8, backgroundColor: '#1976D2', borderWidth: 2, borderColor: '#fff', }, });