Initial commit - copied over from GitLab
39
.gitignore
vendored
Normal file
@ -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
|
||||||
44
.gitlab-ci.yml
Normal file
@ -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
|
||||||
13
DRP-App/.easignore
Normal file
@ -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/
|
||||||
43
DRP-App/.gitignore
vendored
Normal file
@ -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
|
||||||
50
DRP-App/README.md
Normal file
@ -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.
|
||||||
31
DRP-App/app.config.js
Normal file
@ -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 || ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
64
DRP-App/app.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
61
DRP-App/app/(tabs)/_layout.tsx
Normal file
@ -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 (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: Colors[colorScheme ?? 'dark'].tint,
|
||||||
|
headerShown: false,
|
||||||
|
tabBarButton: HapticTab,
|
||||||
|
tabBarBackground: TabBarBackground,
|
||||||
|
tabBarStyle: Platform.select({
|
||||||
|
ios: {
|
||||||
|
// Use a transparent background on iOS to show the blur effect
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
// Use a solid background on Android
|
||||||
|
backgroundColor: Colors[colorScheme ?? 'dark'].background,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
}),
|
||||||
|
}}>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Home',
|
||||||
|
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="map"
|
||||||
|
options={{
|
||||||
|
title: 'map',
|
||||||
|
tabBarIcon: ({ color }) => <IconSymbol size={28} name="map.fill" color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="mapList"
|
||||||
|
options={{
|
||||||
|
title: 'Prayer Spaces',
|
||||||
|
tabBarIcon: ({ color }) => <IconSymbol size={28} name="chevron.left.forwardslash.chevron.right" color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="prayTime"
|
||||||
|
options={{
|
||||||
|
title: 'Prayer Times',
|
||||||
|
tabBarIcon: ({ color }) => <IconSymbol size={28} name="clock.fill" color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
DRP-App/app/(tabs)/index.tsx
Normal file
@ -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 (
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Image source={require('../../assets/images/logo.jpeg')} style={styles.logo} />
|
||||||
|
<Text style={styles.title}>Welcome to Sajidaat</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Discover, review, and share prayer spaces near you. Find mosques, prayer rooms, and community spaces with real user reviews and photos.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.button} onPress={() => router.push('/(tabs)/mapList')}>
|
||||||
|
<Ionicons name="list" size={24} color="#fff" style={{ marginRight: 8 }} />
|
||||||
|
<Text style={styles.buttonText}>View Prayer Spaces List</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.button} onPress={() => router.push('/(tabs)/map')}>
|
||||||
|
<Ionicons name="map" size={24} color="#fff" style={{ marginRight: 8 }} />
|
||||||
|
<Text style={styles.buttonText}>Explore Map</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.button} onPress={() => router.push('/(tabs)/prayTime')}>
|
||||||
|
<Ionicons name="time" size={24} color="#fff" style={{ marginRight: 8 }} />
|
||||||
|
<Text style={styles.buttonText}>Check Prayer Times</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={styles.infoBox}>
|
||||||
|
<Text style={styles.infoTitle}>How it works:</Text>
|
||||||
|
<Text style={styles.infoText}>• Browse a map or list of prayer spaces</Text>
|
||||||
|
<Text style={styles.infoText}>• See reviews, photos, and facilities</Text>
|
||||||
|
<Text style={styles.infoText}>• Add your own reviews and photos</Text>
|
||||||
|
<Text style={styles.infoText}>• Help others find the best places to pray</Text>
|
||||||
|
</View>
|
||||||
|
{/* <Text style={styles.footer}>Made with <Ionicons name="heart" size={16} color="#e74c3c" /> by the DRP Project Team</Text> */}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
938
DRP-App/app/(tabs)/map.tsx
Normal file
@ -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<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',
|
||||||
|
},
|
||||||
|
});
|
||||||
1489
DRP-App/app/(tabs)/mapList.tsx
Normal file
306
DRP-App/app/(tabs)/prayTime.tsx
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { View, Text, FlatList, StyleSheet, Alert } from 'react-native';
|
||||||
|
import * as Location from 'expo-location';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
|
||||||
|
type PrayerTime = {
|
||||||
|
name: string;
|
||||||
|
time: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PrayerTimeDate = {
|
||||||
|
name: string;
|
||||||
|
time: Dayjs;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PrayerData = {
|
||||||
|
location: string;
|
||||||
|
date: string;
|
||||||
|
times: PrayerTime[];
|
||||||
|
timesAsDates: PrayerTimeDate[];
|
||||||
|
sunriseTime: string;
|
||||||
|
hanafiAsr: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PrayersScreen() {
|
||||||
|
const [prayerData, setPrayerData] = useState<PrayerData | null>(null);
|
||||||
|
const [nextPrayer, setNextPrayer] = useState<{ name: string; countdown: string } | null>(null);
|
||||||
|
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const getCoords = async () => {
|
||||||
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
throw new Error('Permission to access location was denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await Location.getCurrentPositionAsync({});
|
||||||
|
return location.coords;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPrayerTimes = async () => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const coords = await getCoords();
|
||||||
|
const today = dayjs();
|
||||||
|
const yyyy = today.year();
|
||||||
|
const mm = String(today.month() + 1).padStart(2, '0'); // month() is 0-based
|
||||||
|
const dd = String(today.date()).padStart(2, '0');
|
||||||
|
const formattedDate = `${yyyy}-${mm}-${dd}`;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.aladhan.com/v1/timings/${formattedDate}?latitude=${coords.latitude}&longitude=${coords.longitude}&method=2`);
|
||||||
|
const hanafiRes = await fetch(
|
||||||
|
`https://api.aladhan.com/v1/timings/${formattedDate}?latitude=${coords.latitude}&longitude=${coords.longitude}&method=2&school=1`);
|
||||||
|
const data = await res.json();
|
||||||
|
const hanafiData = await hanafiRes.json();
|
||||||
|
const hanafiTiming = hanafiData.data.timings.Asr;
|
||||||
|
|
||||||
|
const timings = data.data.timings;
|
||||||
|
const formattedTimes = [
|
||||||
|
{ name: 'Fajr', time: timings.Fajr },
|
||||||
|
{ name: 'Dhuhr', time: timings.Dhuhr },
|
||||||
|
{ name: 'Asr', time: timings.Asr },
|
||||||
|
{ name: 'Maghrib', time: timings.Maghrib },
|
||||||
|
{ name: 'Isha', time: timings.Isha },
|
||||||
|
];
|
||||||
|
|
||||||
|
setPrayerData({
|
||||||
|
location: data.data.meta.timezone,
|
||||||
|
date: `${data.data.date.gregorian.date} / ${data.data.date.hijri.date}`,
|
||||||
|
times: formattedTimes,
|
||||||
|
timesAsDates: getPrayerDateTimes(formattedTimes),
|
||||||
|
sunriseTime: timings.Sunrise,
|
||||||
|
hanafiAsr: hanafiTiming
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching prayer times:', err);
|
||||||
|
Alert.alert('Error', 'Failed to fetch prayer times.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: convert prayer times into Date objects for today
|
||||||
|
const getPrayerDateTimes = (times: PrayerTime[]) => {
|
||||||
|
const now = dayjs();
|
||||||
|
return times.map(({ name, time }) => {
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
|
// Create a Dayjs object for today with set hours and minutes
|
||||||
|
const prayerTime = now.hour(hours).minute(minutes).second(0).millisecond(0);
|
||||||
|
return { name, time: prayerTime };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedulePrayerNotification = async (prayerName: string, prayerTime: dayjs.Dayjs) => {
|
||||||
|
const triggerTime = prayerTime.subtract(10, 'minute');
|
||||||
|
|
||||||
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: `Upcoming Prayer: ${prayerName}`,
|
||||||
|
body: `Time for ${prayerName} is in 10 minutes.`,
|
||||||
|
sound: true,
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
type: 'date',
|
||||||
|
date: triggerTime.toDate(),
|
||||||
|
} as Notifications.DateTriggerInput,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCurrentPrayer = (prayerTimes: PrayerData, prayerName: string): boolean => {
|
||||||
|
const times = prayerTimes.timesAsDates;
|
||||||
|
const now = dayjs();
|
||||||
|
// Sort times just in case
|
||||||
|
times.sort((a, b) => a.time.valueOf() - b.time.valueOf());
|
||||||
|
|
||||||
|
for (let i = times.length - 1; i >= 0; i--) {
|
||||||
|
if (now.isAfter(times[i].time) || now.isSame(times[i].time)) {
|
||||||
|
// Check if the current prayer matches the prayerName
|
||||||
|
return times[i].name === prayerName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If none matched, compare to last prayer (optional fallback)
|
||||||
|
return times.length > 0 ? times[times.length - 1].name === prayerName : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the next prayer based on current time
|
||||||
|
const findNextPrayer = (prayerTimes: PrayerTimeDate[]) => {
|
||||||
|
const now = dayjs();
|
||||||
|
|
||||||
|
// Find next prayer with time > now
|
||||||
|
const next = prayerTimes.find((p) => dayjs(p.time).isAfter(now));
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If none found, all prayers passed — fallback to first prayer tomorrow (Fajr)
|
||||||
|
// So add 1 day to first prayer time
|
||||||
|
const tomorrowFajr = prayerTimes[0].time.add(1, 'day');
|
||||||
|
return { name: prayerTimes[0].name, time: tomorrowFajr };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update countdown every second
|
||||||
|
const startCountdown = (prayerTimes: PrayerTimeDate[]) => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
const now = dayjs();
|
||||||
|
const next = findNextPrayer(prayerTimes);
|
||||||
|
|
||||||
|
const diff = next.time.diff(now);
|
||||||
|
|
||||||
|
// If countdown hits zero or negative, just continue to next prayer immediately
|
||||||
|
const safeDiff = diff > 0 ? diff : 0;
|
||||||
|
|
||||||
|
const hrs = String(Math.floor(safeDiff / (1000 * 60 * 60))).padStart(2, '0');
|
||||||
|
const mins = String(Math.floor((safeDiff / (1000 * 60)) % 60)).padStart(2, '0');
|
||||||
|
const secs = String(Math.floor((safeDiff / 1000) % 60)).padStart(2, '0');
|
||||||
|
|
||||||
|
setNextPrayer({
|
||||||
|
name: next.name,
|
||||||
|
countdown: `${hrs}:${mins}:${secs}`,
|
||||||
|
});
|
||||||
|
}, 1000) as any;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPrayerTimes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Start countdown when prayerData changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (prayerData?.times) {
|
||||||
|
startCountdown(prayerData.timesAsDates);
|
||||||
|
|
||||||
|
const now = dayjs();
|
||||||
|
|
||||||
|
// Only schedule notifications for prayers that haven't passed yet
|
||||||
|
prayerData.timesAsDates
|
||||||
|
.filter(prayer => prayer.time.isAfter(now))
|
||||||
|
.forEach(prayer => {
|
||||||
|
schedulePrayerNotification(prayer.name, prayer.time);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, [prayerData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Today's Prayer Times</Text>
|
||||||
|
{prayerData && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.date}>{prayerData.date}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{nextPrayer ? (
|
||||||
|
<Text style={styles.nextPrayer}>
|
||||||
|
Next prayer: {nextPrayer.name} in {nextPrayer.countdown}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.nextPrayer}>Calculating next prayer...</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prayerData ? (
|
||||||
|
<FlatList
|
||||||
|
data={prayerData.times}
|
||||||
|
keyExtractor={(item) => item.name}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const isCurrent = isCurrentPrayer(prayerData, item.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.card, isCurrent && styles.currentPrayerCard]}>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||||
|
<Text style={styles.prayerName}>{item.name}</Text>
|
||||||
|
<Text style={styles.prayerTime}>{item.time}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Fajr Sunrise */}
|
||||||
|
{item.name === 'Fajr' && prayerData.sunriseTime && (
|
||||||
|
<View style={styles.sunriseRow}>
|
||||||
|
<Text style={styles.subtitle}>Sunrise</Text>
|
||||||
|
<Text style={styles.subtitle}>{prayerData.sunriseTime}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Asr (Hanafi) */}
|
||||||
|
{item.name === 'Asr' && prayerData.hanafiAsr && (
|
||||||
|
<View style={styles.sunriseRow}>
|
||||||
|
<Text style={styles.subtitle}>Asr (Hanafi)</Text>
|
||||||
|
<Text style={styles.subtitle}>{prayerData.hanafiAsr}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{ paddingVertical: 10 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={{ marginTop: 20, textAlign: 'center' }}>Loading...</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, padding: 20, backgroundColor: '#FFEEE7' },
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: 6, // reduce space below title
|
||||||
|
},
|
||||||
|
sunriseRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#555',
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#888',
|
||||||
|
marginBottom: 2, // keep this small
|
||||||
|
},
|
||||||
|
nextPrayer: {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#008060',
|
||||||
|
marginTop: 4, // reduce gap above next prayer
|
||||||
|
marginBottom: 10,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
padding: 15,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
backgroundColor: '#fff', // changed to white
|
||||||
|
},
|
||||||
|
currentPrayerCard: {
|
||||||
|
backgroundColor: '#fff', // keep current prayer tab white too
|
||||||
|
borderColor: '#4CAF50',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
specialPrayerName: {
|
||||||
|
color: '#00796b', // Teal
|
||||||
|
},
|
||||||
|
specialPrayerTime: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
prayerName: { fontSize: 16, fontWeight: '600' },
|
||||||
|
prayerTime: { fontSize: 16 },
|
||||||
|
});
|
||||||
32
DRP-App/app/+not-found.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
|
||||||
|
export default function NotFoundScreen() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<ThemedText type="title">This screen does not exist.</ThemedText>
|
||||||
|
<Link href="/" style={styles.link}>
|
||||||
|
<ThemedText type="link">does this ever happen?</ThemedText>
|
||||||
|
</Link>
|
||||||
|
</ThemedView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
marginTop: 15,
|
||||||
|
paddingVertical: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
182
DRP-App/app/ImageViewerModal.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
// ImageViewerModal.tsx
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Dimensions,
|
||||||
|
FlatList,
|
||||||
|
SafeAreaView,
|
||||||
|
Platform
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
export interface ImageObject {
|
||||||
|
url: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface ImageViewerModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
images: ImageObject[];
|
||||||
|
initialIndex?: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageViewerModal: React.FC<ImageViewerModalProps> = ({
|
||||||
|
visible,
|
||||||
|
images,
|
||||||
|
initialIndex = 0,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
|
const flatListRef = useRef<FlatList<ImageObject>>(null);
|
||||||
|
|
||||||
|
// Effect to scroll to initialIndex when modal becomes visible or initialIndex prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && images && images.length > 0) {
|
||||||
|
const validInitialIndex = Math.max(0, Math.min(initialIndex, images.length - 1));
|
||||||
|
setCurrentIndex(validInitialIndex);
|
||||||
|
// setTimeout is a common workaround for FlatList not scrolling immediately
|
||||||
|
// to initialScrollIndex on mount or when data changes if modal was hidden.
|
||||||
|
setTimeout(() => {
|
||||||
|
flatListRef.current?.scrollToIndex({
|
||||||
|
animated: false,
|
||||||
|
index: validInitialIndex,
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [visible, initialIndex, images]);
|
||||||
|
|
||||||
|
const onViewableItemsChanged = useRef(
|
||||||
|
({ viewableItems }: { viewableItems: Array<{ item: ImageObject; index: number | null }> }) => {
|
||||||
|
if (viewableItems.length > 0 && viewableItems[0].index !== null) {
|
||||||
|
setCurrentIndex(viewableItems[0].index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).current;
|
||||||
|
|
||||||
|
const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 50 }).current;
|
||||||
|
|
||||||
|
if (!images || images.length === 0) {
|
||||||
|
return null; // Don't render modal if no images
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderImageItem = ({ item }: { item: ImageObject }) => (
|
||||||
|
<View style={styles.slide}>
|
||||||
|
<Image source={{ uri: item.url }} style={styles.largeImage} resizeMode="contain" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
animationType="slide"
|
||||||
|
transparent={false}
|
||||||
|
visible={visible}
|
||||||
|
onRequestClose={onClose} // For Android back button
|
||||||
|
>
|
||||||
|
<SafeAreaView style={styles.modalContainer}>
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
data={images}
|
||||||
|
renderItem={renderImageItem}
|
||||||
|
keyExtractor={(item, index) => `${item.url}-${index}`} // Ensure unique key
|
||||||
|
horizontal
|
||||||
|
pagingEnabled
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
onViewableItemsChanged={onViewableItemsChanged}
|
||||||
|
viewabilityConfig={viewabilityConfig}
|
||||||
|
// initialScrollIndex={currentIndex} // Let useEffect handle initial scroll
|
||||||
|
getItemLayout={(data, index) => (
|
||||||
|
{ length: screenWidth, offset: screenWidth * index, index }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{images[currentIndex]?.note && (
|
||||||
|
<View style={styles.noteContainer}>
|
||||||
|
<Text style={styles.noteText}>{images[currentIndex].note}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||||
|
<Text style={styles.closeButtonText}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{images.length > 1 && (
|
||||||
|
<View style={styles.pagination}>
|
||||||
|
<Text style={styles.paginationText}>{currentIndex + 1} / {images.length}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
modalContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'black',
|
||||||
|
},
|
||||||
|
slide: {
|
||||||
|
width: screenWidth,
|
||||||
|
height: '100%', // Make slide take full height of SafeAreaView content area
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
largeImage: {
|
||||||
|
width: screenWidth, // Image takes full width of its slide
|
||||||
|
height: '80%', // Image takes 80% height of its slide, adjust as needed
|
||||||
|
},
|
||||||
|
noteContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: Platform.OS === 'ios' ? 20 : 40, // Adjust for different OS safe areas or tab bars if modal is within tabs
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 10,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||||
|
},
|
||||||
|
noteText: {
|
||||||
|
color: 'white',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: Platform.OS === 'ios' ? 50 : 20, // Safer position for status bar, adjust if using custom header
|
||||||
|
right: 15,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 10, // Ensure it's above other elements
|
||||||
|
},
|
||||||
|
closeButtonText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 20, // Helps center the X
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: Platform.OS === 'ios' ? 55 : 25,
|
||||||
|
left: 15,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 15,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
paginationText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ImageViewerModal;
|
||||||
172
DRP-App/app/SearchComponent.tsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
// SearchComponent.tsx
|
||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import { View, TextInput, FlatList, TouchableOpacity, Text, StyleSheet, Alert, Keyboard } from 'react-native'; // Added Keyboard import
|
||||||
|
|
||||||
|
const GOOGLE_API_KEY = 'AIzaSyB1WZHDqjGk696AmVw7tA2sMAuOurt552Q'; // Your Google Places API Key
|
||||||
|
|
||||||
|
const TARGET_LATITUDE_DELTA = 0.005;
|
||||||
|
const TARGET_LONGITUDE_DELTA = 0.005;
|
||||||
|
|
||||||
|
interface SearchComponentProps {
|
||||||
|
onSelectPlace: (lat: number, lng: number, name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchComponent: React.FC<SearchComponentProps> = ({ onSelectPlace }) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [addressSuggestions, setAddressSuggestions] = useState<any[]>([]);
|
||||||
|
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Ref to track if a suggestion is currently being pressed
|
||||||
|
const isPressingSuggestion = useRef(false);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(async (text: string) => {
|
||||||
|
setSearchQuery(text);
|
||||||
|
if (debounceTimeout.current) {
|
||||||
|
clearTimeout(debounceTimeout.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length < 3) {
|
||||||
|
setAddressSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?input=${encodeURIComponent(text)}&key=${GOOGLE_API_KEY}&components=country:gb&location=51.5074,-0.1278&radius=20000`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data && data.predictions) {
|
||||||
|
setAddressSuggestions(data.predictions);
|
||||||
|
} else {
|
||||||
|
setAddressSuggestions([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google Autocomplete search error:', error);
|
||||||
|
setAddressSuggestions([]);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSuggestionPress = useCallback(async (item: any) => {
|
||||||
|
console.log("Autocomplete suggestion clicked:", item.description);
|
||||||
|
|
||||||
|
// NEW: Dismiss the keyboard immediately after a suggestion is selected
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
|
setSearchQuery(item.description);
|
||||||
|
setAddressSuggestions([]); // Hide suggestions after selection
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${item.place_id}&fields=geometry,name&key=${GOOGLE_API_KEY}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data && data.result && data.result.geometry) {
|
||||||
|
const { lat, lng } = data.result.geometry.location;
|
||||||
|
const name = data.result.name || item.description;
|
||||||
|
onSelectPlace(lat, lng, name);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Location Error', 'Could not get precise coordinates for selected place.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google Place Details error:', error);
|
||||||
|
Alert.alert('Network Error', 'Failed to fetch location details.');
|
||||||
|
}
|
||||||
|
}, [onSelectPlace]);
|
||||||
|
|
||||||
|
const handleEnterPress = useCallback(() => {
|
||||||
|
if (addressSuggestions.length > 0) {
|
||||||
|
handleSuggestionPress(addressSuggestions[0]); // Take the first suggestion
|
||||||
|
}
|
||||||
|
}, [addressSuggestions, handleSuggestionPress]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.searchBarContainer}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchBar}
|
||||||
|
placeholder="Search for a location (e.g., Oxford Street)"
|
||||||
|
placeholderTextColor="black" // Force dark placeholder for all platforms
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={handleSearch}
|
||||||
|
onBlur={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isPressingSuggestion.current) {
|
||||||
|
setAddressSuggestions([]);
|
||||||
|
}
|
||||||
|
}, 100); // Small delay
|
||||||
|
}}
|
||||||
|
onSubmitEditing={handleEnterPress}
|
||||||
|
/>
|
||||||
|
{addressSuggestions.length > 0 && (
|
||||||
|
<FlatList
|
||||||
|
style={styles.suggestionsList}
|
||||||
|
data={addressSuggestions}
|
||||||
|
keyExtractor={(item) => item.place_id}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.suggestionItem}
|
||||||
|
onPressIn={() => { isPressingSuggestion.current = true; }}
|
||||||
|
onPressOut={() => { isPressingSuggestion.current = false; }}
|
||||||
|
onPress={() => handleSuggestionPress(item)}
|
||||||
|
>
|
||||||
|
<Text style={styles.suggestionText}>{item.description}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
initialNumToRender={5}
|
||||||
|
maxToRenderPerBatch={5}
|
||||||
|
windowSize={10}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
searchBarContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 20,
|
||||||
|
left: 10,
|
||||||
|
right: 10,
|
||||||
|
zIndex: 10,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 10,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
searchBar: {
|
||||||
|
height: 40,
|
||||||
|
borderColor: '#ccc',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#222', // Ensure dark text for visibility
|
||||||
|
},
|
||||||
|
suggestionsList: {
|
||||||
|
maxHeight: 200,
|
||||||
|
marginTop: 5,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#eee',
|
||||||
|
},
|
||||||
|
suggestionItem: {
|
||||||
|
padding: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#eee',
|
||||||
|
},
|
||||||
|
suggestionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#222', // Ensure dark text for visibility
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SearchComponent;
|
||||||
56
DRP-App/app/_layout.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { AuthProvider } from '@/context/AuthContext';
|
||||||
|
import { Slot, SplashScreen, useRouter } from 'expo-router';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// Prevent the splash screen from auto-hiding
|
||||||
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [appReady, setAppReady] = useState(false);
|
||||||
|
const [splashComplete, setSplashComplete] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize your app
|
||||||
|
const prepare = async () => {
|
||||||
|
try {
|
||||||
|
// Request notification permissions
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
console.warn('Permission for notifications not granted!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up notification handler
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Artificially delay for demonstration
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
} finally {
|
||||||
|
setAppReady(true);
|
||||||
|
await SplashScreen.hideAsync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
prepare();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Slot />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
514
DRP-App/app/add_mosque.tsx
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
// app/add_mosque.tsx
|
||||||
|
import React, { useState, useEffect, useRef } from 'react'; // useRef added for debounceTimeout
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Button,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
TouchableOpacity,
|
||||||
|
Platform, // Ensure Platform is imported for style usage
|
||||||
|
Switch,
|
||||||
|
FlatList, // Added FlatList for autocomplete suggestions
|
||||||
|
} from 'react-native';
|
||||||
|
import * as Location from 'expo-location';
|
||||||
|
import { Picker } from '@react-native-picker/picker';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
|
||||||
|
interface AddPlaceResponse {
|
||||||
|
message: string;
|
||||||
|
placeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BACKEND_URL = 'http://132.145.65.145:8080'; // Your backend URL
|
||||||
|
const GOOGLE_API_KEY = 'AIzaSyB1WZHDqjGk696AmVw7tA2sMAuOurt552Q';
|
||||||
|
|
||||||
|
export default function AddMosqueScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const lat = params.lat;
|
||||||
|
const lng = params.lng;
|
||||||
|
const addressParam = params.address;
|
||||||
|
const nameParam = params.name;
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [latitude, setLatitude] = useState<string>('');
|
||||||
|
const [longitude, setLongitude] = useState<string>('');
|
||||||
|
const [locationType, setLocationType] = useState<'mosque' | 'other' | 'outdoor_space' | 'multi_faith_room'>('mosque');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [website, setWebsite] = useState(''); // NEW STATE FOR WEBSITE
|
||||||
|
|
||||||
|
const [isLoadingLocation, setIsLoadingLocation] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isGeocoding, setIsGeocoding] = useState(false); // Used for Google Details API fetch state
|
||||||
|
const [coordsObtained, setCoordsObtained] = useState(false);
|
||||||
|
|
||||||
|
// Autocomplete specific states
|
||||||
|
const [addressSuggestions, setAddressSuggestions] = useState<any[]>([]); // Google predictions have { description, place_id }
|
||||||
|
const [isAddressLoading, setIsAddressLoading] = useState(false); // Indicates loading for autocomplete suggestions
|
||||||
|
const debounceTimeout = useRef<NodeJS.Timeout | null>(null); // For debouncing input
|
||||||
|
|
||||||
|
|
||||||
|
// Track if user has interacted with the switches
|
||||||
|
const [womensSpace, setWomensSpace] = useState<null | boolean>(null);
|
||||||
|
const [wudu, setWudu] = useState<null | boolean>(null);
|
||||||
|
|
||||||
|
// Effect to auto-populate fields from query params on initial load
|
||||||
|
useEffect(() => {
|
||||||
|
if (lat && typeof lat === 'string') {
|
||||||
|
setLatitude(lat);
|
||||||
|
}
|
||||||
|
if (lng && typeof lng === 'string') {
|
||||||
|
setLongitude(lng);
|
||||||
|
}
|
||||||
|
if (addressParam && typeof addressParam === 'string') {
|
||||||
|
setAddress(decodeURIComponent(addressParam));
|
||||||
|
}
|
||||||
|
if (nameParam && typeof nameParam === 'string') {
|
||||||
|
setName(decodeURIComponent(nameParam));
|
||||||
|
}
|
||||||
|
if (lat && lng) {
|
||||||
|
setCoordsObtained(true);
|
||||||
|
}
|
||||||
|
}, [lat, lng, addressParam, nameParam]);
|
||||||
|
|
||||||
|
|
||||||
|
const getCurrentLocation = async () => {
|
||||||
|
setIsLoadingLocation(true);
|
||||||
|
setCoordsObtained(false); // Reset status
|
||||||
|
setLatitude(''); // Clear old coords
|
||||||
|
setLongitude(''); // Clear old coords
|
||||||
|
setAddress(''); // Clear address for new reverse geocoding
|
||||||
|
setAddressSuggestions([]); // Clear suggestions
|
||||||
|
|
||||||
|
let { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert('Permission denied', 'Permission to access location was denied. Cannot auto-fill coordinates.');
|
||||||
|
setIsLoadingLocation(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let location = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High });
|
||||||
|
setLatitude(location.coords.latitude.toString());
|
||||||
|
setLongitude(location.coords.longitude.toString());
|
||||||
|
setCoordsObtained(true);
|
||||||
|
|
||||||
|
const reverseGeocode = await Location.reverseGeocodeAsync({
|
||||||
|
latitude: location.coords.latitude,
|
||||||
|
longitude: location.coords.longitude,
|
||||||
|
});
|
||||||
|
if (reverseGeocode && reverseGeocode.length > 0) {
|
||||||
|
const firstResult = reverseGeocode[0];
|
||||||
|
const formattedAddress = [
|
||||||
|
firstResult.name,
|
||||||
|
firstResult.street,
|
||||||
|
firstResult.city,
|
||||||
|
firstResult.postalCode,
|
||||||
|
firstResult.country,
|
||||||
|
].filter(Boolean).join(', ');
|
||||||
|
setAddress(formattedAddress);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting current location:", error);
|
||||||
|
Alert.alert('Error', 'Failed to get current location. Please enter manually.');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingLocation(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const fetchGoogleSuggestions = async (input: string) => {
|
||||||
|
if (!input || input.length < 3) {
|
||||||
|
setAddressSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Debounce is now handled by handleAddressChangeWithDebounce,
|
||||||
|
// so this function itself doesn't need to debounce.
|
||||||
|
setIsAddressLoading(true); // Indicate loading for suggestions
|
||||||
|
try {
|
||||||
|
const url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?input=${encodeURIComponent(input)}&key=${GOOGLE_API_KEY}&components=country:gb&location=51.5074,-0.1278&radius=20000`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data && data.predictions) {
|
||||||
|
setAddressSuggestions(data.predictions);
|
||||||
|
} else {
|
||||||
|
setAddressSuggestions([]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Google Autocomplete fetch error:', e);
|
||||||
|
setAddressSuggestions([]);
|
||||||
|
} finally {
|
||||||
|
setIsAddressLoading(false); // Stop loading for suggestions
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPlaceDetails = async (placeId: string, description: string) => {
|
||||||
|
setIsGeocoding(true); // Indicates loading for place details (lat/lng)
|
||||||
|
setCoordsObtained(false); // Reset status while fetching details
|
||||||
|
setLatitude('');
|
||||||
|
setLongitude('');
|
||||||
|
setAddressSuggestions([]); // Hide suggestions list
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${placeId}&fields=geometry&key=${GOOGLE_API_KEY}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data && data.result && data.result.geometry) {
|
||||||
|
const { lat, lng } = data.result.geometry.location;
|
||||||
|
setAddress(description);
|
||||||
|
setLatitude(lat.toString());
|
||||||
|
setLongitude(lng.toString());
|
||||||
|
setCoordsObtained(true);
|
||||||
|
Alert.alert('Location Found', `Coordinates for "${description}" obtained.`);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Error', 'Could not retrieve coordinates for the selected place.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Google Place Details fetch error:', e);
|
||||||
|
Alert.alert('Error', 'Failed to get location details. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsGeocoding(false); // Stop loading for place details
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// THIS IS THE DEBOUNCED HANDLER FOR THE ADDRESS TEXTINPUT
|
||||||
|
const handleAddressChangeWithDebounce = (text: string) => {
|
||||||
|
setAddress(text); // Update address state immediately
|
||||||
|
if (debounceTimeout.current) {
|
||||||
|
clearTimeout(debounceTimeout.current);
|
||||||
|
}
|
||||||
|
debounceTimeout.current = setTimeout(() => {
|
||||||
|
fetchGoogleSuggestions(text); // Call Google Autocomplete after debounce
|
||||||
|
}, 500); // Debounce for 500ms
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestionPress = (item: any) => { // 'item' is a Google prediction object
|
||||||
|
fetchPlaceDetails(item.place_id, item.description);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Ensure essential fields are filled and coordinates are obtained
|
||||||
|
if (!name || !address || !latitude || !longitude || !locationType || womensSpace === null || wudu === null || !coordsObtained) {
|
||||||
|
Alert.alert('Missing Info', 'Please fill in all required fields (Name, Address, Location Type, Women\'s Space, Wudu Facilities) and ensure coordinates are obtained (use current location or select from address suggestions).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
latitude: parseFloat(latitude),
|
||||||
|
longitude: parseFloat(longitude),
|
||||||
|
location_type: locationType,
|
||||||
|
womens_space: womensSpace,
|
||||||
|
wudu: wudu,
|
||||||
|
notes: notes,
|
||||||
|
website_url: website, // Include website in payload
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Sending new place payload:", payload);
|
||||||
|
const response = await fetch(`${BACKEND_URL}/places/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 result: AddPlaceResponse = await response.json();
|
||||||
|
Alert.alert('Success', result.message || 'Prayer Space added successfully!');
|
||||||
|
|
||||||
|
setName('');
|
||||||
|
setAddress('');
|
||||||
|
setLatitude('');
|
||||||
|
setLongitude('');
|
||||||
|
setLocationType('mosque');
|
||||||
|
setWomensSpace(null);
|
||||||
|
setWudu(null);
|
||||||
|
setNotes('');
|
||||||
|
setWebsite(''); // Clear website field
|
||||||
|
setCoordsObtained(false);
|
||||||
|
|
||||||
|
router.back();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Add mosque error:', error);
|
||||||
|
Alert.alert('Error', `Failed to add prayer space: ${error.message || 'Network error'}`);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Styles for Yes/No buttons for switches
|
||||||
|
const getSwitchButtonStyle = (selected: boolean | null, isYes: boolean) => ({
|
||||||
|
marginRight: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: selected === isYes ? '#007bff' : '#ccc',
|
||||||
|
backgroundColor: selected === isYes ? '#e6f0ff' : '#fff',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
marginLeft: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSwitchButtonTextStyle = (selected: boolean | null, isYes: boolean) => ({
|
||||||
|
color: selected === isYes ? '#007bff' : '#333',
|
||||||
|
fontWeight: selected === isYes ? 'bold' : 'normal',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
|
||||||
|
<Text style={styles.title}>Add New Prayer Space</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Location Name *</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="e.g., London Central Mosque"
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Address *</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Start typing an address or use current location"
|
||||||
|
value={address}
|
||||||
|
onChangeText={handleAddressChangeWithDebounce} // Correctly using debounced handler
|
||||||
|
onFocus={() => { if (address.length >=3 && addressSuggestions.length > 0) setAddressSuggestions(addressSuggestions); else if (address.length >=3) fetchGoogleSuggestions(address); }}
|
||||||
|
onBlur={() => setTimeout(() => setAddressSuggestions([]), 200)}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
{isAddressLoading && <ActivityIndicator size="small" color="#007bff" style={{ marginBottom: 10 }} />}
|
||||||
|
{addressSuggestions.length > 0 && (
|
||||||
|
<ScrollView style={styles.suggestionsContainer} nestedScrollEnabled={true}>
|
||||||
|
{addressSuggestions.map((item) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.place_id}
|
||||||
|
style={styles.suggestionItem}
|
||||||
|
onPress={() => fetchPlaceDetails(item.place_id, item.description)}
|
||||||
|
>
|
||||||
|
<Text>{item.description}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<Button
|
||||||
|
title={isLoadingLocation ? "Getting Location..." : "Use Current Location"}
|
||||||
|
onPress={getCurrentLocation}
|
||||||
|
disabled={isLoadingLocation || isGeocoding}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Display status of coordinates */}
|
||||||
|
{coordsObtained && latitude && longitude && (
|
||||||
|
<Text style={styles.coordsStatusText}>
|
||||||
|
Coordinates obtained: Lat {parseFloat(latitude).toFixed(4)}, Lng {parseFloat(longitude).toFixed(4)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.label}>Prayer Space Type *</Text>
|
||||||
|
<View style={styles.pickerContainer}>
|
||||||
|
<Picker
|
||||||
|
selectedValue={locationType}
|
||||||
|
// UPDATED: locationType values
|
||||||
|
onValueChange={(itemValue: 'mosque' | 'other' | 'outdoor_space' | 'multi_faith_room') => setLocationType(itemValue)}
|
||||||
|
style={styles.picker}
|
||||||
|
>
|
||||||
|
<Picker.Item label="Mosque" value="mosque" />
|
||||||
|
<Picker.Item label="Outdoor Space" value="outdoor_space" />
|
||||||
|
<Picker.Item label="Multi-Faith Room" value="multi_faith_room" />
|
||||||
|
<Picker.Item label="Other Prayer Space" value="other" />
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.switchRow}>
|
||||||
|
<Text style={styles.label}>Women's Space Available *</Text>
|
||||||
|
<View style={{ flexDirection: 'row' }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={getSwitchButtonStyle(womensSpace, false)}
|
||||||
|
onPress={() => setWomensSpace(false)}
|
||||||
|
>
|
||||||
|
<Text style={getSwitchButtonTextStyle(womensSpace, false)}>No</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={getSwitchButtonStyle(womensSpace, true)}
|
||||||
|
onPress={() => setWomensSpace(true)}
|
||||||
|
>
|
||||||
|
<Text style={getSwitchButtonTextStyle(womensSpace, true)}>Yes</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.switchRow}>
|
||||||
|
<Text style={styles.label}>Wudu Facilities Available *</Text>
|
||||||
|
<View style={{ flexDirection: 'row' }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={getSwitchButtonStyle(wudu, false)}
|
||||||
|
onPress={() => setWudu(false)}
|
||||||
|
>
|
||||||
|
<Text style={getSwitchButtonTextStyle(wudu, false)}>No</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={getSwitchButtonStyle(wudu, true)}
|
||||||
|
onPress={() => setWudu(true)}
|
||||||
|
>
|
||||||
|
<Text style={getSwitchButtonTextStyle(wudu, true)}>Yes</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Website URL (Optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="e.g., https://example.com"
|
||||||
|
value={website}
|
||||||
|
onChangeText={setWebsite}
|
||||||
|
keyboardType="url"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Notes</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.notesInput]}
|
||||||
|
placeholder="Any additional notes (e.g., opening hours, specific features)"
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title={isSubmitting ? "Adding Prayer Space..." : "Add Prayer Space"}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={isSubmitting || isLoadingLocation || isGeocoding || !coordsObtained || womensSpace === null || wudu === null}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity style={styles.cancelButton} onPress={() => router.back()}>
|
||||||
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
padding: 20,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 30,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
notesInput: {
|
||||||
|
height: 100,
|
||||||
|
paddingTop: 12,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
pickerContainer: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ccc',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 15,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
picker: {
|
||||||
|
height: Platform.OS === 'ios' ? 120 : 50,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
switchRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 15,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
coordsStatusText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#007bff',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
geocodingIndicator: {
|
||||||
|
marginTop: -5,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
marginTop: 15,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: '#dc3545',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
suggestionsContainer: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ccc',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 15,
|
||||||
|
maxHeight: 150,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
suggestionItem: {
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderBottomColor: '#eee',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
suggestionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
});
|
||||||
313
DRP-App/app/add_review.tsx
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
// app/add_review.tsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Button,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Alert,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Picker } from '@react-native-picker/picker'; // If you were using a picker
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router'; // Import useRouter and useLocalSearchParams
|
||||||
|
|
||||||
|
// Ensure interfaces match your PrayerSpacesListPage.tsx and backend expectations
|
||||||
|
interface ReviewPayload {
|
||||||
|
user: string;
|
||||||
|
place: string; // Backend expects place NAME string for lookup
|
||||||
|
quiet: number | null;
|
||||||
|
clean: number | null;
|
||||||
|
private: number | null;
|
||||||
|
cleanWudu: number | null;
|
||||||
|
childFriendly: number | null;
|
||||||
|
safe: number | null;
|
||||||
|
notes?: string; // Corresponds to comment in your frontend if added
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddReviewResponse {
|
||||||
|
message: string;
|
||||||
|
reviewId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BACKEND_URL = 'http://132.145.65.145:8080'; // Your backend URL
|
||||||
|
const HARDCODED_USERNAME = 'testuser'; // Hardcoded username string, matching your seed user
|
||||||
|
|
||||||
|
// --- Emoji Rating Helpers (copied from PrayerSpacesListPage.tsx) ---
|
||||||
|
// This ensures the new page has these functions available
|
||||||
|
function averageRatingToEmoji(avg: number) {
|
||||||
|
if (avg < 1.66) {
|
||||||
|
return "😞";
|
||||||
|
} else if (avg < 2.33) {
|
||||||
|
return "😐";
|
||||||
|
} else {
|
||||||
|
return "😊";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmojiRatingSelectorProps {
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
onSelect: (value: number) => void;
|
||||||
|
style?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmojiRatingSelector: React.FC<EmojiRatingSelectorProps> = ({ label, value, onSelect, style }) => {
|
||||||
|
const emojis = [
|
||||||
|
{ emoji: "😞", value: 1, label: "Poor" },
|
||||||
|
{ emoji: "😐", value: 2, label: "Okay" },
|
||||||
|
{ emoji: "😊", value: 3, label: "Great" }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[addReviewStyles.ratingContainer, style]}>
|
||||||
|
<Text style={addReviewStyles.ratingLabel}>{label}</Text>
|
||||||
|
<View style={addReviewStyles.emojiRow}>
|
||||||
|
{emojis.map((item) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.value}
|
||||||
|
style={[
|
||||||
|
addReviewStyles.emojiButton,
|
||||||
|
value === item.value && addReviewStyles.selectedEmoji
|
||||||
|
]}
|
||||||
|
onPress={() => onSelect(item.value)}
|
||||||
|
>
|
||||||
|
<Text style={addReviewStyles.emojiText}>{item.emoji}</Text>
|
||||||
|
<Text style={addReviewStyles.emojiLabel}>{item.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// --- End Emoji Rating Helpers ---
|
||||||
|
|
||||||
|
|
||||||
|
export default function AddReviewScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useLocalSearchParams(); // Get params passed from navigation
|
||||||
|
const placeName = typeof params.placeName === 'string' ? params.placeName : 'Unknown Place';
|
||||||
|
const placeId = typeof params.placeId === 'string' ? params.placeId : ''; // Place ID for potential future use or debugging
|
||||||
|
|
||||||
|
const [quiet, setQuiet] = useState<number | null>(null);
|
||||||
|
const [clean, setClean] = useState<number | null>(null);
|
||||||
|
const [privacy, setPrivacy] = useState<number | null>(null);
|
||||||
|
const [cleanWudu, setCleanWudu] = useState<number | null>(null);
|
||||||
|
const [childFriendly, setChildFriendly] = useState<number | null>(null);
|
||||||
|
const [safe, setSafe] = useState<number | null>(null);
|
||||||
|
const [notes, setNotes] = useState(''); // Changed from comment to notes to match backend model name
|
||||||
|
|
||||||
|
const isValidRating = (val: number | null): boolean => {
|
||||||
|
return val !== null && val >= 1 && val <= 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Validate all rating fields
|
||||||
|
if (!quiet || !clean || !privacy || !cleanWudu || !childFriendly || !safe) {
|
||||||
|
Alert.alert('Missing Info', 'Please fill in all rating fields.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (![quiet, clean, privacy, cleanWudu, childFriendly, safe].every(isValidRating)) {
|
||||||
|
Alert.alert('Invalid Input', 'Please select a rating for each category (1-3).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare payload for backend - sending place NAME as string
|
||||||
|
const payload: ReviewPayload = {
|
||||||
|
user: HARDCODED_USERNAME,
|
||||||
|
place: placeName, // IMPORTANT: Sending place NAME, not ID, as per your backend's SaveReview
|
||||||
|
quiet: quiet,
|
||||||
|
clean: clean,
|
||||||
|
private: privacy,
|
||||||
|
cleanWudu: cleanWudu,
|
||||||
|
childFriendly: childFriendly,
|
||||||
|
safe: safe,
|
||||||
|
notes: notes, // Sending notes field
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Sending review payload from AddReviewScreen:", payload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BACKEND_URL}/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: AddReviewResponse = await response.json();
|
||||||
|
Alert.alert('Success', data.message || 'Review submitted!');
|
||||||
|
|
||||||
|
// Clear form and navigate back
|
||||||
|
setQuiet(null);
|
||||||
|
setClean(null);
|
||||||
|
setPrivacy(null);
|
||||||
|
setCleanWudu(null);
|
||||||
|
setChildFriendly(null);
|
||||||
|
setSafe(null);
|
||||||
|
setNotes(''); // Clear notes field
|
||||||
|
|
||||||
|
router.back(); // Go back to the previous screen (PrayerSpacesListPage)
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert('Error', `Could not send review: ${error.message || error}`);
|
||||||
|
console.error("Review submission error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={addReviewStyles.container} contentContainerStyle={addReviewStyles.contentContainer}>
|
||||||
|
<Text style={addReviewStyles.title}>Submit a Review</Text>
|
||||||
|
<Text style={addReviewStyles.subtitle}>For: {placeName}</Text>
|
||||||
|
|
||||||
|
{/* Emoji Rating Selectors */}
|
||||||
|
<EmojiRatingSelector
|
||||||
|
label="Cleanliness Rating"
|
||||||
|
value={clean}
|
||||||
|
onSelect={setClean}
|
||||||
|
/>
|
||||||
|
<EmojiRatingSelector
|
||||||
|
label="Wudu Cleanliness Rating"
|
||||||
|
value={cleanWudu}
|
||||||
|
onSelect={setCleanWudu}
|
||||||
|
/>
|
||||||
|
<EmojiRatingSelector
|
||||||
|
label="Quietness Rating"
|
||||||
|
value={quiet}
|
||||||
|
onSelect={setQuiet}
|
||||||
|
/>
|
||||||
|
<EmojiRatingSelector
|
||||||
|
label="Privacy Rating"
|
||||||
|
value={privacy}
|
||||||
|
onSelect={setPrivacy}
|
||||||
|
/>
|
||||||
|
<EmojiRatingSelector
|
||||||
|
label="Child Friendliness Rating"
|
||||||
|
value={childFriendly}
|
||||||
|
onSelect={setChildFriendly}
|
||||||
|
/>
|
||||||
|
<EmojiRatingSelector
|
||||||
|
label="Safety Rating"
|
||||||
|
value={safe}
|
||||||
|
onSelect={setSafe}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={addReviewStyles.label}>Your Comments (optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[addReviewStyles.input, addReviewStyles.notesInput]}
|
||||||
|
placeholder="Add your thoughts about this place..."
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button title="Submit Review" onPress={handleSubmit} />
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={addReviewStyles.cancelButton}>
|
||||||
|
<Text style={addReviewStyles.cancelButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addReviewStyles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
padding: 20,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 10,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
marginBottom: 25,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#555',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#333',
|
||||||
|
marginTop: 10, // Add some top margin for spacing
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
notesInput: {
|
||||||
|
height: 100,
|
||||||
|
paddingTop: 12,
|
||||||
|
},
|
||||||
|
// Styles for EmojiRatingSelector (copied from PrayerSpacesListPage)
|
||||||
|
ratingContainer: {
|
||||||
|
marginBottom: 15,
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#fff', // Added background for better visibility
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
ratingLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
emojiRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
width: '90%',
|
||||||
|
},
|
||||||
|
emojiButton: {
|
||||||
|
padding: 8, // Slightly less padding for touch area
|
||||||
|
borderRadius: 5,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
selectedEmoji: {
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
borderColor: '#007AFF',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
emojiText: {
|
||||||
|
fontSize: 30,
|
||||||
|
},
|
||||||
|
emojiLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 5,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
marginTop: 15,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: '#dc3545',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
377
DRP-App/app/upload.tsx
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'; // ADDED useEffect
|
||||||
|
import { // Ensure all necessary components are imported
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
Image,
|
||||||
|
Alert,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
ScrollView,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useLocalSearchParams } from 'expo-router'; // ADDED: Import useLocalSearchParams
|
||||||
|
|
||||||
|
interface UploadResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadTab() {
|
||||||
|
// Get parameters from the route
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
// Ensure placeId from params is treated as a string, provide fallback if not present
|
||||||
|
const initialPlaceId = typeof params.placeId === 'string' ? params.placeId : '';
|
||||||
|
const initialPlaceName = typeof params.placeName === 'string' ? params.placeName : 'Selected Place'; // For display
|
||||||
|
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
// Initialize placeId state with the value from params
|
||||||
|
const [placeId, setPlaceId] = useState<string>(initialPlaceId);
|
||||||
|
const [placeNameDisplay, setPlaceNameDisplay] = useState<string>(initialPlaceName); // State for displaying name
|
||||||
|
const [notes, setNotes] = useState<string>('');
|
||||||
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const BACKEND_URL = 'http://132.145.65.145:8080';
|
||||||
|
|
||||||
|
// Use useEffect to update placeId and placeNameDisplay if params change
|
||||||
|
// This handles cases where the component might already be mounted and params are updated
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof params.placeId === 'string' && params.placeId !== placeId) {
|
||||||
|
setPlaceId(params.placeId);
|
||||||
|
}
|
||||||
|
if (typeof params.placeName === 'string' && params.placeName !== placeNameDisplay) {
|
||||||
|
setPlaceNameDisplay(params.placeName);
|
||||||
|
}
|
||||||
|
}, [params.placeId, params.placeName]); // Depend on params.placeId and placeName
|
||||||
|
|
||||||
|
const pickImage = async () => {
|
||||||
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert('Permission Required', 'Sorry, we need camera roll permissions to upload images.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images, // Use MediaTypeOptions.Images directly
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [4, 3],
|
||||||
|
quality: 0.8,
|
||||||
|
allowsMultipleSelection: false,
|
||||||
|
});
|
||||||
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||||
|
setSelectedImage(result.assets[0].uri);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const takePhoto = async () => {
|
||||||
|
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert('Permission Required', 'Sorry, we need camera permissions to take photos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImagePicker.launchCameraAsync({
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [4, 3],
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||||
|
setSelectedImage(result.assets[0].uri);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadImage = async () => {
|
||||||
|
if (!selectedImage) {
|
||||||
|
Alert.alert('Error', 'Please select an image first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!placeId) { // Ensure placeId is not empty after potential navigation
|
||||||
|
Alert.alert('Error', 'Place ID is missing. Please select a mosque first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Uploading for placeId:', placeId);
|
||||||
|
console.log('Notes:', notes);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
const imageFile = {
|
||||||
|
uri: selectedImage,
|
||||||
|
type: 'image/jpeg',
|
||||||
|
name: 'image.jpg',
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
formData.append('image', imageFile);
|
||||||
|
formData.append('place_id', placeId.trim()); // Ensure this matches backend expected field name
|
||||||
|
formData.append('Notes', notes.trim()); // Ensure this matches backend expected field name
|
||||||
|
|
||||||
|
console.log('Sending form data with keys:', Array.from(formData.keys()));
|
||||||
|
for (let [key, value] of formData.entries()) {
|
||||||
|
console.log(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${BACKEND_URL}/images/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
// DO NOT set 'Content-Type' for FormData, fetch sets it automatically with boundary
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let result: UploadResponse;
|
||||||
|
const responseCopy = response.clone(); // Clone response to read body twice if needed
|
||||||
|
try {
|
||||||
|
result = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
const text = await responseCopy.text();
|
||||||
|
throw new Error(text || "Unknown error parsing JSON response");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
Alert.alert('Success', 'Image uploaded successfully!');
|
||||||
|
setSelectedImage(null);
|
||||||
|
setPlaceId(initialPlaceId); // Reset to initial placeId from params, or empty if not from params
|
||||||
|
setPlaceNameDisplay(initialPlaceName); // Reset display name
|
||||||
|
setNotes('');
|
||||||
|
} else {
|
||||||
|
Alert.alert('Upload Failed', result.message || 'Failed to upload image. Please check backend logs.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
Alert.alert('Error', `Upload failed: ${error.message || 'Network error'}.`);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearImage = () => {
|
||||||
|
setSelectedImage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
|
||||||
|
<Text style={styles.title}>Upload Image</Text>
|
||||||
|
|
||||||
|
{/* Display Place Name (User friendly) */}
|
||||||
|
<Text style={styles.placeNameDisplay}>For: {placeNameDisplay || 'No place selected'}</Text>
|
||||||
|
|
||||||
|
{/* Image Selection Section */}
|
||||||
|
<View style={styles.imageSection}>
|
||||||
|
{selectedImage ? (
|
||||||
|
<View style={styles.imageContainer}>
|
||||||
|
<Image source={{ uri: selectedImage }} style={styles.selectedImage} />
|
||||||
|
<TouchableOpacity style={styles.clearButton} onPress={clearImage}>
|
||||||
|
<Ionicons name="close-circle" size={24} color="#ff4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.placeholderContainer}>
|
||||||
|
<Ionicons name="image-outline" size={64} color="#ccc" />
|
||||||
|
<Text style={styles.placeholderText}>No image selected</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Image Selection Buttons */}
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<TouchableOpacity style={styles.actionButton} onPress={pickImage}>
|
||||||
|
<Ionicons name="images-outline" size={20} color="#007AFF" />
|
||||||
|
<Text style={styles.buttonText}>Gallery</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.actionButton} onPress={takePhoto}>
|
||||||
|
<Ionicons name="camera-outline" size={20} color="#007AFF" />
|
||||||
|
<Text style={styles.buttonText}>Camera</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Form Section */}
|
||||||
|
<View style={styles.formSection}>
|
||||||
|
<Text style={styles.label}>Notes</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.notesInput]}
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="Add notes about this image (optional)"
|
||||||
|
placeholderTextColor="#999"
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Upload Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.uploadButton, (!selectedImage || uploading || !placeId) && styles.uploadButtonDisabled]} // Disable if placeId is missing
|
||||||
|
onPress={uploadImage}
|
||||||
|
disabled={!selectedImage || uploading || !placeId} // Disable if placeId is missing
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="cloud-upload-outline" size={20} color="#fff" />
|
||||||
|
<Text style={styles.uploadButtonText}>Upload Image</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<View style={styles.instructionsContainer}>
|
||||||
|
<Text style={styles.instructionsTitle}>Instructions:</Text>
|
||||||
|
<Text style={styles.instructionsText}>
|
||||||
|
1. Select an image from your gallery or take a photo{'\n'}
|
||||||
|
2. Add optional notes{'\n'}
|
||||||
|
3. Tap Upload
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
placeNameDisplay: { // New style to display the place name
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 15,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#555',
|
||||||
|
},
|
||||||
|
imageSection: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
imageContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
selectedImage: {
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
borderRadius: 10,
|
||||||
|
resizeMode: 'cover',
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
placeholderContainer: {
|
||||||
|
height: 200,
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
placeholderText: {
|
||||||
|
marginTop: 10,
|
||||||
|
color: '#999',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
marginBottom: 30,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#007AFF',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
marginLeft: 8,
|
||||||
|
color: '#007AFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
formSection: {
|
||||||
|
marginBottom: 30,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
readOnlyInput: { // NEW STYLE for read-only text input
|
||||||
|
backgroundColor: '#e9ecef', // Lighter background for read-only
|
||||||
|
color: '#6c757d', // Slightly greyed out text
|
||||||
|
},
|
||||||
|
notesInput: {
|
||||||
|
height: 80,
|
||||||
|
paddingTop: 12,
|
||||||
|
},
|
||||||
|
uploadButton: {
|
||||||
|
backgroundColor: '#28a745',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 15,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 30,
|
||||||
|
},
|
||||||
|
uploadButtonDisabled: {
|
||||||
|
backgroundColor: '#ccc',
|
||||||
|
},
|
||||||
|
uploadButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
instructionsContainer: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: 15,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftColor: '#007AFF',
|
||||||
|
},
|
||||||
|
instructionsTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
instructionsText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
DRP-App/assets/fonts/SpaceMono-Regular.ttf
Normal file
BIN
DRP-App/assets/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
DRP-App/assets/images/cat.jpeg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
DRP-App/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
DRP-App/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
DRP-App/assets/images/logo.jpeg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
DRP-App/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
DRP-App/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
DRP-App/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
DRP-App/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
DRP-App/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
45
DRP-App/components/Collapsible.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { PropsWithChildren, useState } from 'react';
|
||||||
|
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
|
||||||
|
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.heading}
|
||||||
|
onPress={() => setIsOpen((value) => !value)}
|
||||||
|
activeOpacity={0.8}>
|
||||||
|
<IconSymbol
|
||||||
|
name="chevron.right"
|
||||||
|
size={18}
|
||||||
|
weight="medium"
|
||||||
|
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||||
|
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
heading: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
marginTop: 6,
|
||||||
|
marginLeft: 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
24
DRP-App/components/ExternalLink.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Href, Link } from 'expo-router';
|
||||||
|
import { openBrowserAsync } from 'expo-web-browser';
|
||||||
|
import { type ComponentProps } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||||
|
|
||||||
|
export function ExternalLink({ href, ...rest }: Props) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
{...rest}
|
||||||
|
href={href}
|
||||||
|
onPress={async (event) => {
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
event.preventDefault();
|
||||||
|
// Open the link in an in-app browser.
|
||||||
|
await openBrowserAsync(href);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
DRP-App/components/HapticTab.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||||
|
import { PlatformPressable } from '@react-navigation/elements';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||||
|
return (
|
||||||
|
<PlatformPressable
|
||||||
|
{...props}
|
||||||
|
onPressIn={(ev) => {
|
||||||
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
|
// Add a soft haptic feedback when pressing down on the tabs.
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
props.onPressIn?.(ev);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
DRP-App/components/HelloWave.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withSequence,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
|
||||||
|
export function HelloWave() {
|
||||||
|
const rotationAnimation = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rotationAnimation.value = withRepeat(
|
||||||
|
withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
|
||||||
|
4 // Run the animation 4 times
|
||||||
|
);
|
||||||
|
}, [rotationAnimation]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${rotationAnimation.value}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={animatedStyle}>
|
||||||
|
<ThemedText style={styles.text}>👋</ThemedText>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
text: {
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 32,
|
||||||
|
marginTop: -6,
|
||||||
|
},
|
||||||
|
});
|
||||||
82
DRP-App/components/ParallaxScrollView.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import type { PropsWithChildren, ReactElement } from 'react';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
useAnimatedRef,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useScrollViewOffset,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
import { useBottomTabOverflow } from '@/components/ui/TabBarBackground';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
|
||||||
|
const HEADER_HEIGHT = 250;
|
||||||
|
|
||||||
|
type Props = PropsWithChildren<{
|
||||||
|
headerImage: ReactElement;
|
||||||
|
headerBackgroundColor: { dark: string; light: string };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export default function ParallaxScrollView({
|
||||||
|
children,
|
||||||
|
headerImage,
|
||||||
|
headerBackgroundColor,
|
||||||
|
}: Props) {
|
||||||
|
const colorScheme = useColorScheme() ?? 'light';
|
||||||
|
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||||
|
const scrollOffset = useScrollViewOffset(scrollRef);
|
||||||
|
const bottom = useBottomTabOverflow();
|
||||||
|
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: interpolate(
|
||||||
|
scrollOffset.value,
|
||||||
|
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||||
|
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<Animated.ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
scrollIndicatorInsets={{ bottom }}
|
||||||
|
contentContainerStyle={{ paddingBottom: bottom }}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.header,
|
||||||
|
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||||
|
headerAnimatedStyle,
|
||||||
|
]}>
|
||||||
|
{headerImage}
|
||||||
|
</Animated.View>
|
||||||
|
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||||
|
</Animated.ScrollView>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
height: HEADER_HEIGHT,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 32,
|
||||||
|
gap: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
60
DRP-App/components/ThemedText.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||||
|
|
||||||
|
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
|
|
||||||
|
export type ThemedTextProps = TextProps & {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemedText({
|
||||||
|
style,
|
||||||
|
lightColor,
|
||||||
|
darkColor,
|
||||||
|
type = 'default',
|
||||||
|
...rest
|
||||||
|
}: ThemedTextProps) {
|
||||||
|
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
{ color },
|
||||||
|
type === 'default' ? styles.default : undefined,
|
||||||
|
type === 'title' ? styles.title : undefined,
|
||||||
|
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||||
|
type === 'subtitle' ? styles.subtitle : undefined,
|
||||||
|
type === 'link' ? styles.link : undefined,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
default: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
defaultSemiBold: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 32,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
lineHeight: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#0a7ea4',
|
||||||
|
},
|
||||||
|
});
|
||||||
14
DRP-App/components/ThemedView.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { View, type ViewProps } from 'react-native';
|
||||||
|
|
||||||
|
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
|
|
||||||
|
export type ThemedViewProps = ViewProps & {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||||
|
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||||
|
|
||||||
|
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
32
DRP-App/components/ui/IconSymbol.ios.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||||
|
import { StyleProp, ViewStyle } from 'react-native';
|
||||||
|
|
||||||
|
export function IconSymbol({
|
||||||
|
name,
|
||||||
|
size = 24,
|
||||||
|
color,
|
||||||
|
style,
|
||||||
|
weight = 'regular',
|
||||||
|
}: {
|
||||||
|
name: SymbolViewProps['name'];
|
||||||
|
size?: number;
|
||||||
|
color: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
weight?: SymbolWeight;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SymbolView
|
||||||
|
weight={weight}
|
||||||
|
tintColor={color}
|
||||||
|
resizeMode="scaleAspectFit"
|
||||||
|
name={name}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
DRP-App/components/ui/IconSymbol.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// Fallback for using MaterialIcons on Android and web.
|
||||||
|
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||||
|
import { ComponentProps } from 'react';
|
||||||
|
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||||
|
|
||||||
|
const MAPPING = {
|
||||||
|
'house.fill': 'home',
|
||||||
|
'paperplane.fill': 'send',
|
||||||
|
'chevron.left.forwardslash.chevron.right': 'code',
|
||||||
|
'chevron.right': 'chevron-right',
|
||||||
|
'person.fill': 'person',
|
||||||
|
'map.fill': 'map',
|
||||||
|
'star.fill': 'star',
|
||||||
|
'clock.fill': 'access-time',
|
||||||
|
} as IconMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||||
|
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||||
|
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||||
|
*/
|
||||||
|
export function IconSymbol({
|
||||||
|
name,
|
||||||
|
size = 24,
|
||||||
|
color,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
name: IconSymbolName;
|
||||||
|
size?: number;
|
||||||
|
color: string | OpaqueColorValue;
|
||||||
|
style?: StyleProp<TextStyle>;
|
||||||
|
weight?: SymbolWeight;
|
||||||
|
}) {
|
||||||
|
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||||
|
}
|
||||||
19
DRP-App/components/ui/TabBarBackground.ios.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
export default function BlurTabBarBackground() {
|
||||||
|
return (
|
||||||
|
<BlurView
|
||||||
|
// System chrome material automatically adapts to the system's theme
|
||||||
|
// and matches the native tab bar appearance on iOS.
|
||||||
|
tint="systemChromeMaterial"
|
||||||
|
intensity={100}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBottomTabOverflow() {
|
||||||
|
return useBottomTabBarHeight();
|
||||||
|
}
|
||||||
6
DRP-App/components/ui/TabBarBackground.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// This is a shim for web and Android where the tab bar is generally opaque.
|
||||||
|
export default undefined;
|
||||||
|
|
||||||
|
export function useBottomTabOverflow() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
26
DRP-App/constants/Colors.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||||
|
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const tintColorLight = '#0a7ea4';
|
||||||
|
const tintColorDark = '#fff';
|
||||||
|
|
||||||
|
export const Colors = {
|
||||||
|
light: {
|
||||||
|
text: '#11181C',
|
||||||
|
background: '#fff',
|
||||||
|
tint: tintColorLight,
|
||||||
|
icon: '#687076',
|
||||||
|
tabIconDefault: '#687076',
|
||||||
|
tabIconSelected: tintColorLight,
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: '#ECEDEE',
|
||||||
|
background: '#151718',
|
||||||
|
tint: tintColorDark,
|
||||||
|
icon: '#9BA1A6',
|
||||||
|
tabIconDefault: '#9BA1A6',
|
||||||
|
tabIconSelected: tintColorDark,
|
||||||
|
},
|
||||||
|
};
|
||||||
61
DRP-App/context/AuthContext.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthContextType = {
|
||||||
|
user: User | null;
|
||||||
|
login: (username: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
SecureStore.getItemAsync('user').then((storedUser) => {
|
||||||
|
if (storedUser) {
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username: string) => {
|
||||||
|
const userData = { username };
|
||||||
|
setUser(userData);
|
||||||
|
await SecureStore.setItemAsync('user', JSON.stringify(userData));
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
setUser(null);
|
||||||
|
await SecureStore.deleteItemAsync('user');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, login, logout, loading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
31
DRP-App/eas.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.7.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true,
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
10
DRP-App/eslint.config.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const expoConfig = require('eslint-config-expo/flat');
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ['dist/*'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
1
DRP-App/hooks/useColorScheme.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useColorScheme } from 'react-native';
|
||||||
21
DRP-App/hooks/useColorScheme.web.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||||
|
*/
|
||||||
|
export function useColorScheme() {
|
||||||
|
const [hasHydrated, setHasHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colorScheme = useRNColorScheme();
|
||||||
|
|
||||||
|
if (hasHydrated) {
|
||||||
|
return colorScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
21
DRP-App/hooks/useThemeColor.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Learn more about light and dark modes:
|
||||||
|
* https://docs.expo.dev/guides/color-schemes/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
|
||||||
|
export function useThemeColor(
|
||||||
|
props: { light?: string; dark?: string },
|
||||||
|
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||||
|
) {
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
const colorFromProps = props[theme];
|
||||||
|
|
||||||
|
if (colorFromProps) {
|
||||||
|
return colorFromProps;
|
||||||
|
} else {
|
||||||
|
return Colors[theme][colorName];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
DRP-App/package.json
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "prayer-space-finder",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "expo lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^14.1.0",
|
||||||
|
"@react-native-community/slider": "4.5.6",
|
||||||
|
"@react-native-picker/picker": "^2.11.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||||
|
"@react-navigation/elements": "^2.3.8",
|
||||||
|
"@react-navigation/native": "^7.1.6",
|
||||||
|
"@react-navigation/native-stack": "^7.3.10",
|
||||||
|
"better-sqlite3": "^11.10.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"expo": "53.0.10",
|
||||||
|
"expo-blur": "~14.1.5",
|
||||||
|
"expo-constants": "~17.1.6",
|
||||||
|
"expo-dev-client": "~5.2.0",
|
||||||
|
"expo-font": "~13.3.1",
|
||||||
|
"expo-haptics": "~14.1.4",
|
||||||
|
"expo-image": "~2.2.0",
|
||||||
|
"expo-linking": "~7.1.5",
|
||||||
|
"expo-location": "^18.1.5",
|
||||||
|
"expo-router": "^5.0.7",
|
||||||
|
"expo-secure-store": "~14.2.3",
|
||||||
|
"expo-splash-screen": "~0.30.9",
|
||||||
|
"expo-status-bar": "~2.2.3",
|
||||||
|
"expo-symbols": "~0.4.5",
|
||||||
|
"expo-system-ui": "~5.0.8",
|
||||||
|
"expo-web-browser": "~14.1.6",
|
||||||
|
"notifications": "^0.2.0",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0",
|
||||||
|
"react-native": "^0.79.3",
|
||||||
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
|
"react-native-maps": "1.20.1",
|
||||||
|
"react-native-reanimated": "~3.17.4",
|
||||||
|
"react-native-safe-area-context": "^5.4.0",
|
||||||
|
"react-native-screens": "~4.11.1",
|
||||||
|
"react-native-web": "~0.20.0",
|
||||||
|
"react-native-webview": "13.13.5",
|
||||||
|
"expo-notifications": "~0.31.3",
|
||||||
|
"expo-image-picker": "~16.1.4",
|
||||||
|
"expo-build-properties": "~0.14.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.25.2",
|
||||||
|
"@types/react": "~19.0.10",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-config-expo": "~9.2.0",
|
||||||
|
"typescript": "~5.8.3"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
117
DRP-App/prayerSpaces.json
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ps001",
|
||||||
|
"name": "East London Mosque",
|
||||||
|
"latitude": 51.5152,
|
||||||
|
"longitude": -0.0604,
|
||||||
|
"address": "82-92 Whitechapel Rd, London E1 1JQ",
|
||||||
|
"type": "Mosque",
|
||||||
|
"facilities": {
|
||||||
|
"womensSpace": true,
|
||||||
|
"wudu": true
|
||||||
|
},
|
||||||
|
"openingHours": "Fajr to Isha. Women's section open for all prayers.",
|
||||||
|
"rating": 4.7,
|
||||||
|
"numberOfReviews": 1250,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"url": "https://upload.wikimedia.org/wikipedia/commons/c/cc/East_London_Mosque_Front_View.jpg",
|
||||||
|
"note": "Front view of the East London Mosque and London Muslim Centre."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://prayersconnect.com/api/image-resize/eyJlZGl0cyI6eyJyZXNpemUiOnsid2lkdGgiOjYxNiwiaGVpZ2h0IjozOTJ9fSwidXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL3BsYWNlcy9BQWNYcjhyb3haOS1rZ3FBbHluQTRjT2ZzcGlhLVlhZGRjX3pRV2dSNW1FQ2xKVFpHcEd4U21VLVVMWUFuUEVid3FGdnNNRGhUd0k1Wmc1NS1Kd1BzSW9vSDZuVm1hS0d5Z1JkSS1jPXMxNjAwLXc0MDMyIn0=",
|
||||||
|
"note": "Interior prayer hall, view towards the Mihrab."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRhfHiXh7Jl7iHjMy6PF7dn5GgTxHebNFVAAQ&s",
|
||||||
|
"note": "Women's gallery providing a separate prayer space."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.eastlondonmosque.org.uk/Handlers/GetImage.ashx?IDMF=7c71af52-2713-4120-bd19-8b03a9b4d313&h=842&src=mc&w=1192",
|
||||||
|
"note": "Women's gallery providing a separate prayer space."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "Large, well-established mosque with extensive facilities. Can be busy for Jummah."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps002",
|
||||||
|
"name": "London Central Mosque (Regent's Park Mosque)",
|
||||||
|
"latitude": 51.5269,
|
||||||
|
"longitude": -0.1601,
|
||||||
|
"address": "146 Park Rd, London NW8 7RG",
|
||||||
|
"type": "Mosque",
|
||||||
|
"facilities": {
|
||||||
|
"womensSpace": true,
|
||||||
|
"wudu": true
|
||||||
|
},
|
||||||
|
"openingHours": "Open for all 5 daily prayers. Check website for specific women's hall times during events.",
|
||||||
|
"rating": 4.6,
|
||||||
|
"numberOfReviews": 980,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"url": "https://5pillarsuk.com/wp-content/uploads/2020/01/27895109593_c7f7aeac6c_b.jpg",
|
||||||
|
"note": "Front view of Regents Park Mosque."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://d3sux4fmh2nu8u.cloudfront.net/Pictures/480xany/2/5/9/1849259_londonmosquepa35501250_912549.jpg",
|
||||||
|
"note": "Interior prayer hall, view towards the Mihrab."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjuoNbNEhP7yg4OiYq6TGEGQ8nFosHWWzrkIeg2LLSftg_D3mjvsoEozN3BIp-K8SPDtnTf-XCskDYpLLpsUnKyett-8fkQ4gp5BC4d7CoCDs0SwP01ou-ogk2zFCPwxTqYIxqg0wWK7Rs/s1600/IMG_3047.JPG",
|
||||||
|
"note": "Women's gallery providing a separate prayer space."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "Iconic mosque, landmark building. Women's gallery may have restricted access during large events."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps003",
|
||||||
|
"name": "Finsbury Park Mosque",
|
||||||
|
"latitude": 51.5614,
|
||||||
|
"longitude": -0.1081,
|
||||||
|
"address": "7-11 St Thomas's Rd, London N4 2QH",
|
||||||
|
"type": "Mosque",
|
||||||
|
"facilities": {
|
||||||
|
"womensSpace": true,
|
||||||
|
"wudu": true
|
||||||
|
},
|
||||||
|
"openingHours": "Open for all prayers.",
|
||||||
|
"rating": 4.3,
|
||||||
|
"numberOfReviews": 350,
|
||||||
|
"images": [],
|
||||||
|
"notes": "Community-focused mosque."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps004",
|
||||||
|
"name": "Mayfair Islamic Centre",
|
||||||
|
"latitude": 51.5100,
|
||||||
|
"longitude": -0.1475,
|
||||||
|
"address": "19 Hertford St, London W1J 7RU",
|
||||||
|
"type": "Prayer Room",
|
||||||
|
"facilities": {
|
||||||
|
"womensSpace": true,
|
||||||
|
"wudu": true
|
||||||
|
},
|
||||||
|
"openingHours": "Typically open Dhuhr, Asr, Maghrib. Check posted times.",
|
||||||
|
"rating": 4.0,
|
||||||
|
"numberOfReviews": 45,
|
||||||
|
"images": [],
|
||||||
|
"notes": "Smaller prayer room, can be difficult to find. Limited space."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ps005",
|
||||||
|
"name": "Selfridges Quiet Room (Men & Women)",
|
||||||
|
"latitude": 51.5141,
|
||||||
|
"longitude": -0.1508,
|
||||||
|
"address": "400 Oxford St, London W1A 1AB (4th Floor)",
|
||||||
|
"type": "Community Space",
|
||||||
|
"facilities": {
|
||||||
|
"womensSpace": true,
|
||||||
|
"wudu": false
|
||||||
|
},
|
||||||
|
"openingHours": "During store opening hours. Ask staff for access.",
|
||||||
|
"rating": 3.8,
|
||||||
|
"numberOfReviews": 22,
|
||||||
|
"images": [],
|
||||||
|
"notes": "Multi-faith quiet room. No dedicated wudu facilities within the room. Not exclusively for Muslims."
|
||||||
|
}
|
||||||
|
]
|
||||||
112
DRP-App/scripts/reset-project.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This script is used to reset the project to a blank state.
|
||||||
|
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||||
|
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const readline = require("readline");
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
|
||||||
|
const exampleDir = "app-example";
|
||||||
|
const newAppDir = "app";
|
||||||
|
const exampleDirPath = path.join(root, exampleDir);
|
||||||
|
|
||||||
|
const indexContent = `import { Text, View } from "react-native";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const layoutContent = `import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return <Stack />;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveDirectories = async (userInput) => {
|
||||||
|
try {
|
||||||
|
if (userInput === "y") {
|
||||||
|
// Create the app-example directory
|
||||||
|
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||||
|
console.log(`📁 /${exampleDir} directory created.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move old directories to new app-example directory or delete them
|
||||||
|
for (const dir of oldDirs) {
|
||||||
|
const oldDirPath = path.join(root, dir);
|
||||||
|
if (fs.existsSync(oldDirPath)) {
|
||||||
|
if (userInput === "y") {
|
||||||
|
const newDirPath = path.join(root, exampleDir, dir);
|
||||||
|
await fs.promises.rename(oldDirPath, newDirPath);
|
||||||
|
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||||
|
} else {
|
||||||
|
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||||
|
console.log(`❌ /${dir} deleted.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new /app directory
|
||||||
|
const newAppDirPath = path.join(root, newAppDir);
|
||||||
|
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||||
|
console.log("\n📁 New /app directory created.");
|
||||||
|
|
||||||
|
// Create index.tsx
|
||||||
|
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||||
|
await fs.promises.writeFile(indexPath, indexContent);
|
||||||
|
console.log("📄 app/index.tsx created.");
|
||||||
|
|
||||||
|
// Create _layout.tsx
|
||||||
|
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||||
|
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||||
|
console.log("📄 app/_layout.tsx created.");
|
||||||
|
|
||||||
|
console.log("\n✅ Project reset complete. Next steps:");
|
||||||
|
console.log(
|
||||||
|
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||||
|
userInput === "y"
|
||||||
|
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||||
|
: ""
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error during script execution: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rl.question(
|
||||||
|
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
||||||
|
(answer) => {
|
||||||
|
const userInput = answer.trim().toLowerCase() || "y";
|
||||||
|
if (userInput === "y" || userInput === "n") {
|
||||||
|
moveDirectories(userInput).finally(() => rl.close());
|
||||||
|
} else {
|
||||||
|
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
18
DRP-App/tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react",
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
21
LICENCE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) <YEAR> <COPYRIGHT HOLDER>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
62
README.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Sajidaat (سَاجِدَات) – Prayer Space Finder
|
||||||
|
**Find your peace. Find your place.**
|
||||||
|
|
||||||
|
This project was developed as part of the _"Designing for Real People"_ module for the second-year Computing course at Imperial College London. The brief was to identify a real-world problem faced by a specific community and develop a human-centered digital solution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Table of Contents
|
||||||
|
|
||||||
|
- [❔ Problem](#-the-problem)
|
||||||
|
- [✅ Solution](#-the-solution)
|
||||||
|
- [✨ Core Features](#-core-features)
|
||||||
|
- [🛠️ Tech Stack](#️-tech-stack)
|
||||||
|
- [🚀 Getting Started](#-getting-started)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❔ The Problem
|
||||||
|
|
||||||
|
For many Muslims, especially women, finding a suitable prayer space while away from home is a source of anxiety. Standard mapping tools lack crucial details like the availability of a women's section or its cleanliness, leading to wasted time and forcing individuals to pray in unsuitable public spaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ The Solution
|
||||||
|
|
||||||
|
**Sajidaat** is a community-driven mobile app that empowers Muslims to find and verify suitable prayer spaces with confidence. The name "Sajidaat" (سَاجِدَات) is Arabic for "women in prostration," signifying our focus on this challenge. By transforming private frustrations into public, actionable feedback, Sajidaat not only helps individuals but also encourages the development of more inclusive community spaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Core Features
|
||||||
|
|
||||||
|
- 📍 **Interactive Map & Distance-Sorted List**
|
||||||
|
- ♀️ **Filtering:** For Women’s Spaces, Wudu, Ratings, etc.
|
||||||
|
- ⭐ **Community-Driven Ratings & Reviews**
|
||||||
|
- 🖼️ **Image Gallery** with Notes for each location
|
||||||
|
- ➕ **Crowdsourced Spaces:** Users can add new locations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
- **Framework:** React Native with Expo
|
||||||
|
- **Frontend Language:** TypeScript
|
||||||
|
- **Prototyping & Mobile Runtime:** Expo Go
|
||||||
|
- **Mapping:** `react-native-maps` (Google Maps)
|
||||||
|
- **Builds:** EAS Build
|
||||||
|
|
||||||
|
- **Backend Server:** Golang (Gin + GORM)
|
||||||
|
- **Database:** PostgreSQL
|
||||||
|
|
||||||
|
- **Environment & Deployment:** Docker for prototyping and portability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
To run this project locally:
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
2. Run `npm install` (or `yarn install`)
|
||||||
|
3. Create a `.env` file in the root directory with your API keys
|
||||||
|
5. Start the server with: npx expo start
|
||||||
32
backend/Dockerfile
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Use official Go image as build environment
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
# Set working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go.mod and go.sum first (for caching dependencies)
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies (cached if no changes)
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy the rest of the source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the Go app statically linked for Alpine
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o main
|
||||||
|
|
||||||
|
# Start a new minimal image to run the app
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Install ca-certificates for HTTPS calls (if needed)
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
|
# Copy the binary from builder
|
||||||
|
COPY --from=builder /app/main /main
|
||||||
|
|
||||||
|
# Expose port your app listens on
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Run the binary
|
||||||
|
CMD ["./main"]
|
||||||
48
backend/database/db_config.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"log"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *gorm.DB
|
||||||
|
|
||||||
|
func getConnectionString() string {
|
||||||
|
var (
|
||||||
|
host = os.Getenv("DB_HOST")
|
||||||
|
port = os.Getenv("DB_PORT")
|
||||||
|
user = os.Getenv("DB_USER")
|
||||||
|
password = os.Getenv("DB_PASSWORD")
|
||||||
|
dbname = os.Getenv("DB_NAME")
|
||||||
|
)
|
||||||
|
|
||||||
|
log.Printf("[LOG] Attempting to connect with: host=%s, port=%s, user=%s, dbname=%s",
|
||||||
|
host, port, user, dbname)
|
||||||
|
|
||||||
|
connStr := fmt.Sprintf(
|
||||||
|
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
dbname,
|
||||||
|
)
|
||||||
|
|
||||||
|
return connStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectDB() {
|
||||||
|
db_url := getConnectionString()
|
||||||
|
|
||||||
|
db, err := gorm.Open(postgres.Open(db_url), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("[FAIL] Failed to connect to database:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("[SUCCESS] Connected to database successfully")
|
||||||
|
DB = db
|
||||||
|
}
|
||||||
50
backend/go.mod
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
module backend
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
toolchain go1.23.10
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.10.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/gorm v1.30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
|
golang.org/x/crypto v0.31.0 // indirect
|
||||||
|
golang.org/x/net v0.25.0 // indirect
|
||||||
|
golang.org/x/sync v0.10.0 // indirect
|
||||||
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
117
backend/go.sum
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
|
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
|
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||||
|
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
17
backend/main.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/database"
|
||||||
|
"backend/routes"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
database.ConnectDB()
|
||||||
|
|
||||||
|
r := routes.SetupRoutes()
|
||||||
|
|
||||||
|
log.Println("[LOG]: backend server started")
|
||||||
|
|
||||||
|
r.Run()
|
||||||
|
}
|
||||||
12
backend/models/image.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PlaceImage struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
PlaceID uuid.UUID
|
||||||
|
ImageURL string
|
||||||
|
Notes string
|
||||||
|
}
|
||||||
18
backend/models/place.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Place struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Name string
|
||||||
|
Latitude float64
|
||||||
|
Longitude float64
|
||||||
|
Address string
|
||||||
|
LocationType string
|
||||||
|
WomensSpace bool
|
||||||
|
Wudu bool
|
||||||
|
WebsiteURL string
|
||||||
|
Notes string
|
||||||
|
}
|
||||||
18
backend/models/review.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Review struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Reviewer uuid.UUID
|
||||||
|
Place uuid.UUID
|
||||||
|
Quiet int
|
||||||
|
Clean int
|
||||||
|
CleanWudu int
|
||||||
|
Private int
|
||||||
|
ChildFriendly int
|
||||||
|
Safe int
|
||||||
|
Notes string
|
||||||
|
}
|
||||||
11
backend/models/user.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Username string
|
||||||
|
HashedPass string
|
||||||
|
}
|
||||||
52
backend/routes/routes.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"backend/server"
|
||||||
|
"os" // os is used by ensureUploadsDir
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetupRoutes() *gin.Engine {
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// Create uploads directory if it doesn't exist
|
||||||
|
if err := ensureUploadsDir(); err != nil {
|
||||||
|
panic("Failed to create uploads directory: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health
|
||||||
|
r.GET("/health", server.HealthCheck)
|
||||||
|
|
||||||
|
// Reviews
|
||||||
|
r.GET("/reviews/query", server.QueryReviews)
|
||||||
|
r.POST("/reviews/new", server.SaveReview)
|
||||||
|
|
||||||
|
// Places
|
||||||
|
r.GET("/places/query", server.QueryPlaces)
|
||||||
|
r.POST("/places/new", server.SavePlace)
|
||||||
|
r.PUT("/places/:id/website", server.UpdatePlaceWebsite) // NEW: Route for updating website
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Place Images
|
||||||
|
r.GET("/place_images/query", server.QueryImages)
|
||||||
|
r.GET("/place_images/query/:place_id", server.QueryImagesForPlace)
|
||||||
|
r.GET("/place_images/folder", server.ListEndpoints)
|
||||||
|
|
||||||
|
// New Image Upload Endpoint
|
||||||
|
r.POST("/images/upload", server.UploadImage)
|
||||||
|
|
||||||
|
// Serve static files from uploads directory
|
||||||
|
r.Static("/uploads", "./uploads")
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureUploadsDir creates the uploads directory if it doesn't exist
|
||||||
|
func ensureUploadsDir() error {
|
||||||
|
uploadDir := "./uploads"
|
||||||
|
if _, err := os.Stat(uploadDir); os.IsNotExist(err) {
|
||||||
|
return os.Mkdir(uploadDir, 0755)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
447
backend/server/server.go
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"backend/database"
|
||||||
|
"backend/models"
|
||||||
|
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FrontendReviewPayload: EXPECTS `user` AND `place` AS STRINGS
|
||||||
|
type FrontendReviewPayload struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
Place string `json:"place"`
|
||||||
|
Quiet int `json:"quiet"`
|
||||||
|
Clean int `json:"clean"`
|
||||||
|
Private int `json:"private"`
|
||||||
|
CleanWudu int `json:"cleanWudu"`
|
||||||
|
ChildFriendly int `json:"childFriendly"`
|
||||||
|
Safe int `json:"safe"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrontendReview: Type used when marshalling reviews for the frontend (e.g., in QueryReviews)
|
||||||
|
type FrontendReview struct {
|
||||||
|
User string `json:"User"`
|
||||||
|
Place string `json:"Place"`
|
||||||
|
Quiet int `json:"Quiet"`
|
||||||
|
Clean int `json:"Clean"`
|
||||||
|
CleanWudu int `json:"CleanWudu"`
|
||||||
|
Private int `json:"Private"`
|
||||||
|
ChildFriendly int `json:"ChildFriendly"`
|
||||||
|
Safe int `json:"Safe"`
|
||||||
|
Notes string `json:"Notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrontendPlacePayload: For adding new places - NOW INCLUDES WebsiteURL
|
||||||
|
type FrontendPlacePayload struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
LocationType string `json:"location_type"`
|
||||||
|
WomensSpace bool `json:"womens_space"`
|
||||||
|
Wudu bool `json:"wudu"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
WebsiteURL string `json:"website_url"` // ADDED: Matches frontend's 'website' field in AddMosqueScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrontendPlace: Type used when marshalling places for the frontend (e.g., in QueryPlaces)
|
||||||
|
type FrontendPlace struct {
|
||||||
|
ID uuid.UUID `json:"ID"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Latitude float64 `json:"Latitude"`
|
||||||
|
Longitude float64 `json:"Longitude"`
|
||||||
|
Address string `json:"Address"`
|
||||||
|
LocationType string `json:"LocationType"`
|
||||||
|
WomensSpace bool `json:"WomensSpace"`
|
||||||
|
Wudu bool `json:"Wudu"`
|
||||||
|
WebsiteURL string `json:"WebsiteURL"`
|
||||||
|
Notes string `json:"Notes"`
|
||||||
|
Images []models.PlaceImage `json:"Images"`
|
||||||
|
Reviews []models.Review `json:"Reviews"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func HealthCheck(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, "status: healthy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueryPlaces(c *gin.Context) {
|
||||||
|
var places []models.Place
|
||||||
|
|
||||||
|
result := database.DB.Find(&places)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[ERROR]: error while retrieving places from database: %s", result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve places: %s", result.Error)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var newPlaces []FrontendPlace
|
||||||
|
|
||||||
|
for _, oldPlace := range places {
|
||||||
|
newPlace := toFrontendPlace(oldPlace)
|
||||||
|
newPlaces = append(newPlaces, newPlace)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, newPlaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueryImages(c *gin.Context) {
|
||||||
|
var images []models.PlaceImage
|
||||||
|
|
||||||
|
result := database.DB.Find(&images)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[ERROR]: error while retrieving images from database: %s", result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve images: %s", result.Error)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, images)
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueryImagesForPlace(c *gin.Context) {
|
||||||
|
placeID := c.Param("place_id")
|
||||||
|
|
||||||
|
var images []models.PlaceImage
|
||||||
|
|
||||||
|
result := database.DB.
|
||||||
|
Where("place_id = ?", placeID).
|
||||||
|
Find(&images)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[ERROR]: error while retrieving images for place from database: %s", result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve images for place: %s", result.Error)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, images)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func QueryReviews(c *gin.Context) {
|
||||||
|
var reviews []models.Review
|
||||||
|
|
||||||
|
database.DB.Find(&reviews)
|
||||||
|
|
||||||
|
frontendReviews := []FrontendReview{}
|
||||||
|
|
||||||
|
for _, oldReview := range reviews {
|
||||||
|
newReview := toFrontendReview(oldReview)
|
||||||
|
frontendReviews = append(frontendReviews, newReview)
|
||||||
|
log.Printf("%+v\n", newReview)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[LOG]: Fetched frontend reviews: %+v", frontendReviews)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, frontendReviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveReview(c *gin.Context) {
|
||||||
|
var payload FrontendReviewPayload
|
||||||
|
|
||||||
|
if err := c.BindJSON(&payload); err != nil {
|
||||||
|
log.Printf("[ERROR]: Failed to bind JSON for review: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[LOG]: Received review payload: %+v", payload)
|
||||||
|
|
||||||
|
// Look up Place by Name
|
||||||
|
var place models.Place
|
||||||
|
database.DB.Where("name = ?", payload.Place).First(&place)
|
||||||
|
if place.ID == uuid.Nil {
|
||||||
|
log.Printf("[ERROR]: Place with name '%s' not found for review submission.", payload.Place)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Place not found. Please ensure the place name is correct."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up Reviewer by Username
|
||||||
|
var reviewer models.User
|
||||||
|
database.DB.Where("username = ?", payload.User).First(&reviewer)
|
||||||
|
if reviewer.ID == uuid.Nil {
|
||||||
|
log.Printf("[ERROR]: Reviewer with username '%s' not found for review submission.", payload.User)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Reviewer user not found. Please ensure username is correct."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
review := models.Review{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Reviewer: reviewer.ID,
|
||||||
|
Place: place.ID,
|
||||||
|
Quiet: payload.Quiet,
|
||||||
|
Clean: payload.Clean,
|
||||||
|
Private: payload.Private,
|
||||||
|
CleanWudu: payload.CleanWudu,
|
||||||
|
ChildFriendly: payload.ChildFriendly,
|
||||||
|
Safe: payload.Safe,
|
||||||
|
Notes: payload.Notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := database.DB.Create(&review)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[ERROR]: Error creating review in DB: %s", result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"message": "Review submitted successfully!", "reviewId": review.ID.String()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePlace function (for adding new mosques)
|
||||||
|
func SavePlace(c *gin.Context) {
|
||||||
|
var payload FrontendPlacePayload
|
||||||
|
|
||||||
|
if err := c.BindJSON(&payload); err != nil {
|
||||||
|
log.Printf("[ERROR]: Failed to bind JSON for new place: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[LOG]: Received new place payload: %+v", payload)
|
||||||
|
|
||||||
|
place := models.Place{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: payload.Name,
|
||||||
|
Latitude: payload.Latitude,
|
||||||
|
Longitude: payload.Longitude,
|
||||||
|
Address: payload.Address,
|
||||||
|
LocationType: payload.LocationType,
|
||||||
|
WomensSpace: payload.WomensSpace,
|
||||||
|
Wudu: payload.Wudu,
|
||||||
|
Notes: payload.Notes,
|
||||||
|
WebsiteURL: payload.WebsiteURL, // Ensure models.Place has WebsiteURL field
|
||||||
|
}
|
||||||
|
|
||||||
|
result := database.DB.Create(&place)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[ERROR]: Error creating new place in DB: %s", result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"message": "Place added successfully!", "placeId": place.ID.String()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadImage function (Fixed ImageURL storage)
|
||||||
|
func UploadImage(c *gin.Context) {
|
||||||
|
placeIDStr := c.PostForm("place_id")
|
||||||
|
notes := c.PostForm("Notes")
|
||||||
|
|
||||||
|
if placeIDStr == "" {
|
||||||
|
log.Printf("[ERROR]: Place ID is missing in image upload.")
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Place ID is required for image upload."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := c.FormFile("image")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ERROR]: Failed to get image file from form: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": fmt.Sprintf("Failed to get image file: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if !isValidImage(file) {
|
||||||
|
log.Printf("[ERROR]: Invalid image file type for upload: %s", file.Header.Get("Content-Type"))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid file type. Only JPEG, PNG, and GIF are allowed."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
placeUUID, err := uuid.Parse(placeIDStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ERROR]: Invalid Place ID UUID format for image upload '%s': %s", placeIDStr, err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid Place ID format."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingPlace models.Place
|
||||||
|
if err := database.DB.First(&existingPlace, placeUUID).Error; err != nil {
|
||||||
|
log.Printf("[ERROR]: Place with ID %s not found for image upload: %s", placeUUID, err)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "Place not found."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique filename and path
|
||||||
|
ext := filepath.Ext(file.Filename)
|
||||||
|
uniqueFileName := fmt.Sprintf("%s-%s%s",
|
||||||
|
time.Now().Format("20060102-150405"),
|
||||||
|
uuid.New().String(),
|
||||||
|
ext)
|
||||||
|
|
||||||
|
// filePath is where the file is physically saved on the server
|
||||||
|
filePath := filepath.Join("uploads", uniqueFileName)
|
||||||
|
|
||||||
|
// dbImageURL is the path stored in the database, relative to the web server's static route
|
||||||
|
dbImageURL := fmt.Sprintf("/uploads/%s", uniqueFileName) // Correct format for web serving
|
||||||
|
|
||||||
|
if err := c.SaveUploadedFile(file, filePath); err != nil {
|
||||||
|
log.Printf("[ERROR]: Failed to save uploaded file '%s' to %s: %s", file.Filename, filePath, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Failed to save image file on server."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store image metadata in the database (place_images table)
|
||||||
|
image := models.PlaceImage{
|
||||||
|
ID: uuid.New(),
|
||||||
|
PlaceID: placeUUID,
|
||||||
|
ImageURL: dbImageURL, // FIXED: Store the web-accessible relative URL here
|
||||||
|
Notes: notes, // Store notes from the form
|
||||||
|
}
|
||||||
|
|
||||||
|
result := database.DB.Create(&image)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[ERROR]: Error saving image metadata to database: %s", result.Error)
|
||||||
|
// Clean up the file if DB save fails
|
||||||
|
if removeErr := os.Remove(filePath); removeErr != nil {
|
||||||
|
log.Printf("[ERROR]: Failed to clean up uploaded file '%s' after DB error: %s", filePath, removeErr)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": fmt.Sprintf("Failed to save image metadata: %s", result.Error)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "File uploaded successfully",
|
||||||
|
"imageUrl": dbImageURL, // Return the relative path
|
||||||
|
"imageId": image.ID.String(),
|
||||||
|
"placeId": image.PlaceID.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdatePlaceWebsite(c *gin.Context) {
|
||||||
|
placeID := c.Param("id") // Get the place ID from the URL parameter
|
||||||
|
|
||||||
|
var payload struct { // Define an anonymous struct for the incoming JSON payload
|
||||||
|
WebsiteURL string `json:"website_url"` // Matches payload from frontend
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.BindJSON(&payload); err != nil {
|
||||||
|
log.Printf("[ERROR]: Failed to bind JSON for website update: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[LOG]: Received website update payload for Place ID %s: %+v", placeID, payload)
|
||||||
|
|
||||||
|
// Parse the placeID string to uuid.UUID
|
||||||
|
placeUUID, err := uuid.Parse(placeID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ERROR]: Invalid Place ID UUID format for website update '%s': %s", placeID, err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Place ID format."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the place by ID and update its WebsiteURL
|
||||||
|
// Note: models.Place must have WebsiteURL field mapped to website_url in DB
|
||||||
|
result := database.DB.Model(&models.Place{}).Where("id = ?", placeUUID).Update("WebsiteURL", payload.WebsiteURL)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[ERROR]: Error updating website for place %s in DB: %s", placeID, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
log.Printf("[WARN]: No place found with ID %s for website update.", placeID)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Place not found."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Website updated successfully!"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to validate image files
|
||||||
|
func isValidImage(file *multipart.FileHeader) bool {
|
||||||
|
allowedTypes := []string{"image/jpeg", "image/png", "image/gif"}
|
||||||
|
contentType := file.Header.Get("Content-Type")
|
||||||
|
for _, t := range allowedTypes {
|
||||||
|
if t == contentType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEndpoints function (from your provided routes.go, for debugging/listing files)
|
||||||
|
func ListEndpoints(c *gin.Context) {
|
||||||
|
dirPath := "./uploads"
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dirPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error reading directory:", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to read uploads directory: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileNames []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
fileNames = append(fileNames, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"files": fileNames, "message": "List of files in uploads directory."})
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFrontendReview(review models.Review) FrontendReview {
|
||||||
|
var user models.User
|
||||||
|
var place models.Place
|
||||||
|
|
||||||
|
if err := database.DB.First(&user, review.Reviewer).Error; err != nil {
|
||||||
|
log.Printf("[WARN]: User with ID %s not found for review %s: %s", review.Reviewer, review.ID, err)
|
||||||
|
user.Username = "Unknown User"
|
||||||
|
}
|
||||||
|
if err := database.DB.First(&place, review.Place).Error; err != nil {
|
||||||
|
log.Printf("[WARN]: Place with ID %s not found for review %s: %s", review.Place, review.ID, err)
|
||||||
|
place.Name = "Unknown Place"
|
||||||
|
}
|
||||||
|
|
||||||
|
newReview := FrontendReview{
|
||||||
|
User: user.Username,
|
||||||
|
Place: place.Name,
|
||||||
|
Quiet: review.Quiet,
|
||||||
|
Clean: review.Clean,
|
||||||
|
CleanWudu: review.CleanWudu,
|
||||||
|
Private: review.Private,
|
||||||
|
ChildFriendly: review.ChildFriendly,
|
||||||
|
Safe: review.Safe,
|
||||||
|
Notes: review.Notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
return newReview
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFrontendPlace(place models.Place) FrontendPlace {
|
||||||
|
var reviews []models.Review
|
||||||
|
var images []models.PlaceImage
|
||||||
|
|
||||||
|
database.DB.Where("place = ?", place.ID).Find(&reviews)
|
||||||
|
database.DB.Where("place_id = ?", place.ID).Find(&images)
|
||||||
|
|
||||||
|
newPlace := FrontendPlace{
|
||||||
|
ID: place.ID,
|
||||||
|
Name: place.Name,
|
||||||
|
Latitude: place.Latitude,
|
||||||
|
Longitude: place.Longitude,
|
||||||
|
Address: place.Address,
|
||||||
|
LocationType: place.LocationType,
|
||||||
|
WomensSpace: place.WomensSpace,
|
||||||
|
Wudu: place.Wudu,
|
||||||
|
WebsiteURL: place.WebsiteURL,
|
||||||
|
Notes: place.Notes,
|
||||||
|
Images: images,
|
||||||
|
Reviews: reviews,
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPlace
|
||||||
|
}
|
||||||
40
database/init/01-schema.sql
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
CREATE TABLE places (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255),
|
||||||
|
latitude DOUBLE PRECISION,
|
||||||
|
longitude DOUBLE PRECISION,
|
||||||
|
address VARCHAR(1023),
|
||||||
|
location_type VARCHAR(255) CHECK (location_type IN ('mosque', 'other', 'outdoor_space', 'multi_faith_room')),
|
||||||
|
womens_space BOOLEAN,
|
||||||
|
wudu BOOLEAN,
|
||||||
|
website_url VARCHAR(2047) DEFAULT NULL,
|
||||||
|
notes VARCHAR(4095)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username VARCHAR(255),
|
||||||
|
hashedpass VARCHAR(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE reviews (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
reviewer UUID REFERENCES users(id),
|
||||||
|
place UUID REFERENCES places(id),
|
||||||
|
quiet INT NOT NULL,
|
||||||
|
clean INT NOT NULL,
|
||||||
|
clean_wudu INT NOT NULL,
|
||||||
|
private INT NOT NULL,
|
||||||
|
child_friendly INT NOT NULL,
|
||||||
|
safe INT NOT NULL,
|
||||||
|
notes VARCHAR(4095) DEFAULT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE place_images (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
place_id UUID REFERENCES places(id),
|
||||||
|
image_url VARCHAR(2047),
|
||||||
|
notes VARCHAR(4095)
|
||||||
|
)
|
||||||
243
database/init/02-seed.sql
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
INSERT INTO users (id, username, hashedpass)
|
||||||
|
VALUES ('123e4567-e89b-12d3-a456-426614174000', 'testuser', 'password');
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO places (id, name, longitude, latitude, address, location_type, womens_space, wudu, website_url, notes)
|
||||||
|
VALUES (
|
||||||
|
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||||
|
'East London Mosque',
|
||||||
|
-0.0604,
|
||||||
|
51.5152,
|
||||||
|
'82-92 Whitechapel Rd, London E1 1JQ',
|
||||||
|
'mosque',
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
'https://www.eastlondonmosque.org.uk/',
|
||||||
|
'Large, well-established mosque with extensive facilities. Can be busy for Jummah.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO places (id, name, longitude, latitude, address, location_type, womens_space, wudu, website_url, notes)
|
||||||
|
VALUES (
|
||||||
|
'c1d2b340-a9fb-4e39-9d58-8d537012fc1f',
|
||||||
|
'London Central Mosque',
|
||||||
|
-0.1618,
|
||||||
|
51.5316,
|
||||||
|
'146 Park Rd, London NW8 7RG',
|
||||||
|
'mosque',
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
'https://www.iccuk.org/',
|
||||||
|
'Iconic mosque near Regent’s Park. Very popular and spacious. Good facilities for women.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO places (id, name, longitude, latitude, address, location_type, womens_space, wudu, website_url, notes)
|
||||||
|
VALUES (
|
||||||
|
'b8f6f9a2-8b1e-4c01-988f-30c66c3c4b7f',
|
||||||
|
'Finsbury Park Mosque',
|
||||||
|
-0.1094,
|
||||||
|
51.5651,
|
||||||
|
'7–11 St Thomas’s Rd, London N4 2QH',
|
||||||
|
'mosque',
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
'https://finsburyparkmosque.org/',
|
||||||
|
'Active community mosque. Offers classes and events. Women’s section available upstairs.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO places (id, name, longitude, latitude, address, location_type, womens_space, wudu, website_url, notes)
|
||||||
|
VALUES (
|
||||||
|
'e347c2be-338e-41d9-b9b0-dacc5a77fd22',
|
||||||
|
'Brick Lane Mosque',
|
||||||
|
-0.0713,
|
||||||
|
51.5207,
|
||||||
|
'1 Fournier St, London E1 6QE',
|
||||||
|
'mosque',
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
'https://bricklanejammemasjid.org.uk/',
|
||||||
|
'Housed in a historic building. Limited space but centrally located. Jummah can be crowded.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO places (id, name, longitude, latitude, address, location_type, womens_space, wudu, website_url, notes)
|
||||||
|
VALUES (
|
||||||
|
'd47b6d77-9c47-4dd1-8361-b5bdf5f9630f',
|
||||||
|
'West London Islamic Centre',
|
||||||
|
-0.3295,
|
||||||
|
51.5111,
|
||||||
|
'Brownlow Rd, London W13 0SQ',
|
||||||
|
'mosque',
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
'https://wlic.co.uk/',
|
||||||
|
'Modern, active mosque with good facilities. Family-friendly. Separate prayer hall for women.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
|
||||||
|
VALUES(
|
||||||
|
'9b2f6f78-1c6d-4a3c-bb70-2d6b907d8fc6',
|
||||||
|
'123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
'Excellent option. Felt very safe. No issues!'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- London Central Mosque reviews
|
||||||
|
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
|
||||||
|
VALUES (
|
||||||
|
'aa2a3bb7-9a66-4e5c-87db-b2f994f94460',
|
||||||
|
'123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
'c1d2b340-a9fb-4e39-9d58-8d537012fc1f',
|
||||||
|
3, 3, 3, 2, 3, 3,
|
||||||
|
'Very clean and well-maintained. A bit busy on weekends, but overall peaceful.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
|
||||||
|
VALUES (
|
||||||
|
'9f3c6e3a-06d6-4b8c-a5f9-cbf1533a2b20',
|
||||||
|
'123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
'c1d2b340-a9fb-4e39-9d58-8d537012fc1f',
|
||||||
|
2, 3, 2, 1, 3, 3,
|
||||||
|
'Impressive space but the wudu area can get a bit crowded.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
|
||||||
|
VALUES (
|
||||||
|
'1a644da0-57c0-4bba-bb65-195fa32667a6',
|
||||||
|
'123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
'c1d2b340-a9fb-4e39-9d58-8d537012fc1f',
|
||||||
|
3, 3, 3, 3, 3, 3,
|
||||||
|
'One of the best prayer spaces in London. Women’s area is very good too.'
|
||||||
|
);
|
||||||
|
|
||||||
|
--Finsbury park mosque reviews
|
||||||
|
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
|
||||||
|
VALUES (
|
||||||
|
'd3ac1e01-9c91-4d3a-93ee-fd52d08eab37',
|
||||||
|
'123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
'b8f6f9a2-8b1e-4c01-988f-30c66c3c4b7f',
|
||||||
|
2, 2, 2, 2, 2, 2,
|
||||||
|
'Good space but can be quite busy and a little noisy at times.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
|
||||||
|
VALUES (
|
||||||
|
'66bc2be3-849e-4127-b6b1-c25465fd6ef0',
|
||||||
|
'123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
'b8f6f9a2-8b1e-4c01-988f-30c66c3c4b7f',
|
||||||
|
3, 2, 2, 2, 2, 3,
|
||||||
|
'Felt safe and comfortable. Great khutbahs.'
|
||||||
|
);
|
||||||
|
|
||||||
|
--Brick lane mosque reviews
|
||||||
|
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
|
||||||
|
VALUES (
|
||||||
|
'82c2f98b-6b59-46e0-b263-ecbdc3054cf1',
|
||||||
|
'123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
'e347c2be-338e-41d9-b9b0-dacc5a77fd22',
|
||||||
|
2, 2, 1, 1, 2, 2,
|
||||||
|
'Historic site but facilities are limited. Noisy due to location.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
|
||||||
|
VALUES (
|
||||||
|
'2cb11b62-8f19-4c6d-a75f-4179f63cc3fa',
|
||||||
|
'123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
'e347c2be-338e-41d9-b9b0-dacc5a77fd22',
|
||||||
|
1, 2, 1, 1, 1, 2,
|
||||||
|
'Very basic space. No real privacy. Better for quick prayers than extended time.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
|
||||||
|
VALUES (
|
||||||
|
'f5e7483a-69f3-4e94-a180-d8d37d64a0b4',
|
||||||
|
'123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
'e347c2be-338e-41d9-b9b0-dacc5a77fd22',
|
||||||
|
2, 2, 2, 2, 2, 2,
|
||||||
|
'Love the atmosphere but definitely not the cleanest mosque.'
|
||||||
|
);
|
||||||
|
|
||||||
|
--West London Islamic Centre
|
||||||
|
INSERT INTO reviews (id, reviewer, place, quiet, clean, clean_wudu, private, child_friendly, safe, notes)
|
||||||
|
VALUES (
|
||||||
|
'eae0c801-cf0a-42b2-bd39-fb8a54cf5d72',
|
||||||
|
'123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
'd47b6d77-9c47-4dd1-8361-b5bdf5f9630f',
|
||||||
|
3, 3, 3, 3, 3, 3,
|
||||||
|
'Absolutely wonderful space. Quiet and ideal for focus.'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Images
|
||||||
|
|
||||||
|
-- East London Mosque
|
||||||
|
INSERT INTO place_images (id, place_id, image_url, notes)
|
||||||
|
VALUES(
|
||||||
|
'f3b9a69e-7c4e-4c8c-9b8d-f2e313298dc9',
|
||||||
|
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||||
|
'https://upload.wikimedia.org/wikipedia/commons/c/cc/East_London_Mosque_Front_View.jpg',
|
||||||
|
'Front view of the East London Mosque and London Muslim Centre.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO place_images (id, place_id, image_url, notes)
|
||||||
|
VALUES(
|
||||||
|
'a3c2de90-bfc5-41fa-8c6d-5b3950c0fa2f',
|
||||||
|
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||||
|
'https://prayersconnect.com/api/image-resize/eyJlZGl0cyI6eyJyZXNpemUiOnsid2lkdGgiOjYxNiwiaGVpZ2h0IjozOTJ9fSwidXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL3BsYWNlcy9BQWNYcjhyb3haOS1rZ3FBbHluQTRjT2ZzcGlhLVlhZGRjX3pRV2dSNW1FQ2xKVFpHcEd4U21VLVVMWUFuUEVid3FGdnNNRGhUd0k1Wmc1NS1Kd1BzSW9vSDZuVm1hS0d5Z1JkSS1jPXMxNjAwLXc0MDMyIn0=',
|
||||||
|
'Interior prayer hall, view towards the Mihrab.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO place_images (id, place_id, image_url, notes)
|
||||||
|
VALUES(
|
||||||
|
'54f6b42c-7e4e-4fc3-91a0-bf248aa5a998',
|
||||||
|
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||||
|
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRhfHiXh7Jl7iHjMy6PF7dn5GgTxHebNFVAAQ&s',
|
||||||
|
'Women''s gallery providing a separate prayer space.'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO place_images (id, place_id, image_url, notes)
|
||||||
|
VALUES(
|
||||||
|
'e74a8b59-6ae1-4c6e-9f1e-2b8122e2e2ce',
|
||||||
|
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||||
|
'https://www.eastlondonmosque.org.uk/Handlers/GetImage.ashx?IDMF=7c71af52-2713-4120-bd19-8b03a9b4d313&h=842&src=mc&w=1192',
|
||||||
|
'Women''s gallery providing a separate prayer space.'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- London Central Mosque (Regent's Park)
|
||||||
|
INSERT INTO place_images (id, place_id, image_url, notes)
|
||||||
|
VALUES (
|
||||||
|
'6a11a9f0-d8f9-4ad6-b77e-d09b987df2f1',
|
||||||
|
'c1d2b340-a9fb-4e39-9d58-8d537012fc1f',
|
||||||
|
'https://upload.wikimedia.org/wikipedia/commons/1/13/London_Central_Mosque_2.jpg',
|
||||||
|
'Exterior view of London Central Mosque near Regent’s Park.'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Finsbury Park Mosque
|
||||||
|
INSERT INTO place_images (id, place_id, image_url, notes)
|
||||||
|
VALUES (
|
||||||
|
'9c59a700-1f88-4cd7-97ee-9a53c6cb7ff5',
|
||||||
|
'b8f6f9a2-8b1e-4c01-988f-30c66c3c4b7f',
|
||||||
|
'https://upload.wikimedia.org/wikipedia/commons/d/d8/North_London_Central_Mosque%2C_Finsbury_Park_-_geograph.org.uk_-_759870.jpg',
|
||||||
|
'Street-facing image of Finsbury Park Mosque (North London Central Mosque).'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Brick Lane Mosque
|
||||||
|
INSERT INTO place_images (id, place_id, image_url, notes)
|
||||||
|
VALUES (
|
||||||
|
'e3fd61f1-0d8b-4f89-8a55-40f4d040b59b',
|
||||||
|
'e347c2be-338e-41d9-b9b0-dacc5a77fd22',
|
||||||
|
'https://upload.wikimedia.org/wikipedia/commons/4/4b/Brick_Lane_Mosque2.JPG',
|
||||||
|
'Historic Brick Lane Mosque building located in East London.'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- West London Islamic Centre
|
||||||
|
INSERT INTO place_images (id, place_id, image_url, notes)
|
||||||
|
VALUES (
|
||||||
|
'ddb1fa3d-8974-4b3e-a0f4-62f4cbcf4080',
|
||||||
|
'd47b6d77-9c47-4dd1-8361-b5bdf5f9630f',
|
||||||
|
'https://wlic.co.uk/wp-content/uploads/2019/09/WLIC_Aug_19.jpg',
|
||||||
|
'Front entrance of the modern West London Islamic Centre.'
|
||||||
|
);
|
||||||
41
docker-compose.yml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
version: '0.1'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
volumes:
|
||||||
|
- uploaded_images:/uploads
|
||||||
|
ports:
|
||||||
|
- '8080:8080'
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:14
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
POSTGRES_DB: ${DB_NAME}
|
||||||
|
volumes:
|
||||||
|
- data:/var/lib/postgresql/data
|
||||||
|
- ./database/init/01-schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||||
|
- ./database/init/02-seed.sql:/docker-entrypoint-initdb.d/02-seed.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
|
uploaded_images:
|
||||||
6
package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "drp-project",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
23
templates/my-component.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
spec:
|
||||||
|
inputs:
|
||||||
|
# These are examples of inputs.
|
||||||
|
job_name:
|
||||||
|
default: job-template
|
||||||
|
image:
|
||||||
|
default: busybox:latest
|
||||||
|
stage:
|
||||||
|
default: test
|
||||||
|
|
||||||
|
---
|
||||||
|
# This is an example of a job using inputs
|
||||||
|
# use variables with this syntax : $[[ inputs.xxx ]]
|
||||||
|
$[[ inputs.job_name ]]:
|
||||||
|
image: $[[ inputs.image ]]
|
||||||
|
stage: $[[ inputs.stage ]]
|
||||||
|
script:
|
||||||
|
- echo "Starting job $[[ inputs.job_name ]]"
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
|
||||||
|
when: never
|
||||||
|
- when: always
|
||||||