938 lines
30 KiB
TypeScript
938 lines
30 KiB
TypeScript
// 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<Region | null>(null);
|
|
const [userCoords, setUserCoords] = useState<Location.LocationObjectCoords | null>(null);
|
|
const [permissionStatus, setPermissionStatus] = useState<Location.PermissionStatus | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const [mapBearing, setMapBearing] = useState<number>(0);
|
|
const [prayerSpaces, setPrayerSpaces] = useState<MapPrayerSpace[]>([]);
|
|
const updateBearingTimeoutRef = useRef<number | null>(null);
|
|
|
|
const [isInfoModalVisible, setIsInfoModalVisible] = useState(false);
|
|
const [selectedMosqueInfo, setSelectedMosqueInfo] = useState<MapPrayerSpace | null>(null);
|
|
|
|
const mapRef = useRef<MapView>(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 = () => (
|
|
<View style={styles.prayerMarker}>
|
|
<Ionicons name="location-sharp" size={30} color="#007bff" />
|
|
</View>
|
|
);
|
|
|
|
// 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 (
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color="#007bff" />
|
|
<Text>Loading map...</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<View style={styles.container}>
|
|
{/* Search Bar is now a separate component */}
|
|
<SearchComponent onSelectPlace={handleSelectedPlaceFromSearch} />
|
|
|
|
{/* MapView: Uses initialRegion for initial position, then animateToRegion for movement */}
|
|
<MapView
|
|
ref={mapRef}
|
|
style={styles.map}
|
|
initialRegion={currentMapRegion} // Reverted to initialRegion
|
|
showsUserLocation
|
|
showsMyLocationButton
|
|
onPress={handleMapPress}
|
|
onPoiClick={handlePoiClick}
|
|
onRegionChange={() => {
|
|
debouncedUpdateMapBearing();
|
|
}}
|
|
onRegionChangeComplete={() => {
|
|
updateMapBearing();
|
|
}}>
|
|
{prayerSpaces.map((place) => (
|
|
<Marker
|
|
key={place.ID}
|
|
coordinate={{ latitude: place.Latitude, longitude: place.Longitude }}
|
|
title={place.Name}
|
|
onPress={() => handleMarkerPress(place)}
|
|
anchor={{ x: 0.5, y: 1 }}
|
|
>
|
|
<PrayerSpaceMarker />
|
|
</Marker>
|
|
))}
|
|
|
|
{/* Remove user location marker */}
|
|
</MapView>
|
|
|
|
<View style={styles.buttonContainer}>
|
|
<TouchableOpacity style={styles.addMosqueButton} onPress={handleAddMosque}>
|
|
<Ionicons name="add" size={30} color="white" />
|
|
<Text style={styles.addMosqueText} numberOfLines={1}>
|
|
Add Prayer Space
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* MosqueInfoModal */}
|
|
<MosqueInfoModal
|
|
visible={isInfoModalVisible}
|
|
onClose={() => setIsInfoModalVisible(false)}
|
|
mosque={selectedMosqueInfo}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// --- MosqueInfoModal Component (Inline) ---
|
|
interface MosqueInfoModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
mosque: MapPrayerSpace | null;
|
|
}
|
|
|
|
const { width: modalWidth, height: modalHeight } = Dimensions.get('window');
|
|
|
|
const MosqueInfoModal: React.FC<MosqueInfoModalProps> = ({ 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 (
|
|
<Modal
|
|
animationType="slide"
|
|
transparent={true}
|
|
visible={visible}
|
|
onRequestClose={onClose}
|
|
>
|
|
<View style={infoModalStyles.centeredView}>
|
|
<View style={infoModalStyles.modalView}>
|
|
<TouchableOpacity style={infoModalStyles.closeButtonTopRight} onPress={onClose}>
|
|
<Ionicons name="close-circle" size={30} color="#999" />
|
|
</TouchableOpacity>
|
|
|
|
{mosque.Images && mosque.Images.length > 0 && (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={infoModalStyles.thumbnailScrollView}
|
|
contentContainerStyle={infoModalStyles.thumbnailScrollViewContent}
|
|
>
|
|
{mosque.Images.map((imgObj, index) => (
|
|
<Image key={imgObj.id || `modal-img-${index}`} source={{ uri: imgObj.url }} style={infoModalStyles.thumbnailImage} />
|
|
))}
|
|
</ScrollView>
|
|
)}
|
|
|
|
<Text style={infoModalStyles.listItemName}>{mosque.Name}</Text>
|
|
{mosque.Website && (
|
|
<TouchableOpacity onPress={openWebsite}>
|
|
<Text style={[infoModalStyles.listItemText, { color: 'blue', textDecorationLine: 'underline' }]}>
|
|
{mosque.Website}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
<Text style={infoModalStyles.infoText}>Type: {prayerSpaceTypeMap(mosque.LocationType)}</Text>
|
|
<Text style={infoModalStyles.infoText}>Address: {mosque.Address}</Text>
|
|
{mosque.OpeningHours && <Text style={infoModalStyles.infoText}>Hours: {mosque.OpeningHours}</Text>}
|
|
|
|
{mosque.NumberOfReviews === 0 ? (
|
|
<Text style={infoModalStyles.noReviewsPlaceholderText}>No Reviews</Text>
|
|
) : (
|
|
<>
|
|
<Text style={getListItemStyle(mosque.Clean)}>
|
|
Cleanliness: {typeof mosque.Clean === 'number' ? averageRatingToEmoji(mosque.Clean) : '—'}
|
|
</Text>
|
|
<Text style={getListItemStyle(mosque.CleanWudu)}>
|
|
Wudu Cleanliness: {typeof mosque.CleanWudu === 'number' ? averageRatingToEmoji(mosque.CleanWudu) : '—'}
|
|
</Text>
|
|
<Text style={getListItemStyle(mosque.Quiet)}>
|
|
Quietness: {typeof mosque.Quiet === 'number' ? averageRatingToEmoji(mosque.Quiet) : '—'}
|
|
</Text>
|
|
<Text style={getListItemStyle(mosque.Privateness)}>
|
|
Privacy: {typeof mosque.Privateness === 'number' ? averageRatingToEmoji(mosque.Privateness) : '—'}
|
|
</Text>
|
|
<Text style={getListItemStyle(mosque.ChildFriendly)}>
|
|
Child Friendliness: {typeof mosque.ChildFriendly === 'number' ? averageRatingToEmoji(mosque.ChildFriendly) : '—'}
|
|
</Text>
|
|
<Text style={getListItemStyle(mosque.Safe)}>
|
|
Safety: {typeof mosque.Safe === 'number' ? averageRatingToEmoji(mosque.Safe) : '—'}
|
|
</Text>
|
|
</>
|
|
)}
|
|
|
|
<View style={infoModalStyles.facilitiesIconsContainer}>
|
|
{mosque.WomensSpace && <Text style={infoModalStyles.facilityIcon} accessibilityLabel="Women's Space Available">🚺 Women's Space</Text>}
|
|
{mosque.Wudu && <Text style={infoModalStyles.facilityIcon} accessibilityLabel="Wudu Available">💧 Wudu Facilities</Text>}
|
|
</View>
|
|
{mosque.Notes && <Text style={infoModalStyles.infoText}>Notes: {mosque.Notes}</Text>}
|
|
|
|
<View style={infoModalStyles.buttonRowContainer}>
|
|
<TouchableOpacity
|
|
style={[infoModalStyles.splitButtonBase, infoModalStyles.splitButtonLeft]}
|
|
onPress={handleShowReviewsFromModal}
|
|
>
|
|
<Text style={infoModalStyles.splitButtonText}>Show Reviews</Text>
|
|
</TouchableOpacity>
|
|
<View style={infoModalStyles.buttonSeparator} />
|
|
<TouchableOpacity
|
|
style={[infoModalStyles.splitButtonBase, infoModalStyles.splitButtonRight]}
|
|
onPress={openDirectionsInMaps}
|
|
>
|
|
<Text style={infoModalStyles.splitButtonText}>Get Directions</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<TouchableOpacity style={infoModalStyles.addReviewButton} onPress={handleAddReviewFromModal}>
|
|
<Text style={infoModalStyles.addReviewButtonText}>Add a Review</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={infoModalStyles.addPhotoButton} onPress={handleAddPhotoFromModal}>
|
|
<Text style={infoModalStyles.addPhotoButtonText}>Add a Photo</Text>
|
|
</TouchableOpacity>
|
|
|
|
{/* ReviewsModal - Now inline within MosqueInfoModal */}
|
|
<ReviewsModal
|
|
visible={isReviewsModalVisible}
|
|
onClose={() => setIsReviewsModalVisible(false)}
|
|
reviews={mosque.Reviews}
|
|
spaceName={mosque.Name}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
// --- ReviewsModal Component (Inline) ---
|
|
interface ReviewsModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
reviews: Review[];
|
|
spaceName: string;
|
|
}
|
|
|
|
const ReviewsModal: React.FC<ReviewsModalProps> = ({ visible, onClose, reviews, spaceName }) => {
|
|
return (
|
|
<Modal
|
|
animationType="slide"
|
|
transparent={true}
|
|
visible={visible}
|
|
onRequestClose={onClose}
|
|
>
|
|
<View style={infoModalStyles.centeredView}>
|
|
<View style={infoModalStyles.modalView}>
|
|
<Text style={infoModalStyles.modalTitle}>Reviews for {spaceName}</Text>
|
|
<ScrollView style={infoModalStyles.infoScrollView}>
|
|
{reviews.length === 0 ? (
|
|
<Text style={infoModalStyles.noReviewsPlaceholderText}>No reviews yet.</Text>
|
|
) : (
|
|
reviews.map((review, index) => (
|
|
<View key={review.ID || `review-${index}`} style={infoModalStyles.reviewItem}>
|
|
{review.Rating !== undefined && review.Rating !== null && (
|
|
<Text style={infoModalStyles.reviewRating}>Overall Rating: {averageRatingToEmoji(review.Rating)}</Text>
|
|
)}
|
|
<Text style={infoModalStyles.reviewDetail}>Cleanliness: {typeof review.Clean === 'number' ? averageRatingToEmoji(review.Clean) : '—'}</Text>
|
|
<Text style={infoModalStyles.reviewDetail}>Wudu Cleanliness: {typeof review.CleanWudu === 'number' ? averageRatingToEmoji(review.CleanWudu) : '—'}</Text>
|
|
<Text style={infoModalStyles.reviewDetail}>Quietness: {typeof review.Quiet === 'number' ? averageRatingToEmoji(review.Quiet) : '—'}</Text>
|
|
<Text style={infoModalStyles.reviewDetail}>Privacy: {typeof review.Private === 'number' ? averageRatingToEmoji(review.Private) : '—'}</Text>
|
|
<Text style={infoModalStyles.reviewDetail}>Child Friendliness: {typeof review.ChildFriendly === 'number' ? averageRatingToEmoji(review.ChildFriendly) : '—'}</Text>
|
|
<Text style={infoModalStyles.reviewDetail}>Safety: {typeof review.Safe === 'number' ? averageRatingToEmoji(review.Safe) : '—'}</Text>
|
|
{review.Comment && <Text style={infoModalStyles.reviewComment}>"{review.Comment}"</Text>}
|
|
{review.User && <Text style={infoModalStyles.reviewUser}>— {review.User}</Text>}
|
|
</View>
|
|
))
|
|
)}
|
|
</ScrollView>
|
|
<TouchableOpacity style={infoModalStyles.closeButton} onPress={onClose}>
|
|
<Text style={infoModalStyles.closeButtonText}>Close</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
|
|
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',
|
|
},
|
|
}); |