commit 4e949822622a472e6700fcdb6a0494dc12cb7e95 Author: os222 Date: Sat Jun 21 21:22:52 2025 +0100 Initial commit - copied over from GitLab diff --git a/.env b/.env new file mode 100644 index 0000000..79dc4c9 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +DB_USER=user +DB_PASSWORD=password +DB_NAME=main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61494d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo +DRP-App/node_modules/ +DRP-App/package-lock.json diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..c5e5397 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,44 @@ +image: node:22.16.0 + +variables: + ANDROID_SDK_ROOT: "/sdk" + JAVA_HOME: "/usr/lib/jvm/openjdk-17" + + EAS_NO_VCS: "1" + CI: "true" + + +cache: + paths: + - node_modules/ + +stages: + - restart_docker + - deploy + - test + - build + + +restart_docker: + stage: restart_docker + script: + - sudo docker compose down + +deploy_backend: + stage: deploy + image: docker:latest + script: + - sudo docker compose up --build -d + +build_android: + stage: build + script: + - cd DRP-App/ + - npm install + - export EXPO_TOKEN=$EXPO_TOKEN + # - eas build -p android --non-interactive + + artifacts: + paths: + - dist/*.apk + expire_in: 1 week diff --git a/DRP-App/.easignore b/DRP-App/.easignore new file mode 100644 index 0000000..c9b73eb --- /dev/null +++ b/DRP-App/.easignore @@ -0,0 +1,13 @@ +# Ignore system/user/temp directories +AppData/ +*.tmp +*.log + +# Node & cache +node_modules/ +npm-debug.log +.expo/ +.expo-shared/ +dist/ +build/ +coverage/ diff --git a/DRP-App/.gitignore b/DRP-App/.gitignore new file mode 100644 index 0000000..7eeb193 --- /dev/null +++ b/DRP-App/.gitignore @@ -0,0 +1,43 @@ +# Build and system generated +.expo/ +.expo-shared/ +DRP-App/node_modules/ +.vscode/ + +# Environment and credentials +.env +.env.* + +# Build outputs (if any) +dist/ +build/ +web-build/ + +# OS files +.DS_Store +Thumbs.db + +# IDE/Editor +.idea/ +*.log + +# TypeScript +*.tsbuildinfo + +# Optional cache +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Expo specific +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/DRP-App/README.md b/DRP-App/README.md new file mode 100644 index 0000000..48dd63f --- /dev/null +++ b/DRP-App/README.md @@ -0,0 +1,50 @@ +# Welcome to your Expo app πŸ‘‹ + +This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). + +## Get started + +1. Install dependencies + + ```bash + npm install + ``` + +2. Start the app + + ```bash + npx expo start + ``` + +In the output, you'll find options to open the app in a + +- [development build](https://docs.expo.dev/develop/development-builds/introduction/) +- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) +- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) +- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo + +You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). + +## Get a fresh project + +When you're ready, run: + +```bash +npm run reset-project +``` + +This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. + +## Learn more + +To learn more about developing your project with Expo, look at the following resources: + +- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). +- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. + +## Join the community + +Join our community of developers creating universal apps. + +- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. +- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. diff --git a/DRP-App/app.config.js b/DRP-App/app.config.js new file mode 100644 index 0000000..3214d74 --- /dev/null +++ b/DRP-App/app.config.js @@ -0,0 +1,31 @@ +export default ({ config }) => ({ + // 1) spread in everything from app.json under "expo" + ...config, + + // 2) inject the googleMaps.apiKey from the env + android: { + // keep all existing android fields (adaptiveIcon, edgeToEdgeEnabled, package, permissions) + ...config.android, + + config: { + ...(config.android?.config || {}), + googleMaps: { + apiKey: process.env.ANDROID_MAPS_API_KEY || 'AIzaSyB1WZHDqjGk696AmVw7tA2sMAuOurt552Q' // When running dev mode or starting apk build locally, this will need to be replaced with the actual API key + } // You can get this from CI/CD variables + }, + // Permissions (unchanged) + permissions: config.android?.permissions || [ + 'ACCESS_FINE_LOCATION', + 'ACCESS_COARSE_LOCATION' + ] + }, + + ios: { + // keep everything in iOS as-is, but inject googleMapsApiKey + ...config.ios, + config: { + ...(config.ios?.config || {}), + googleMapsApiKey: process.env.IOS_MAPS_API_KEY || '' + } + }, +}); \ No newline at end of file diff --git a/DRP-App/app.json b/DRP-App/app.json new file mode 100644 index 0000000..c52de0b --- /dev/null +++ b/DRP-App/app.json @@ -0,0 +1,64 @@ +{ + "expo": { + "name": "prayer-space-finder", + "slug": "prayer-space-finder", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/logo.jpeg", + "scheme": "prayerspacefinder", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.prayerspacefinder", + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/images/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "edgeToEdgeEnabled": true, + "package": "com.anonymous.prayerspacefinder", + "permissions": [ + "ACCESS_FINE_LOCATION", + "ACCESS_COARSE_LOCATION" + ] + }, + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + ["expo-build-properties", { + "android": { + "usesCleartextTraffic": true + } + }], + "expo-router", + [ + "expo-splash-screen", + { + "image": "./assets/images/splash-icon.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#ffffff" + } + ], + "expo-secure-store" + ], + "experiments": { + "typedRoutes": true + }, + "extra": { + "router": {}, + "eas": { + "projectId": "8c9da06e-de9b-4251-843f-c86aea8cfc27" + } + }, + "owner": "sawalha04" + } +} diff --git a/DRP-App/app/(tabs)/_layout.tsx b/DRP-App/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..f90886e --- /dev/null +++ b/DRP-App/app/(tabs)/_layout.tsx @@ -0,0 +1,61 @@ +import { HapticTab } from '@/components/HapticTab'; +import { IconSymbol } from '@/components/ui/IconSymbol'; +import TabBarBackground from '@/components/ui/TabBarBackground'; +import { Colors } from '@/constants/Colors'; +import { Tabs } from 'expo-router'; +import React from 'react'; +import { Platform, useColorScheme } from 'react-native'; + +export default function TabLayout() { + const colorScheme = useColorScheme(); + + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + + ); +} diff --git a/DRP-App/app/(tabs)/index.tsx b/DRP-App/app/(tabs)/index.tsx new file mode 100644 index 0000000..53d2dda --- /dev/null +++ b/DRP-App/app/(tabs)/index.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Image, ScrollView } from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; + +export default function HomeScreen() { + const router = useRouter(); + + return ( + + + Welcome to Sajidaat + + Discover, review, and share prayer spaces near you. Find mosques, prayer rooms, and community spaces with real user reviews and photos. + + router.push('/(tabs)/mapList')}> + + View Prayer Spaces List + + router.push('/(tabs)/map')}> + + Explore Map + + router.push('/(tabs)/prayTime')}> + + Check Prayer Times + + + How it works: + β€’ Browse a map or list of prayer spaces + β€’ See reviews, photos, and facilities + β€’ Add your own reviews and photos + β€’ Help others find the best places to pray + + {/* Made with by the DRP Project Team */} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexGrow: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + backgroundColor: '#FFEEE7', + }, + logo: { + width: 100, + height: 100, + marginBottom: 24, + borderRadius: 20, + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: '#007bff', + marginBottom: 10, + textAlign: 'center', + }, + subtitle: { + fontSize: 16, + color: '#555', + marginBottom: 24, + textAlign: 'center', + paddingHorizontal: 10, + }, + button: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#007bff', + paddingVertical: 14, + paddingHorizontal: 24, + borderRadius: 8, + marginVertical: 8, + width: '90%', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, + }, + buttonText: { + color: '#fff', + fontSize: 18, + fontWeight: '600', + }, + infoBox: { + backgroundColor: '#E2A593', // changed from #FFEEE7 to #E2A593 for filter/info box + borderRadius: 10, + padding: 18, + marginTop: 28, + marginBottom: 16, + width: '100%', + }, + infoTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + infoText: { + fontSize: 15, + color: '#555', + marginBottom: 4, + }, + footer: { + fontSize: 14, + color: '#888', + marginTop: 16, + textAlign: 'center', + }, +}); diff --git a/DRP-App/app/(tabs)/map.tsx b/DRP-App/app/(tabs)/map.tsx new file mode 100644 index 0000000..0060c51 --- /dev/null +++ b/DRP-App/app/(tabs)/map.tsx @@ -0,0 +1,938 @@ +// 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', + }, +}); \ No newline at end of file diff --git a/DRP-App/app/(tabs)/mapList.tsx b/DRP-App/app/(tabs)/mapList.tsx new file mode 100644 index 0000000..833d313 --- /dev/null +++ b/DRP-App/app/(tabs)/mapList.tsx @@ -0,0 +1,1489 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + Switch, + ScrollView, + TouchableOpacity, + Platform, + Alert, + Image, + ActivityIndicator, + Modal, + Dimensions, + TextInput, // Needed for the new ReviewForm + Button, // Needed for the new ReviewForm + Linking, +} from 'react-native'; +import { Picker } from '@react-native-picker/picker'; +import Slider from '@react-native-community/slider'; +import { useRouter } from 'expo-router'; +import * as Location from 'expo-location'; + +import ImageViewerModal from '../ImageViewerModal'; // Assuming this is still a separate file + +// --- Interfaces --- +export interface ImageObject { + id?: string; // ID should be optional for frontend if not always present or generated by backend + url: string; + note?: string; +} + +export interface Review { + ID: string; + Rating?: number; // Calculated average (quiet+clean+private)/3 from backend, optional if not always present + Quiet: number; + Clean: number; + Private: number; + Comment?: string; // Optional: if you add a comment column to your DB later + User?: string; // Optional: The reviewer's username, derived from a join with users table in backend + // NEW fields in Review from your provided code + CleanWudu?: number; + ChildFriendly?: number; + Safe?: number; +} + +interface PrayerSpaceBase { + id: string; + name: string; + latitude: number; + longitude: number; + address: string; + type: 'Mosque' | 'Prayer Room' | 'Other' | 'Outdoor Space' | 'Multi-Faith Room'; + womensSpace: boolean; + wudu: boolean; + openingHours: string; + clean: number; // Average cleanliness rating from reviews + // NEW fields in PrayerSpaceBase from your provided code + cleanWudu: number; // Average wudu cleanliness rating from reviews + quiet: number; // Average quietness rating from reviews + privateness: number; // Average privateness rating from reviews + childFriendly: number; // Average child friendly rating from reviews + safe: number; // Average safety rating from reviews + numberOfReviews: number; + images: ImageObject[]; + notes?: string; + website: string; + reviews: Review[]; // Array of detailed review objects for this space +} + +// Function to map backend data to frontend PrayerSpaceBase structure +function mapBackendSpaceToPrayerSpace(backendSpace: any): PrayerSpaceBase { + const { Reviews = [], Images = [] } = backendSpace; + + const totalClean = Reviews.reduce((acc: number, r: Review) => acc + (r.Clean || 0), 0); + const totalCleanWudu = Reviews.reduce((acc: number, r: Review) => acc + (r.CleanWudu || 0), 0); // NEW + const totalQuiet = Reviews.reduce((acc: number, r: Review) => acc + (r.Quiet || 0), 0); + const totalPrivate = Reviews.reduce((acc: number, r: Review) => acc + (r.Private || 0), 0); + const totalChildFriendly = Reviews.reduce((acc: number, r: Review) => acc + (r.ChildFriendly || 0), 0); // NEW + const totalSafe = Reviews.reduce((acc: number, r: Review) => acc + (r.Safe || 0), 0); // NEW + const totalRating = Reviews.reduce((acc: number, r: Review) => acc + (r.Rating || 0), 0); + + + const averageClean = Reviews.length ? (totalClean / Reviews.length) : 0; + const averageCleanWudu = Reviews.length ? (totalCleanWudu / Reviews.length) : 0; // NEW + const averageQuiet = Reviews.length ? (totalQuiet / Reviews.length) : 0; + const averagePrivate = Reviews.length ? (totalPrivate / Reviews.length) : 0; + const averageChildFriendly = Reviews.length ? (totalChildFriendly / Reviews.length) : 0; // NEW + const averageSafe = Reviews.length ? (totalSafe / Reviews.length) : 0; // NEW + const averageOverallRating = Reviews.length ? (totalRating / Reviews.length) : 0; + + return { + id: backendSpace.ID, + name: backendSpace.Name, + latitude: backendSpace.Latitude, + longitude: backendSpace.Longitude, + address: backendSpace.Address, + type: prayerSpaceTypeMap(backendSpace.LocationType), // Using prayerSpaceTypeMap + womensSpace: backendSpace.WomensSpace, + wudu: backendSpace.Wudu, + openingHours: '', + clean: averageClean, + cleanWudu: averageCleanWudu, // NEW + quiet: averageQuiet, + privateness: averagePrivate, + childFriendly: averageChildFriendly, // NEW + safe: averageSafe, // NEW + numberOfReviews: Reviews.length, + images: Images.map((img: any) => ({ + id: img.ID, + url: convertToFullImageUrl(img.ImageURL), // Applying convertToFullImageUrl + note: img.Notes, + })) || [], // Ensure images is an array even if backend returns null/undefined + notes: backendSpace.Notes, + website: backendSpace.WebsiteURL, + reviews: Reviews, + }; +} + +// --- Image URL Conversion --- +function convertToFullImageUrl(imageUrl: string): string { + // If it's already a full URL (starts with http:// or https://), return as is + if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { + return imageUrl; + } + + // If it's a server path, convert it to a full URL + // IMPORTANT: Replace this IP with your actual backend's accessible IP/domain if not running locally + // or if your phone/emulator cannot reach 132.145.65.145 + return `http://132.145.65.145:8080${imageUrl}`; +} + +// --- Emoji Rating Helpers --- +function averageRatingToEmoji(avg: number) { + if (avg <= 0.5) { + return "Unrated"; // If avg is 0 or less, return "Unrated" + } + if (avg < 1.66) { + return "😞"; // closer to 1 (sad) + } else if (avg < 2.33) { + return "😐"; // closer to 2 (neutral) + } else { + return "😊"; // closer to 3 (happy) + } +} + +export function getListItemStyle(rating: number) { + return { + ...styles.listItemText, + backgroundColor: getBackgroundColorForRating(rating), + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + marginBottom: 6, + }; +} + +function getBackgroundColorForRating(rating: number) { + if (typeof rating !== 'number' || rating <= 0.5) { + return '#e0e0e0'; // Darker gray for unrated (more visible) + } + if (rating < 1.66) { + return '#ffcdd2'; // More saturated light red for sad (😞) + } else if (rating < 2.33) { + return '#ffe0b2'; // More saturated light orange for neutral (😐) + } else { + return '#c8e6c9'; // More saturated light green for happy (😊) + } +} + +function averageRatingToText(avg: number) { + if (avg <= 0.5) { + return "" + } else { + return avg.toFixed(1); + } +} + +interface EmojiRatingSelectorProps { + label: string; + value: number | null; + onSelect: (value: number) => void; + style?: object; // Allow custom styles +} + +function getReviewDetailStyle(rating: number) { + return { + ...modalStyles.reviewDetail, + backgroundColor: getBackgroundColorForRating(rating), + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + marginBottom: 4, + }; +} + +const EmojiRatingSelector: React.FC = ({ label, value, onSelect, style }) => { + const emojis = [ + { emoji: "😞", value: 1, label: "Poor" }, + { emoji: "😐", value: 2, label: "Okay" }, + { emoji: "😊", value: 3, label: "Great" } + ]; + + return ( + + {label} + + {emojis.map((item) => ( + onSelect(item.value)} + > + {item.emoji} + {item.label} + + ))} + + + ); +}; + +// --- Prayer Space Type Map --- +// --- Prayer Space Type Map --- +// UPDATED: prayerSpaceTypeMap function to include new types for display +function prayerSpaceTypeMap(str: string): 'Mosque' | 'Prayer Room' | 'Community Space' | 'Other' | 'Outdoor Space' | 'Multi-Faith Room' { + switch (str) { + case "mosque": + return "Mosque" + case "outdoor_space": // NEW Type + return "Outdoor Space" + case "multi_faith_room": // NEW Type + return "Multi-Faith Room" + case "other": + return "Other" + default: + return "Other" // Fallback for unexpected values + } +} + +interface PrayerSpaceWithDistance extends PrayerSpaceBase { + calculatedDistance: number | null; +} + +interface Filters { + showWomensSpace: boolean; + showWudu: boolean; + minRating: number; + spaceType: ('Mosque' | 'Prayer Room' | 'Community Space' | 'All' | 'Other'); + cleanRating?: number; + cleanWuduRating?: number; + quietRating?: number; + privatenessRating?: number; + childFriendlyRating?: number; + safeRating?: number; +} + +// --- Haversine Distance Calculation --- +function getDistanceInKm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371; // Radius of the earth in km + const dLat = deg2rad(lat2 - lat1); + const dLon = deg2rad(lon2 - lon1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; // Distance in km +} + +function deg2rad(deg: number): number { + return deg * (Math.PI / 180); +} + +// --- ReviewsModal Component (Inline) --- +interface ReviewsModalProps { + visible: boolean; + onClose: () => void; + reviews: Review[]; + spaceName: string; +} + +const { width, height } = Dimensions.get('window'); + +const ReviewsModal: React.FC = ({ visible, onClose, reviews, spaceName }) => { + return ( + + + + Reviews for {spaceName} + + {reviews.length === 0 ? ( + No reviews yet. Be the first to leave one! + ) : ( + + {reviews.map((review, index) => ( + + {/* Assuming Rating field exists, if not, calculate here */} + {review.Rating !== undefined && review.Rating !== null && ( + Overall Rating: ⭐ {review.Rating.toFixed(1)} + )} + {/* Using new fields in review display */} + + 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 modalStyles = StyleSheet.create({ + centeredView: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.6)', + }, + modalView: { + margin: 20, + backgroundColor: '#FFEEE7', // modal background + borderRadius: 10, + padding: 25, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + width: width * 0.9, + maxHeight: height * 0.8, + }, + modalTitle: { + fontSize: 22, + fontWeight: 'bold', + marginBottom: 20, + color: '#333', + textAlign: 'center', + }, + reviewsScrollView: { + width: '100%', + maxHeight: height * 0.6, + marginBottom: 15, + }, + reviewItem: { + backgroundColor: '#FFEEE7', // list item background + 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', + }, + noReviewsText: { + fontSize: 16, + color: '#777', + textAlign: 'center', + paddingVertical: 20, + }, + closeButton: { + backgroundColor: '#007bff', + borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 25, + marginTop: 10, + minWidth: 120, + alignItems: 'center', + }, + closeButtonText: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + }, +}); +// --- End ReviewsModal Component --- + +// --- ReviewForm Component (Inline - sends strings, matching your working ReviewScreen.tsx) --- +interface ReviewFormProps { + placeName: string; // Now expecting place NAME string + onReviewSubmitted: () => void; +} + +const HARDCODED_USERNAME = 'testuser'; // Hardcoded username string, matching your seed user + +const ReviewForm: React.FC = ({ placeName, onReviewSubmitted }) => { + const [quiet, setQuiet] = useState(null); + const [clean, setClean] = useState(null); + const [privacy, setPrivacy] = useState(null); + const [cleanWudu, setCleanWudu] = useState(null); // NEW + const [childFriendly, setChildFriendly] = useState(null); // NEW + const [safe, setSafe] = useState(null); // NEW + const [comment, setComment] = useState(''); // Uncomment if you add comment to DB + + // Updated isValidRating for new emoji scale (1-3) + const isValidRating = (val: number | null): boolean => { + return val !== null && val >= 1 && val <= 3; + }; + + const handleSubmit = async () => { + // Validate all NEW rating fields + if (!quiet || !clean || !privacy || !cleanWudu || !childFriendly || !safe) { + Alert.alert('Missing Info', 'Please fill in all rating fields.'); + return; + } + + // Validate all NEW rating fields with isValidRating + if (![quiet, clean, privacy, cleanWudu, childFriendly, safe].every(isValidRating)) { + Alert.alert('Invalid Input', 'Please select a rating for each category (1-3).'); + return; + } + + // Constructing the payload with username and place name strings + const payload = { + user: HARDCODED_USERNAME, // Hardcoded username string + place: placeName, // Place NAME string + quiet: quiet, + clean: clean, + private: privacy, + cleanWudu: cleanWudu, // NEW + childFriendly: childFriendly, // NEW + safe: safe, // NEW + notes: comment, // Uncomment if you add comment to DB + }; + + console.log("Sending review payload (strings and new ratings):", payload); + + try { + const response = await fetch('http://132.145.65.145:8080/reviews/new', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Unknown error' })); + console.error("Backend error response:", errorData); + throw new Error(errorData.message || `HTTP error! Status: ${response.status}`); + } + + const data = await response.json(); + Alert.alert('Success', data.message || 'Review submitted!'); + + // Reset form (including new fields) + setQuiet(null); + setClean(null); + setPrivacy(null); + setCleanWudu(null); // NEW + setChildFriendly(null); // NEW + setSafe(null); // NEW + onReviewSubmitted(); + } catch (error: any) { + Alert.alert('Error', `Could not send review: ${error.message || error}`); + console.error("Review submission error:", error); + } + }; + + return ( + + Submit a Review for {placeName} + + {/* Using EmojiRatingSelector for all ratings */} + + + + + + + + + +