User app beta v1 complete
Browse files- .gitignore +16 -1
- Dockerfile +2 -2
- User/.gitignore +41 -0
- User/App.tsx +24 -0
- User/README.md +162 -0
- User/app.json +71 -0
- User/eas.json +26 -0
- User/index.ts +8 -0
- User/package-lock.json +0 -0
- User/package.json +44 -0
- User/src/components/index.ts +3 -0
- User/src/components/issues/IssueCard.tsx +189 -0
- User/src/components/ui/Button.tsx +152 -0
- User/src/components/ui/Card.tsx +60 -0
- User/src/components/ui/Loader.tsx +46 -0
- User/src/components/ui/Skeleton.tsx +57 -0
- User/src/config/env.ts +17 -0
- User/src/config/supabase.ts +47 -0
- User/src/context/AuthContext.tsx +217 -0
- User/src/lib/googleAuthSafe.ts +52 -0
- User/src/navigation/AppNavigator.tsx +156 -0
- User/src/screens/auth/LoginScreen.tsx +253 -0
- User/src/screens/capture/CaptureScreen.tsx +677 -0
- User/src/screens/capture/ProcessingScreen.tsx +632 -0
- User/src/screens/home/DashboardScreen.tsx +300 -0
- User/src/screens/home/HomeScreen.tsx +445 -0
- User/src/screens/index.ts +7 -0
- User/src/screens/issues/IssueDetailScreen.tsx +481 -0
- User/src/screens/issues/MyIssuesScreen.tsx +281 -0
- User/src/screens/profile/ProfileScreen.tsx +284 -0
- User/src/services/cacheService.ts +55 -0
- User/src/services/issueService.ts +148 -0
- User/src/services/locationService.ts +145 -0
- User/src/theme/index.ts +141 -0
- User/src/types/index.ts +60 -0
- User/tsconfig.json +6 -0
.gitignore
CHANGED
|
@@ -3,4 +3,19 @@
|
|
| 3 |
*.pyc
|
| 4 |
*.jpg
|
| 5 |
*.png
|
| 6 |
-
requestly/CityTrack/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
*.pyc
|
| 4 |
*.jpg
|
| 5 |
*.png
|
| 6 |
+
requestly/CityTrack/*
|
| 7 |
+
User/.env
|
| 8 |
+
User/.env.*
|
| 9 |
+
|
| 10 |
+
User/.expo/
|
| 11 |
+
User/.metro-cache/
|
| 12 |
+
User/.cache/
|
| 13 |
+
|
| 14 |
+
User/android/.gradle/
|
| 15 |
+
User/android/build/
|
| 16 |
+
User/android/app/build/
|
| 17 |
+
User/android/local.properties
|
| 18 |
+
User/android/app/release/
|
| 19 |
+
User/android/app/debug/
|
| 20 |
+
|
| 21 |
+
.vscode
|
Dockerfile
CHANGED
|
@@ -1,7 +1,5 @@
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
-
LABEL org.opencontainers.image.source=https://github.com/0xarchit/citytrack
|
| 4 |
-
|
| 5 |
WORKDIR /app
|
| 6 |
|
| 7 |
RUN apt-get update && apt-get install -y \
|
|
@@ -28,3 +26,5 @@ ENV PYTHONUNBUFFERED=1
|
|
| 28 |
EXPOSE 7860
|
| 29 |
|
| 30 |
CMD ["python", "-m", "uvicorn", "Backend.api:app", "--host", "0.0.0.0", "--port", "7860", "--forwarded-allow-ips", "*"]
|
|
|
|
|
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
|
|
|
|
|
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
RUN apt-get update && apt-get install -y \
|
|
|
|
| 26 |
EXPOSE 7860
|
| 27 |
|
| 28 |
CMD ["python", "-m", "uvicorn", "Backend.api:app", "--host", "0.0.0.0", "--port", "7860", "--forwarded-allow-ips", "*"]
|
| 29 |
+
|
| 30 |
+
LABEL org.opencontainers.image.source=https://github.com/0xarchit/CityTrack
|
User/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
node_modules/
|
| 5 |
+
|
| 6 |
+
# Expo
|
| 7 |
+
.expo/
|
| 8 |
+
dist/
|
| 9 |
+
web-build/
|
| 10 |
+
expo-env.d.ts
|
| 11 |
+
|
| 12 |
+
# Native
|
| 13 |
+
.kotlin/
|
| 14 |
+
*.orig.*
|
| 15 |
+
*.jks
|
| 16 |
+
*.p8
|
| 17 |
+
*.p12
|
| 18 |
+
*.key
|
| 19 |
+
*.mobileprovision
|
| 20 |
+
|
| 21 |
+
# Metro
|
| 22 |
+
.metro-health-check*
|
| 23 |
+
|
| 24 |
+
# debug
|
| 25 |
+
npm-debug.*
|
| 26 |
+
yarn-debug.*
|
| 27 |
+
yarn-error.*
|
| 28 |
+
|
| 29 |
+
# macOS
|
| 30 |
+
.DS_Store
|
| 31 |
+
*.pem
|
| 32 |
+
|
| 33 |
+
# local env files
|
| 34 |
+
.env*.local
|
| 35 |
+
|
| 36 |
+
# typescript
|
| 37 |
+
*.tsbuildinfo
|
| 38 |
+
|
| 39 |
+
# generated native folders
|
| 40 |
+
/ios
|
| 41 |
+
/android
|
User/App.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { StatusBar } from 'expo-status-bar';
|
| 3 |
+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
| 4 |
+
import { StyleSheet } from 'react-native';
|
| 5 |
+
|
| 6 |
+
import { AuthProvider } from './src/context/AuthContext';
|
| 7 |
+
import { AppNavigator } from './src/navigation/AppNavigator';
|
| 8 |
+
|
| 9 |
+
export default function App() {
|
| 10 |
+
return (
|
| 11 |
+
<GestureHandlerRootView style={styles.container}>
|
| 12 |
+
<AuthProvider>
|
| 13 |
+
<StatusBar style="light" />
|
| 14 |
+
<AppNavigator />
|
| 15 |
+
</AuthProvider>
|
| 16 |
+
</GestureHandlerRootView>
|
| 17 |
+
);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const styles = StyleSheet.create({
|
| 21 |
+
container: {
|
| 22 |
+
flex: 1,
|
| 23 |
+
},
|
| 24 |
+
});
|
User/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# City Issue Reporter - Mobile App
|
| 2 |
+
|
| 3 |
+
A React Native + Expo mobile application for citizens to report city infrastructure issues with AI-powered classification and real-time tracking.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
### Authentication
|
| 8 |
+
- **Google OAuth only** via Supabase Auth
|
| 9 |
+
- Secure session management with auto-refresh
|
| 10 |
+
- No anonymous reporting (prevents spam)
|
| 11 |
+
|
| 12 |
+
### Anti-Fraud Protection
|
| 13 |
+
- **Mandatory GPS**: Camera access blocked until GPS is enabled on device
|
| 14 |
+
- **GPS Precision Lock**: Captures blocked until GPS accuracy < 10 meters
|
| 15 |
+
- **Live Camera Only**: No gallery access - photos must be captured in real-time
|
| 16 |
+
- **Location Verification**: Continuous GPS monitoring during capture session
|
| 17 |
+
|
| 18 |
+
### Issue Reporting
|
| 19 |
+
- Live camera capture with frame guides
|
| 20 |
+
- Real-time GPS accuracy indicator
|
| 21 |
+
- AI-powered classification visualization
|
| 22 |
+
- Status: VisionAgent → GeoAgent → PriorityAgent → RoutingAgent → NotificationAgent
|
| 23 |
+
|
| 24 |
+
### Issue Tracking
|
| 25 |
+
- View all your reported issues
|
| 26 |
+
- Filter by status (Reported, In Progress, Resolved)
|
| 27 |
+
- Detailed issue view with:
|
| 28 |
+
- Original vs AI-annotated image toggle
|
| 29 |
+
- Priority and status badges
|
| 30 |
+
- Confidence score visualization
|
| 31 |
+
- Location details
|
| 32 |
+
- Status timeline
|
| 33 |
+
|
| 34 |
+
### Profile & Gamification
|
| 35 |
+
- Civic badges and contribution stats
|
| 36 |
+
- Report history stats
|
| 37 |
+
- Settings management
|
| 38 |
+
|
| 39 |
+
## Tech Stack
|
| 40 |
+
|
| 41 |
+
- **Framework**: React Native + Expo (SDK 54)
|
| 42 |
+
- **Language**: TypeScript
|
| 43 |
+
- **Auth**: Supabase + Google OAuth
|
| 44 |
+
- **Camera**: expo-camera
|
| 45 |
+
- **Location**: expo-location
|
| 46 |
+
- **Navigation**: @react-navigation/native
|
| 47 |
+
- **Styling**: Custom dark theme with glassmorphism
|
| 48 |
+
|
| 49 |
+
## Prerequisites
|
| 50 |
+
|
| 51 |
+
- Node.js 18+
|
| 52 |
+
- npm or yarn
|
| 53 |
+
- Expo CLI
|
| 54 |
+
- Expo Go app on your device (for testing)
|
| 55 |
+
- Google OAuth configured in Supabase
|
| 56 |
+
|
| 57 |
+
## Installation
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
cd User
|
| 61 |
+
npm install
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
## Configuration
|
| 65 |
+
|
| 66 |
+
1. Update Supabase credentials in `src/config/supabase.ts`:
|
| 67 |
+
- `SUPABASE_URL`: Your Supabase project URL
|
| 68 |
+
- `SUPABASE_ANON_KEY`: Your Supabase anon key
|
| 69 |
+
|
| 70 |
+
2. Configure Google OAuth in Supabase Dashboard:
|
| 71 |
+
- Enable Google provider
|
| 72 |
+
- Add your OAuth client credentials
|
| 73 |
+
|
| 74 |
+
3. Update `app.json` with your bundle identifiers if needed
|
| 75 |
+
|
| 76 |
+
## Running the App
|
| 77 |
+
|
| 78 |
+
### Development
|
| 79 |
+
```bash
|
| 80 |
+
npx expo start
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### iOS Simulator
|
| 84 |
+
```bash
|
| 85 |
+
npx expo start --ios
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### Android Emulator
|
| 89 |
+
```bash
|
| 90 |
+
npx expo start --android
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
### Physical Device
|
| 94 |
+
Scan the QR code with Expo Go app
|
| 95 |
+
|
| 96 |
+
## Project Structure
|
| 97 |
+
|
| 98 |
+
```
|
| 99 |
+
src/
|
| 100 |
+
├── components/
|
| 101 |
+
│ ├── ui/
|
| 102 |
+
│ │ ├── Button.tsx # Gradient button with variants
|
| 103 |
+
│ │ └── Card.tsx # Glass/gradient card component
|
| 104 |
+
│ └── issues/
|
| 105 |
+
│ └── IssueCard.tsx # Issue list item component
|
| 106 |
+
├── config/
|
| 107 |
+
│ └── supabase.ts # Supabase client configuration
|
| 108 |
+
├── context/
|
| 109 |
+
│ └── AuthContext.tsx # Auth state management
|
| 110 |
+
├── navigation/
|
| 111 |
+
│ └── AppNavigator.tsx # Navigation structure
|
| 112 |
+
├── screens/
|
| 113 |
+
│ ├── auth/
|
| 114 |
+
│ │ └── LoginScreen.tsx # Google OAuth login
|
| 115 |
+
│ ├── capture/
|
| 116 |
+
│ │ ├── CaptureScreen.tsx # Camera with GPS enforcement
|
| 117 |
+
│ │ └── ProcessingScreen.tsx # AI agent progress
|
| 118 |
+
│ ├── home/
|
| 119 |
+
│ │ └── HomeScreen.tsx # Dashboard
|
| 120 |
+
│ ├── issues/
|
| 121 |
+
│ │ ├── MyIssuesScreen.tsx # Issue list
|
| 122 |
+
│ │ └── IssueDetailScreen.tsx # Issue details
|
| 123 |
+
│ └── profile/
|
| 124 |
+
│ └── ProfileScreen.tsx # User profile
|
| 125 |
+
├── services/
|
| 126 |
+
│ ├── issueService.ts # API communication
|
| 127 |
+
│ └── locationService.ts # GPS utilities
|
| 128 |
+
├── theme/
|
| 129 |
+
│ └── index.ts # Colors, typography, spacing
|
| 130 |
+
└── types/
|
| 131 |
+
└── index.ts # TypeScript interfaces
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
## API Integration
|
| 135 |
+
|
| 136 |
+
The app connects to the Backend API at:
|
| 137 |
+
- Development: `http://10.0.2.2:8000` (Android emulator)
|
| 138 |
+
- Update `API_BASE_URL` in `src/config/supabase.ts` for other environments
|
| 139 |
+
|
| 140 |
+
## Backend Endpoints Used
|
| 141 |
+
|
| 142 |
+
- `POST /issues/stream` - Create issue with background processing
|
| 143 |
+
- `GET /issues` - List issues with pagination
|
| 144 |
+
- `GET /issues/{id}` - Get issue details
|
| 145 |
+
- `GET /flow/flow/{id}` - SSE stream for agent progress
|
| 146 |
+
|
| 147 |
+
## Building for Production
|
| 148 |
+
|
| 149 |
+
### EAS Build (Recommended)
|
| 150 |
+
```bash
|
| 151 |
+
npx eas build --platform all
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
### Local Build
|
| 155 |
+
```bash
|
| 156 |
+
npx expo run:android
|
| 157 |
+
npx expo run:ios
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
## License
|
| 161 |
+
|
| 162 |
+
Part of the Autonomous City Issue Resolution Agent project.
|
User/app.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"expo": {
|
| 3 |
+
"name": "City Issue Reporter",
|
| 4 |
+
"slug": "city-issue-reporter",
|
| 5 |
+
"version": "1.0.0",
|
| 6 |
+
"orientation": "portrait",
|
| 7 |
+
"icon": "./assets/icon.png",
|
| 8 |
+
"userInterfaceStyle": "dark",
|
| 9 |
+
"scheme": "cityissue",
|
| 10 |
+
"splash": {
|
| 11 |
+
"image": "./assets/splash-icon.png",
|
| 12 |
+
"resizeMode": "contain",
|
| 13 |
+
"backgroundColor": "#0F172A"
|
| 14 |
+
},
|
| 15 |
+
"assetBundlePatterns": [
|
| 16 |
+
"**/*"
|
| 17 |
+
],
|
| 18 |
+
"ios": {
|
| 19 |
+
"supportsTablet": true,
|
| 20 |
+
"bundleIdentifier": "com.cityissue.reporter",
|
| 21 |
+
"infoPlist": {
|
| 22 |
+
"NSCameraUsageDescription": "We need camera access to capture photos of city issues for reporting",
|
| 23 |
+
"NSLocationWhenInUseUsageDescription": "We need your location to accurately pinpoint reported issues",
|
| 24 |
+
"NSLocationAlwaysUsageDescription": "We need your location to accurately pinpoint reported issues"
|
| 25 |
+
}
|
| 26 |
+
},
|
| 27 |
+
"android": {
|
| 28 |
+
"adaptiveIcon": {
|
| 29 |
+
"foregroundImage": "./assets/adaptive-icon.png",
|
| 30 |
+
"backgroundColor": "#0F172A"
|
| 31 |
+
},
|
| 32 |
+
"package": "com.cityissue.reporter",
|
| 33 |
+
"usesCleartextTraffic": true,
|
| 34 |
+
"permissions": [
|
| 35 |
+
"android.permission.CAMERA",
|
| 36 |
+
"android.permission.ACCESS_FINE_LOCATION",
|
| 37 |
+
"android.permission.ACCESS_COARSE_LOCATION",
|
| 38 |
+
"android.permission.INTERNET"
|
| 39 |
+
]
|
| 40 |
+
},
|
| 41 |
+
"web": {
|
| 42 |
+
"favicon": "./assets/favicon.png"
|
| 43 |
+
},
|
| 44 |
+
"plugins": [
|
| 45 |
+
[
|
| 46 |
+
"expo-camera",
|
| 47 |
+
{
|
| 48 |
+
"cameraPermission": "Allow City Issue Reporter to access your camera to capture photos of issues."
|
| 49 |
+
}
|
| 50 |
+
],
|
| 51 |
+
[
|
| 52 |
+
"expo-location",
|
| 53 |
+
{
|
| 54 |
+
"locationAlwaysAndWhenInUsePermission": "Allow City Issue Reporter to use your location to accurately report issues."
|
| 55 |
+
}
|
| 56 |
+
],
|
| 57 |
+
"expo-secure-store",
|
| 58 |
+
"@react-native-google-signin/google-signin",
|
| 59 |
+
"expo-web-browser"
|
| 60 |
+
],
|
| 61 |
+
"extra": {
|
| 62 |
+
"eas": {
|
| 63 |
+
"projectId": "your-project-id"
|
| 64 |
+
},
|
| 65 |
+
"EXPO_PUBLIC_API_BASE_URL": "https://0xarchit-citytrack.hf.space",
|
| 66 |
+
"EXPO_PUBLIC_GOOGLE_CLIENT_ID": "524731582218-km42fbhaf8ef52lc5lf3vsmef0832rfk.apps.googleusercontent.com",
|
| 67 |
+
"EXPO_PUBLIC_SUPABASE_URL": "https://mqjshnnpnwrwkqxmgysh.supabase.co",
|
| 68 |
+
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1xanNobm5wbndyd2txeG1neXNoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjgxMjY1MDEsImV4cCI6MjA4MzcwMjUwMX0.BU2aiiBnHxzgEO6hFTv63JUH07sjQ6BzqhBkYA2CChs"
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
User/eas.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cli": {
|
| 3 |
+
"version": ">= 3.0.0"
|
| 4 |
+
},
|
| 5 |
+
"build": {
|
| 6 |
+
"development": {
|
| 7 |
+
"developmentClient": true,
|
| 8 |
+
"distribution": "internal"
|
| 9 |
+
},
|
| 10 |
+
"preview": {
|
| 11 |
+
"distribution": "internal"
|
| 12 |
+
},
|
| 13 |
+
"production": {
|
| 14 |
+
"android": {
|
| 15 |
+
"buildType": "apk",
|
| 16 |
+
"gradleCommand": ":app:assembleRelease",
|
| 17 |
+
"env": {
|
| 18 |
+
"EXPO_PUBLIC_API_BASE_URL": "https://0xarchit-citytrack.hf.space"
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
},
|
| 23 |
+
"submit": {
|
| 24 |
+
"production": {}
|
| 25 |
+
}
|
| 26 |
+
}
|
User/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { registerRootComponent } from 'expo';
|
| 2 |
+
|
| 3 |
+
import App from './App';
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
registerRootComponent(App);
|
User/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
User/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "user",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"main": "index.ts",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"start": "expo start",
|
| 7 |
+
"android": "expo run:android",
|
| 8 |
+
"ios": "expo run:ios",
|
| 9 |
+
"web": "expo start --web"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@expo/vector-icons": "^15.0.3",
|
| 13 |
+
"@react-native-async-storage/async-storage": "^2.2.0",
|
| 14 |
+
"@react-native-google-signin/google-signin": "^16.1.1",
|
| 15 |
+
"@react-navigation/bottom-tabs": "^7.9.1",
|
| 16 |
+
"@react-navigation/native": "^7.1.27",
|
| 17 |
+
"@react-navigation/native-stack": "^7.9.1",
|
| 18 |
+
"@supabase/supabase-js": "^2.90.1",
|
| 19 |
+
"expo": "~54.0.33",
|
| 20 |
+
"expo-auth-session": "~7.0.10",
|
| 21 |
+
"expo-camera": "~17.0.10",
|
| 22 |
+
"expo-constants": "~18.0.13",
|
| 23 |
+
"expo-crypto": "~15.0.8",
|
| 24 |
+
"expo-device": "~8.0.10",
|
| 25 |
+
"expo-image-picker": "~17.0.10",
|
| 26 |
+
"expo-linear-gradient": "~15.0.8",
|
| 27 |
+
"expo-location": "~19.0.8",
|
| 28 |
+
"expo-secure-store": "~15.0.8",
|
| 29 |
+
"expo-status-bar": "~3.0.9",
|
| 30 |
+
"expo-web-browser": "~15.0.10",
|
| 31 |
+
"react": "19.1.0",
|
| 32 |
+
"react-native": "0.81.5",
|
| 33 |
+
"react-native-gesture-handler": "~2.28.0",
|
| 34 |
+
"react-native-reanimated": "~4.1.1",
|
| 35 |
+
"react-native-safe-area-context": "~5.6.0",
|
| 36 |
+
"react-native-screens": "~4.16.0",
|
| 37 |
+
"react-native-sse": "^1.2.1"
|
| 38 |
+
},
|
| 39 |
+
"devDependencies": {
|
| 40 |
+
"@types/react": "~19.1.0",
|
| 41 |
+
"typescript": "~5.9.2"
|
| 42 |
+
},
|
| 43 |
+
"private": true
|
| 44 |
+
}
|
User/src/components/index.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { Button } from './ui/Button';
|
| 2 |
+
export { Card } from './ui/Card';
|
| 3 |
+
export { IssueCard } from './issues/IssueCard';
|
User/src/components/issues/IssueCard.tsx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { View, Text, StyleSheet, Image, TouchableOpacity } from 'react-native';
|
| 3 |
+
import { Ionicons } from '@expo/vector-icons';
|
| 4 |
+
import { Card } from '../ui/Card';
|
| 5 |
+
import { colors, spacing, typography, borderRadius } from '../../theme';
|
| 6 |
+
import { Issue } from '../../types';
|
| 7 |
+
|
| 8 |
+
interface IssueCardProps {
|
| 9 |
+
issue: Issue;
|
| 10 |
+
onPress?: () => void;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const priorityColors: Record<number, string> = {
|
| 14 |
+
1: colors.priority.critical,
|
| 15 |
+
2: colors.priority.high,
|
| 16 |
+
3: colors.priority.medium,
|
| 17 |
+
4: colors.priority.low,
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
const priorityLabels: Record<number, string> = {
|
| 21 |
+
1: 'CRITICAL',
|
| 22 |
+
2: 'HIGH',
|
| 23 |
+
3: 'MEDIUM',
|
| 24 |
+
4: 'LOW',
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
const stateColors: Record<string, string> = {
|
| 28 |
+
reported: colors.status.info,
|
| 29 |
+
validated: colors.accent.purple,
|
| 30 |
+
assigned: colors.accent.cyan,
|
| 31 |
+
in_progress: colors.status.warning,
|
| 32 |
+
resolved: colors.status.success,
|
| 33 |
+
closed: colors.text.tertiary,
|
| 34 |
+
rejected: colors.status.error,
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
export function IssueCard({ issue, onPress }: IssueCardProps) {
|
| 38 |
+
const priorityColor = issue.priority ? priorityColors[issue.priority] : colors.text.tertiary;
|
| 39 |
+
const priorityLabel = issue.priority ? priorityLabels[issue.priority] : 'N/A';
|
| 40 |
+
const stateColor = stateColors[issue.state] || colors.text.tertiary;
|
| 41 |
+
|
| 42 |
+
const formatDate = (dateStr: string) => {
|
| 43 |
+
const date = new Date(dateStr);
|
| 44 |
+
return date.toLocaleDateString('en-US', {
|
| 45 |
+
month: 'short',
|
| 46 |
+
day: 'numeric',
|
| 47 |
+
hour: '2-digit',
|
| 48 |
+
minute: '2-digit',
|
| 49 |
+
});
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
|
| 54 |
+
<Card variant="glass" style={styles.container}>
|
| 55 |
+
<View style={styles.header}>
|
| 56 |
+
{issue.annotated_urls[0] ? (
|
| 57 |
+
<Image source={{ uri: issue.annotated_urls[0] }} style={styles.thumbnail} />
|
| 58 |
+
) : issue.image_urls[0] ? (
|
| 59 |
+
<Image source={{ uri: issue.image_urls[0] }} style={styles.thumbnail} />
|
| 60 |
+
) : (
|
| 61 |
+
<View style={[styles.thumbnail, styles.placeholderImage]}>
|
| 62 |
+
<Ionicons name="camera" size={24} color={colors.text.tertiary} />
|
| 63 |
+
</View>
|
| 64 |
+
)}
|
| 65 |
+
|
| 66 |
+
<View style={styles.info}>
|
| 67 |
+
<View style={styles.badges}>
|
| 68 |
+
<View style={[styles.badge, { backgroundColor: priorityColor }]}>
|
| 69 |
+
<Text style={styles.badgeText}>{priorityLabel}</Text>
|
| 70 |
+
</View>
|
| 71 |
+
<View style={[styles.badge, styles.stateBadge, { borderColor: stateColor }]}>
|
| 72 |
+
<Text style={[styles.badgeText, { color: stateColor }]}>
|
| 73 |
+
{issue.state.toUpperCase().replace('_', ' ')}
|
| 74 |
+
</Text>
|
| 75 |
+
</View>
|
| 76 |
+
</View>
|
| 77 |
+
|
| 78 |
+
{issue.category ? (
|
| 79 |
+
<Text style={styles.category}>{issue.category}</Text>
|
| 80 |
+
) : null}
|
| 81 |
+
|
| 82 |
+
{issue.confidence !== undefined && issue.confidence !== null ? (
|
| 83 |
+
<Text style={styles.confidence}>
|
| 84 |
+
Confidence: {(issue.confidence * 100).toFixed(0)}%
|
| 85 |
+
</Text>
|
| 86 |
+
) : null}
|
| 87 |
+
</View>
|
| 88 |
+
</View>
|
| 89 |
+
|
| 90 |
+
{issue.description ? (
|
| 91 |
+
<Text style={styles.description} numberOfLines={2}>
|
| 92 |
+
{issue.description}
|
| 93 |
+
</Text>
|
| 94 |
+
) : null}
|
| 95 |
+
|
| 96 |
+
<View style={styles.footer}>
|
| 97 |
+
<Text style={styles.date}>{formatDate(issue.created_at)}</Text>
|
| 98 |
+
{issue.is_duplicate ? (
|
| 99 |
+
<View style={styles.duplicateBadge}>
|
| 100 |
+
<Text style={styles.duplicateText}>DUPLICATE</Text>
|
| 101 |
+
</View>
|
| 102 |
+
) : null}
|
| 103 |
+
</View>
|
| 104 |
+
</Card>
|
| 105 |
+
</TouchableOpacity>
|
| 106 |
+
);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const styles = StyleSheet.create({
|
| 110 |
+
container: {
|
| 111 |
+
marginBottom: spacing.md,
|
| 112 |
+
},
|
| 113 |
+
header: {
|
| 114 |
+
flexDirection: 'row',
|
| 115 |
+
},
|
| 116 |
+
thumbnail: {
|
| 117 |
+
width: 80,
|
| 118 |
+
height: 80,
|
| 119 |
+
borderRadius: borderRadius.md,
|
| 120 |
+
},
|
| 121 |
+
placeholderImage: {
|
| 122 |
+
backgroundColor: colors.background.tertiary,
|
| 123 |
+
alignItems: 'center',
|
| 124 |
+
justifyContent: 'center',
|
| 125 |
+
},
|
| 126 |
+
info: {
|
| 127 |
+
flex: 1,
|
| 128 |
+
marginLeft: spacing.md,
|
| 129 |
+
},
|
| 130 |
+
badges: {
|
| 131 |
+
flexDirection: 'row',
|
| 132 |
+
flexWrap: 'wrap',
|
| 133 |
+
gap: spacing.xs,
|
| 134 |
+
},
|
| 135 |
+
badge: {
|
| 136 |
+
paddingHorizontal: spacing.sm,
|
| 137 |
+
paddingVertical: spacing.xs,
|
| 138 |
+
borderRadius: borderRadius.sm,
|
| 139 |
+
},
|
| 140 |
+
stateBadge: {
|
| 141 |
+
backgroundColor: 'transparent',
|
| 142 |
+
borderWidth: 1,
|
| 143 |
+
},
|
| 144 |
+
badgeText: {
|
| 145 |
+
color: colors.text.primary,
|
| 146 |
+
fontSize: 10,
|
| 147 |
+
fontWeight: '600',
|
| 148 |
+
},
|
| 149 |
+
category: {
|
| 150 |
+
color: colors.text.primary,
|
| 151 |
+
fontSize: typography.body.fontSize,
|
| 152 |
+
fontWeight: '600',
|
| 153 |
+
marginTop: spacing.sm,
|
| 154 |
+
},
|
| 155 |
+
confidence: {
|
| 156 |
+
color: colors.text.secondary,
|
| 157 |
+
fontSize: typography.caption.fontSize,
|
| 158 |
+
marginTop: spacing.xs,
|
| 159 |
+
},
|
| 160 |
+
description: {
|
| 161 |
+
color: colors.text.secondary,
|
| 162 |
+
fontSize: typography.bodySmall.fontSize,
|
| 163 |
+
marginTop: spacing.md,
|
| 164 |
+
},
|
| 165 |
+
footer: {
|
| 166 |
+
flexDirection: 'row',
|
| 167 |
+
justifyContent: 'space-between',
|
| 168 |
+
alignItems: 'center',
|
| 169 |
+
marginTop: spacing.md,
|
| 170 |
+
paddingTop: spacing.md,
|
| 171 |
+
borderTopWidth: 1,
|
| 172 |
+
borderTopColor: colors.border.light,
|
| 173 |
+
},
|
| 174 |
+
date: {
|
| 175 |
+
color: colors.text.tertiary,
|
| 176 |
+
fontSize: typography.caption.fontSize,
|
| 177 |
+
},
|
| 178 |
+
duplicateBadge: {
|
| 179 |
+
backgroundColor: colors.status.warning,
|
| 180 |
+
paddingHorizontal: spacing.sm,
|
| 181 |
+
paddingVertical: spacing.xs,
|
| 182 |
+
borderRadius: borderRadius.sm,
|
| 183 |
+
},
|
| 184 |
+
duplicateText: {
|
| 185 |
+
color: colors.text.inverse,
|
| 186 |
+
fontSize: 10,
|
| 187 |
+
fontWeight: '600',
|
| 188 |
+
},
|
| 189 |
+
});
|
User/src/components/ui/Button.tsx
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { TouchableOpacity, Text, StyleSheet, ActivityIndicator, ViewStyle, TextStyle } from 'react-native';
|
| 3 |
+
import { LinearGradient } from 'expo-linear-gradient';
|
| 4 |
+
import { colors, borderRadius, typography, shadows, spacing } from '../../theme';
|
| 5 |
+
|
| 6 |
+
interface ButtonProps {
|
| 7 |
+
title: string;
|
| 8 |
+
onPress: () => void;
|
| 9 |
+
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
| 10 |
+
size?: 'sm' | 'md' | 'lg';
|
| 11 |
+
disabled?: boolean;
|
| 12 |
+
loading?: boolean;
|
| 13 |
+
icon?: React.ReactNode;
|
| 14 |
+
fullWidth?: boolean;
|
| 15 |
+
style?: ViewStyle;
|
| 16 |
+
textStyle?: TextStyle;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function Button({
|
| 20 |
+
title,
|
| 21 |
+
onPress,
|
| 22 |
+
variant = 'primary',
|
| 23 |
+
size = 'md',
|
| 24 |
+
disabled = false,
|
| 25 |
+
loading = false,
|
| 26 |
+
icon,
|
| 27 |
+
fullWidth = false,
|
| 28 |
+
style,
|
| 29 |
+
textStyle,
|
| 30 |
+
}: ButtonProps) {
|
| 31 |
+
const sizeStyles = {
|
| 32 |
+
sm: { paddingVertical: spacing.sm, paddingHorizontal: spacing.md },
|
| 33 |
+
md: { paddingVertical: spacing.md, paddingHorizontal: spacing.lg },
|
| 34 |
+
lg: { paddingVertical: spacing.lg, paddingHorizontal: spacing.xl },
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
const textSizeStyles = {
|
| 38 |
+
sm: { fontSize: 14 },
|
| 39 |
+
md: { fontSize: 16 },
|
| 40 |
+
lg: { fontSize: 18 },
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
const isDisabled = disabled || loading;
|
| 44 |
+
|
| 45 |
+
const renderContent = () => (
|
| 46 |
+
<>
|
| 47 |
+
{loading ? (
|
| 48 |
+
<ActivityIndicator color={variant === 'outline' ? colors.primary.main : colors.primary.contrast} />
|
| 49 |
+
) : (
|
| 50 |
+
<>
|
| 51 |
+
{icon}
|
| 52 |
+
<Text
|
| 53 |
+
style={[
|
| 54 |
+
styles.text,
|
| 55 |
+
textSizeStyles[size],
|
| 56 |
+
variant === 'outline' && styles.outlineText,
|
| 57 |
+
variant === 'ghost' && styles.ghostText,
|
| 58 |
+
icon ? styles.textWithIcon : undefined,
|
| 59 |
+
textStyle,
|
| 60 |
+
]}
|
| 61 |
+
>
|
| 62 |
+
{title}
|
| 63 |
+
</Text>
|
| 64 |
+
</>
|
| 65 |
+
)}
|
| 66 |
+
</>
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
if (variant === 'primary') {
|
| 70 |
+
return (
|
| 71 |
+
<TouchableOpacity
|
| 72 |
+
onPress={onPress}
|
| 73 |
+
disabled={isDisabled}
|
| 74 |
+
style={[fullWidth && styles.fullWidth, style]}
|
| 75 |
+
activeOpacity={0.8}
|
| 76 |
+
>
|
| 77 |
+
<LinearGradient
|
| 78 |
+
colors={isDisabled ? ['#475569', '#334155'] : [colors.primary.light, colors.primary.dark]}
|
| 79 |
+
start={{ x: 0, y: 0 }}
|
| 80 |
+
end={{ x: 1, y: 1 }}
|
| 81 |
+
style={[
|
| 82 |
+
styles.container,
|
| 83 |
+
sizeStyles[size],
|
| 84 |
+
shadows.md,
|
| 85 |
+
isDisabled && styles.disabled,
|
| 86 |
+
]}
|
| 87 |
+
>
|
| 88 |
+
{renderContent()}
|
| 89 |
+
</LinearGradient>
|
| 90 |
+
</TouchableOpacity>
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
return (
|
| 95 |
+
<TouchableOpacity
|
| 96 |
+
onPress={onPress}
|
| 97 |
+
disabled={isDisabled}
|
| 98 |
+
activeOpacity={0.7}
|
| 99 |
+
style={[
|
| 100 |
+
styles.container,
|
| 101 |
+
sizeStyles[size],
|
| 102 |
+
variant === 'secondary' && styles.secondary,
|
| 103 |
+
variant === 'outline' && styles.outline,
|
| 104 |
+
variant === 'ghost' && styles.ghost,
|
| 105 |
+
isDisabled && styles.disabled,
|
| 106 |
+
fullWidth && styles.fullWidth,
|
| 107 |
+
style,
|
| 108 |
+
]}
|
| 109 |
+
>
|
| 110 |
+
{renderContent()}
|
| 111 |
+
</TouchableOpacity>
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const styles = StyleSheet.create({
|
| 116 |
+
container: {
|
| 117 |
+
flexDirection: 'row',
|
| 118 |
+
alignItems: 'center',
|
| 119 |
+
justifyContent: 'center',
|
| 120 |
+
borderRadius: borderRadius.lg,
|
| 121 |
+
},
|
| 122 |
+
text: {
|
| 123 |
+
color: colors.primary.contrast,
|
| 124 |
+
fontWeight: typography.button.fontWeight,
|
| 125 |
+
},
|
| 126 |
+
textWithIcon: {
|
| 127 |
+
marginLeft: spacing.sm,
|
| 128 |
+
},
|
| 129 |
+
secondary: {
|
| 130 |
+
backgroundColor: colors.secondary.main,
|
| 131 |
+
},
|
| 132 |
+
outline: {
|
| 133 |
+
backgroundColor: 'transparent',
|
| 134 |
+
borderWidth: 2,
|
| 135 |
+
borderColor: colors.primary.main,
|
| 136 |
+
},
|
| 137 |
+
outlineText: {
|
| 138 |
+
color: colors.primary.main,
|
| 139 |
+
},
|
| 140 |
+
ghost: {
|
| 141 |
+
backgroundColor: 'transparent',
|
| 142 |
+
},
|
| 143 |
+
ghostText: {
|
| 144 |
+
color: colors.text.primary,
|
| 145 |
+
},
|
| 146 |
+
disabled: {
|
| 147 |
+
opacity: 0.5,
|
| 148 |
+
},
|
| 149 |
+
fullWidth: {
|
| 150 |
+
width: '100%',
|
| 151 |
+
},
|
| 152 |
+
});
|
User/src/components/ui/Card.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { View, StyleSheet, ViewStyle } from 'react-native';
|
| 3 |
+
import { LinearGradient } from 'expo-linear-gradient';
|
| 4 |
+
import { colors, borderRadius, shadows } from '../../theme';
|
| 5 |
+
|
| 6 |
+
interface CardProps {
|
| 7 |
+
children: React.ReactNode;
|
| 8 |
+
variant?: 'default' | 'glass' | 'gradient';
|
| 9 |
+
style?: ViewStyle;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function Card({ children, variant = 'default', style }: CardProps) {
|
| 13 |
+
if (variant === 'gradient') {
|
| 14 |
+
return (
|
| 15 |
+
<LinearGradient
|
| 16 |
+
colors={[colors.background.secondary, colors.background.tertiary]}
|
| 17 |
+
start={{ x: 0, y: 0 }}
|
| 18 |
+
end={{ x: 1, y: 1 }}
|
| 19 |
+
style={[styles.container, styles.gradient, shadows.md, style]}
|
| 20 |
+
>
|
| 21 |
+
{children}
|
| 22 |
+
</LinearGradient>
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
if (variant === 'glass') {
|
| 27 |
+
return (
|
| 28 |
+
<View style={[styles.container, styles.glass, style]}>
|
| 29 |
+
{children}
|
| 30 |
+
</View>
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<View style={[styles.container, styles.default, shadows.sm, style]}>
|
| 36 |
+
{children}
|
| 37 |
+
</View>
|
| 38 |
+
);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const styles = StyleSheet.create({
|
| 42 |
+
container: {
|
| 43 |
+
borderRadius: borderRadius.lg,
|
| 44 |
+
padding: 16,
|
| 45 |
+
},
|
| 46 |
+
default: {
|
| 47 |
+
backgroundColor: colors.background.card,
|
| 48 |
+
borderWidth: 1,
|
| 49 |
+
borderColor: colors.border.light,
|
| 50 |
+
},
|
| 51 |
+
glass: {
|
| 52 |
+
backgroundColor: colors.glass.background,
|
| 53 |
+
borderWidth: 1,
|
| 54 |
+
borderColor: colors.glass.border,
|
| 55 |
+
},
|
| 56 |
+
gradient: {
|
| 57 |
+
borderWidth: 1,
|
| 58 |
+
borderColor: colors.border.accent,
|
| 59 |
+
},
|
| 60 |
+
});
|
User/src/components/ui/Loader.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { View, ActivityIndicator, StyleSheet, ViewStyle } from 'react-native';
|
| 3 |
+
import { colors } from '../../theme';
|
| 4 |
+
|
| 5 |
+
interface LoaderProps {
|
| 6 |
+
size?: 'small' | 'large';
|
| 7 |
+
color?: string;
|
| 8 |
+
style?: ViewStyle;
|
| 9 |
+
fullScreen?: boolean;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export const Loader: React.FC<LoaderProps> = ({
|
| 13 |
+
size = 'small',
|
| 14 |
+
color = colors.primary.main,
|
| 15 |
+
style,
|
| 16 |
+
fullScreen = false
|
| 17 |
+
}) => {
|
| 18 |
+
if (fullScreen) {
|
| 19 |
+
return (
|
| 20 |
+
<View style={[styles.fullScreen, style]}>
|
| 21 |
+
<ActivityIndicator size={size} color={color} />
|
| 22 |
+
</View>
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<View style={[styles.container, style]}>
|
| 28 |
+
<ActivityIndicator size={size} color={color} />
|
| 29 |
+
</View>
|
| 30 |
+
);
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const styles = StyleSheet.create({
|
| 34 |
+
container: {
|
| 35 |
+
justifyContent: 'center',
|
| 36 |
+
alignItems: 'center',
|
| 37 |
+
padding: 10,
|
| 38 |
+
},
|
| 39 |
+
fullScreen: {
|
| 40 |
+
...StyleSheet.absoluteFillObject,
|
| 41 |
+
backgroundColor: 'rgba(255,255,255,0.8)',
|
| 42 |
+
justifyContent: 'center',
|
| 43 |
+
alignItems: 'center',
|
| 44 |
+
zIndex: 999,
|
| 45 |
+
},
|
| 46 |
+
});
|
User/src/components/ui/Skeleton.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect } from 'react';
|
| 2 |
+
import { View, StyleSheet, ViewStyle, DimensionValue } from 'react-native';
|
| 3 |
+
import Animated, {
|
| 4 |
+
useSharedValue,
|
| 5 |
+
useAnimatedStyle,
|
| 6 |
+
withRepeat,
|
| 7 |
+
withTiming,
|
| 8 |
+
withSequence
|
| 9 |
+
} from 'react-native-reanimated';
|
| 10 |
+
|
| 11 |
+
interface SkeletonProps {
|
| 12 |
+
width?: DimensionValue;
|
| 13 |
+
height?: DimensionValue;
|
| 14 |
+
style?: ViewStyle;
|
| 15 |
+
borderRadius?: number;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export const Skeleton: React.FC<SkeletonProps> = ({
|
| 19 |
+
width = '100%',
|
| 20 |
+
height = 20,
|
| 21 |
+
style,
|
| 22 |
+
borderRadius = 4
|
| 23 |
+
}) => {
|
| 24 |
+
const opacity = useSharedValue(0.3);
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
opacity.value = withRepeat(
|
| 28 |
+
withSequence(
|
| 29 |
+
withTiming(0.3, { duration: 1000 }),
|
| 30 |
+
withTiming(0.7, { duration: 1000 })
|
| 31 |
+
),
|
| 32 |
+
-1,
|
| 33 |
+
true
|
| 34 |
+
);
|
| 35 |
+
}, []);
|
| 36 |
+
|
| 37 |
+
const animatedStyle = useAnimatedStyle(() => ({
|
| 38 |
+
opacity: opacity.value,
|
| 39 |
+
}));
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<Animated.View
|
| 43 |
+
style={[
|
| 44 |
+
styles.skeleton,
|
| 45 |
+
{ width, height, borderRadius },
|
| 46 |
+
style,
|
| 47 |
+
animatedStyle,
|
| 48 |
+
]}
|
| 49 |
+
/>
|
| 50 |
+
);
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const styles = StyleSheet.create({
|
| 54 |
+
skeleton: {
|
| 55 |
+
backgroundColor: '#E1E9EE',
|
| 56 |
+
},
|
| 57 |
+
});
|
User/src/config/env.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Constants from "expo-constants";
|
| 2 |
+
|
| 3 |
+
const extra = Constants.expoConfig?.extra ?? {};
|
| 4 |
+
|
| 5 |
+
export const config = {
|
| 6 |
+
API_BASE_URL:
|
| 7 |
+
extra.API_BASE_URL ||
|
| 8 |
+
process.env.EXPO_PUBLIC_API_BASE_URL ||
|
| 9 |
+
"https://0xarchit-citytrack.hf.space",
|
| 10 |
+
SUPABASE_URL: extra.SUPABASE_URL || process.env.EXPO_PUBLIC_SUPABASE_URL,
|
| 11 |
+
SUPABASE_ANON_KEY:
|
| 12 |
+
extra.SUPABASE_ANON_KEY || process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY,
|
| 13 |
+
GOOGLE_CLIENT_ID:
|
| 14 |
+
extra.EXPO_PUBLIC_GOOGLE_CLIENT_ID ||
|
| 15 |
+
process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID ||
|
| 16 |
+
"",
|
| 17 |
+
};
|
User/src/config/supabase.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
| 2 |
+
import { createClient } from "@supabase/supabase-js";
|
| 3 |
+
import Constants from "expo-constants";
|
| 4 |
+
|
| 5 |
+
type ExtraConfig = Record<string, unknown> | undefined;
|
| 6 |
+
|
| 7 |
+
const extra =
|
| 8 |
+
(Constants.expoConfig?.extra as ExtraConfig) ??
|
| 9 |
+
((Constants as any).manifest?.extra as ExtraConfig);
|
| 10 |
+
|
| 11 |
+
const pickString = (value: unknown): string =>
|
| 12 |
+
typeof value === "string" ? value : "";
|
| 13 |
+
|
| 14 |
+
const SUPABASE_URL =
|
| 15 |
+
process.env.EXPO_PUBLIC_SUPABASE_URL ??
|
| 16 |
+
pickString(extra?.EXPO_PUBLIC_SUPABASE_URL) ??
|
| 17 |
+
pickString(extra?.SUPABASE_URL);
|
| 18 |
+
|
| 19 |
+
const SUPABASE_ANON_KEY =
|
| 20 |
+
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ??
|
| 21 |
+
pickString(extra?.EXPO_PUBLIC_SUPABASE_ANON_KEY) ??
|
| 22 |
+
pickString(extra?.SUPABASE_ANON_KEY);
|
| 23 |
+
|
| 24 |
+
export const API_BASE_URL =
|
| 25 |
+
process.env.EXPO_PUBLIC_API_BASE_URL ??
|
| 26 |
+
process.env.EXPO_PUBLIC_API_URL ??
|
| 27 |
+
pickString(extra?.EXPO_PUBLIC_API_BASE_URL) ??
|
| 28 |
+
pickString(extra?.EXPO_PUBLIC_API_URL) ??
|
| 29 |
+
pickString(extra?.API_BASE_URL);
|
| 30 |
+
|
| 31 |
+
export const CONFIG_ERROR =
|
| 32 |
+
!SUPABASE_URL || !SUPABASE_ANON_KEY
|
| 33 |
+
? "Missing Supabase configuration"
|
| 34 |
+
: !API_BASE_URL
|
| 35 |
+
? "Missing API base URL configuration"
|
| 36 |
+
: null;
|
| 37 |
+
|
| 38 |
+
export const supabase = CONFIG_ERROR
|
| 39 |
+
? null
|
| 40 |
+
: createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
| 41 |
+
auth: {
|
| 42 |
+
storage: AsyncStorage,
|
| 43 |
+
autoRefreshToken: true,
|
| 44 |
+
persistSession: true,
|
| 45 |
+
detectSessionInUrl: false,
|
| 46 |
+
},
|
| 47 |
+
});
|
User/src/context/AuthContext.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, {
|
| 2 |
+
createContext,
|
| 3 |
+
useContext,
|
| 4 |
+
useEffect,
|
| 5 |
+
useState,
|
| 6 |
+
ReactNode,
|
| 7 |
+
} from "react";
|
| 8 |
+
import { Session, User } from "@supabase/supabase-js";
|
| 9 |
+
import { CONFIG_ERROR, supabase } from "../config/supabase";
|
| 10 |
+
import { config } from "../config/env";
|
| 11 |
+
import {
|
| 12 |
+
GoogleSignin,
|
| 13 |
+
statusCodes,
|
| 14 |
+
isErrorWithCode,
|
| 15 |
+
} from "../lib/googleAuthSafe";
|
| 16 |
+
import { Alert } from "react-native";
|
| 17 |
+
|
| 18 |
+
interface AuthContextType {
|
| 19 |
+
user: User | null;
|
| 20 |
+
session: Session | null;
|
| 21 |
+
loading: boolean;
|
| 22 |
+
isDevMode: boolean;
|
| 23 |
+
configError: string | null;
|
| 24 |
+
signInWithGoogle: () => Promise<void>;
|
| 25 |
+
continueWithDevMode: () => void;
|
| 26 |
+
signOut: () => Promise<void>;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
| 30 |
+
|
| 31 |
+
interface AuthProviderProps {
|
| 32 |
+
children: ReactNode;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function AuthProvider({ children }: AuthProviderProps) {
|
| 36 |
+
const [user, setUser] = useState<User | null>(null);
|
| 37 |
+
const [session, setSession] = useState<Session | null>(null);
|
| 38 |
+
const [loading, setLoading] = useState(true);
|
| 39 |
+
const [isDevMode, setIsDevMode] = useState(false);
|
| 40 |
+
const [configError] = useState<string | null>(CONFIG_ERROR);
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
if (!supabase) {
|
| 44 |
+
setLoading(false);
|
| 45 |
+
return;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
try {
|
| 49 |
+
GoogleSignin.configure({
|
| 50 |
+
webClientId: config.GOOGLE_CLIENT_ID,
|
| 51 |
+
scopes: ["email", "profile"],
|
| 52 |
+
offlineAccess: true,
|
| 53 |
+
});
|
| 54 |
+
} catch (e: any) {
|
| 55 |
+
if (e.message?.includes("RNGoogleSignin")) {
|
| 56 |
+
console.warn(
|
| 57 |
+
"Google Sign-In not supported in Expo Go. Use a development build or 'Dev Mode'.",
|
| 58 |
+
);
|
| 59 |
+
} else {
|
| 60 |
+
console.error("Google Sign-In config error:", e);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
supabase.auth.getSession().then(({ data: { session } }) => {
|
| 65 |
+
setSession(session);
|
| 66 |
+
setUser(session?.user ?? null);
|
| 67 |
+
setLoading(false);
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
const {
|
| 71 |
+
data: { subscription },
|
| 72 |
+
} = supabase.auth.onAuthStateChange((_event, session) => {
|
| 73 |
+
setSession(session);
|
| 74 |
+
setUser(session?.user ?? null);
|
| 75 |
+
setLoading(false);
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
return () => {
|
| 79 |
+
subscription.unsubscribe();
|
| 80 |
+
};
|
| 81 |
+
}, []);
|
| 82 |
+
|
| 83 |
+
const signInWithGoogle = async () => {
|
| 84 |
+
try {
|
| 85 |
+
if (!supabase) {
|
| 86 |
+
Alert.alert("Configuration Error", "Supabase is not configured.");
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
setLoading(true);
|
| 91 |
+
|
| 92 |
+
// 1. Check Play Services (Android)
|
| 93 |
+
try {
|
| 94 |
+
await GoogleSignin.hasPlayServices();
|
| 95 |
+
} catch (e: any) {
|
| 96 |
+
if (e.message?.includes("RNGoogleSignin")) {
|
| 97 |
+
Alert.alert(
|
| 98 |
+
"Expo Go Detected",
|
| 99 |
+
"Native Google Sign-In is not supported in Expo Go. Please use 'Dev Mode' or build a development client.",
|
| 100 |
+
);
|
| 101 |
+
setLoading(false);
|
| 102 |
+
return;
|
| 103 |
+
}
|
| 104 |
+
throw e;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// 2. Native Sign In
|
| 108 |
+
const userInfo = await GoogleSignin.signIn();
|
| 109 |
+
|
| 110 |
+
// 3. Get ID Token
|
| 111 |
+
if (userInfo.data?.idToken) {
|
| 112 |
+
const { data, error } = await supabase.auth.signInWithIdToken({
|
| 113 |
+
provider: "google",
|
| 114 |
+
token: userInfo.data.idToken,
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
if (error) throw error;
|
| 118 |
+
|
| 119 |
+
// Critical: Update state immediately to trigger UI refresh
|
| 120 |
+
if (data.session) {
|
| 121 |
+
setSession(data.session);
|
| 122 |
+
setUser(data.session.user);
|
| 123 |
+
}
|
| 124 |
+
} else {
|
| 125 |
+
throw new Error("No ID token returned from Google Sign-In");
|
| 126 |
+
}
|
| 127 |
+
} catch (error: any) {
|
| 128 |
+
if (isErrorWithCode(error)) {
|
| 129 |
+
switch (error.code) {
|
| 130 |
+
case statusCodes.SIGN_IN_CANCELLED:
|
| 131 |
+
console.log("User cancelled the login flow");
|
| 132 |
+
break;
|
| 133 |
+
case statusCodes.IN_PROGRESS:
|
| 134 |
+
console.log("Sign in is in progress");
|
| 135 |
+
break;
|
| 136 |
+
case statusCodes.PLAY_SERVICES_NOT_AVAILABLE:
|
| 137 |
+
Alert.alert(
|
| 138 |
+
"Error",
|
| 139 |
+
"Google Play Services not available or outdated.",
|
| 140 |
+
);
|
| 141 |
+
break;
|
| 142 |
+
default:
|
| 143 |
+
console.error("Google Sign-In Error:", error);
|
| 144 |
+
Alert.alert("Google Sign-In Error", error.message);
|
| 145 |
+
}
|
| 146 |
+
} else {
|
| 147 |
+
console.error("An error occurred:", error);
|
| 148 |
+
Alert.alert("Sign-In Failed", error.message || "Unknown error");
|
| 149 |
+
}
|
| 150 |
+
} finally {
|
| 151 |
+
setLoading(false);
|
| 152 |
+
}
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
const continueWithDevMode = () => {
|
| 156 |
+
const devUser: User = {
|
| 157 |
+
id: "dev-user-123",
|
| 158 |
+
email: "dev@citytracker.local",
|
| 159 |
+
app_metadata: {},
|
| 160 |
+
user_metadata: { full_name: "Dev User" },
|
| 161 |
+
aud: "authenticated",
|
| 162 |
+
created_at: new Date().toISOString(),
|
| 163 |
+
};
|
| 164 |
+
setUser(devUser);
|
| 165 |
+
setIsDevMode(true);
|
| 166 |
+
setLoading(false);
|
| 167 |
+
};
|
| 168 |
+
|
| 169 |
+
const signOut = async () => {
|
| 170 |
+
try {
|
| 171 |
+
setLoading(true);
|
| 172 |
+
|
| 173 |
+
// Sign out from Google Native SDK first
|
| 174 |
+
try {
|
| 175 |
+
await GoogleSignin.signOut();
|
| 176 |
+
} catch (e) {
|
| 177 |
+
console.warn("Google Sign-Out error (ignoring):", e);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
if (!isDevMode && supabase) {
|
| 181 |
+
await supabase.auth.signOut();
|
| 182 |
+
}
|
| 183 |
+
setUser(null);
|
| 184 |
+
setSession(null);
|
| 185 |
+
setIsDevMode(false);
|
| 186 |
+
} catch (error) {
|
| 187 |
+
console.error("Sign out error:", error);
|
| 188 |
+
} finally {
|
| 189 |
+
setLoading(false);
|
| 190 |
+
}
|
| 191 |
+
};
|
| 192 |
+
|
| 193 |
+
return (
|
| 194 |
+
<AuthContext.Provider
|
| 195 |
+
value={{
|
| 196 |
+
user,
|
| 197 |
+
session,
|
| 198 |
+
loading,
|
| 199 |
+
isDevMode,
|
| 200 |
+
configError,
|
| 201 |
+
signInWithGoogle,
|
| 202 |
+
continueWithDevMode,
|
| 203 |
+
signOut,
|
| 204 |
+
}}
|
| 205 |
+
>
|
| 206 |
+
{children}
|
| 207 |
+
</AuthContext.Provider>
|
| 208 |
+
);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
export function useAuth() {
|
| 212 |
+
const context = useContext(AuthContext);
|
| 213 |
+
if (context === undefined) {
|
| 214 |
+
throw new Error("useAuth must be used within an AuthProvider");
|
| 215 |
+
}
|
| 216 |
+
return context;
|
| 217 |
+
}
|
User/src/lib/googleAuthSafe.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Alert } from "react-native";
|
| 2 |
+
|
| 3 |
+
// Mock objects for when the module is missing
|
| 4 |
+
const mockGoogleSignin = {
|
| 5 |
+
configure: (config?: any) => {
|
| 6 |
+
console.warn("Google Sign-In: Mock configure called (Module not found)");
|
| 7 |
+
},
|
| 8 |
+
hasPlayServices: async () => {
|
| 9 |
+
console.warn("Google Sign-In: Mock hasPlayServices called");
|
| 10 |
+
return Promise.resolve(true);
|
| 11 |
+
},
|
| 12 |
+
signIn: async () => {
|
| 13 |
+
Alert.alert(
|
| 14 |
+
"Not Supported in Expo Go",
|
| 15 |
+
"Native Google Sign-In requires a Development Build. Please use 'Dev Mode' or build a custom client."
|
| 16 |
+
);
|
| 17 |
+
throw new Error("RNGoogleSignin not found");
|
| 18 |
+
},
|
| 19 |
+
signOut: async () => {
|
| 20 |
+
console.warn("Google Sign-In: Mock signOut called");
|
| 21 |
+
return Promise.resolve();
|
| 22 |
+
},
|
| 23 |
+
getTokens: async () => {
|
| 24 |
+
return Promise.reject(new Error("RNGoogleSignin not found"));
|
| 25 |
+
}
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const mockStatusCodes = {
|
| 29 |
+
SIGN_IN_CANCELLED: 'SIGN_IN_CANCELLED',
|
| 30 |
+
IN_PROGRESS: 'IN_PROGRESS',
|
| 31 |
+
PLAY_SERVICES_NOT_AVAILABLE: 'PLAY_SERVICES_NOT_AVAILABLE',
|
| 32 |
+
SIGN_IN_REQUIRED: 'SIGN_IN_REQUIRED',
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
// Safe implementation
|
| 36 |
+
let GoogleSignin: any = mockGoogleSignin;
|
| 37 |
+
let statusCodes = mockStatusCodes;
|
| 38 |
+
let isErrorWithCode = (error: any): boolean => false;
|
| 39 |
+
|
| 40 |
+
try {
|
| 41 |
+
// Try to require the native module
|
| 42 |
+
const nativeModule = require("@react-native-google-signin/google-signin");
|
| 43 |
+
|
| 44 |
+
// If successful, export the real implementations
|
| 45 |
+
GoogleSignin = nativeModule.GoogleSignin;
|
| 46 |
+
statusCodes = nativeModule.statusCodes;
|
| 47 |
+
isErrorWithCode = nativeModule.isErrorWithCode;
|
| 48 |
+
} catch (error) {
|
| 49 |
+
console.log("SafeGoogleAuth: Native Google Sign-In module not found (running in Expo Go?)");
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export { GoogleSignin, statusCodes, isErrorWithCode };
|
User/src/navigation/AppNavigator.tsx
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { NavigationContainer } from '@react-navigation/native';
|
| 3 |
+
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
| 4 |
+
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
| 5 |
+
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
| 6 |
+
import { Ionicons } from '@expo/vector-icons';
|
| 7 |
+
|
| 8 |
+
import { useAuth } from '../context/AuthContext';
|
| 9 |
+
import { colors, borderRadius } from '../theme';
|
| 10 |
+
|
| 11 |
+
import { LoginScreen } from '../screens/auth/LoginScreen';
|
| 12 |
+
import { HomeScreen } from '../screens/home/HomeScreen';
|
| 13 |
+
import { CaptureScreen } from '../screens/capture/CaptureScreen';
|
| 14 |
+
import { ProcessingScreen } from '../screens/capture/ProcessingScreen';
|
| 15 |
+
import { MyIssuesScreen } from '../screens/issues/MyIssuesScreen';
|
| 16 |
+
import { IssueDetailScreen } from '../screens/issues/IssueDetailScreen';
|
| 17 |
+
import { ProfileScreen } from '../screens/profile/ProfileScreen';
|
| 18 |
+
|
| 19 |
+
const Stack = createNativeStackNavigator();
|
| 20 |
+
const Tab = createBottomTabNavigator();
|
| 21 |
+
|
| 22 |
+
function MainTabs() {
|
| 23 |
+
return (
|
| 24 |
+
<Tab.Navigator
|
| 25 |
+
screenOptions={{
|
| 26 |
+
headerShown: false,
|
| 27 |
+
tabBarStyle: styles.tabBar,
|
| 28 |
+
tabBarActiveTintColor: colors.primary.main,
|
| 29 |
+
tabBarInactiveTintColor: colors.text.tertiary,
|
| 30 |
+
tabBarLabelStyle: styles.tabBarLabel,
|
| 31 |
+
}}
|
| 32 |
+
>
|
| 33 |
+
<Tab.Screen
|
| 34 |
+
name="Home"
|
| 35 |
+
component={HomeScreen}
|
| 36 |
+
options={{
|
| 37 |
+
tabBarIcon: ({ focused, color }) => (
|
| 38 |
+
<Ionicons
|
| 39 |
+
name={focused ? 'home' : 'home-outline'}
|
| 40 |
+
size={24}
|
| 41 |
+
color={color}
|
| 42 |
+
/>
|
| 43 |
+
),
|
| 44 |
+
}}
|
| 45 |
+
/>
|
| 46 |
+
<Tab.Screen
|
| 47 |
+
name="MyIssues"
|
| 48 |
+
component={MyIssuesScreen}
|
| 49 |
+
options={{
|
| 50 |
+
title: 'Reports',
|
| 51 |
+
tabBarIcon: ({ focused, color }) => (
|
| 52 |
+
<Ionicons
|
| 53 |
+
name={focused ? 'documents' : 'documents-outline'}
|
| 54 |
+
size={24}
|
| 55 |
+
color={color}
|
| 56 |
+
/>
|
| 57 |
+
),
|
| 58 |
+
}}
|
| 59 |
+
/>
|
| 60 |
+
<Tab.Screen
|
| 61 |
+
name="Profile"
|
| 62 |
+
component={ProfileScreen}
|
| 63 |
+
options={{
|
| 64 |
+
tabBarIcon: ({ focused, color }) => (
|
| 65 |
+
<Ionicons
|
| 66 |
+
name={focused ? 'person' : 'person-outline'}
|
| 67 |
+
size={24}
|
| 68 |
+
color={color}
|
| 69 |
+
/>
|
| 70 |
+
),
|
| 71 |
+
}}
|
| 72 |
+
/>
|
| 73 |
+
</Tab.Navigator>
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function AuthStack() {
|
| 78 |
+
return (
|
| 79 |
+
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
| 80 |
+
<Stack.Screen name="Login" component={LoginScreen} />
|
| 81 |
+
</Stack.Navigator>
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function AppStack() {
|
| 86 |
+
return (
|
| 87 |
+
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
| 88 |
+
<Stack.Screen name="MainTabs" component={MainTabs} />
|
| 89 |
+
<Stack.Screen
|
| 90 |
+
name="Capture"
|
| 91 |
+
component={CaptureScreen}
|
| 92 |
+
options={{ animation: 'slide_from_bottom' }}
|
| 93 |
+
/>
|
| 94 |
+
<Stack.Screen
|
| 95 |
+
name="Processing"
|
| 96 |
+
component={ProcessingScreen}
|
| 97 |
+
options={{ animation: 'fade' }}
|
| 98 |
+
/>
|
| 99 |
+
<Stack.Screen
|
| 100 |
+
name="IssueDetail"
|
| 101 |
+
component={IssueDetailScreen}
|
| 102 |
+
options={{ animation: 'slide_from_right' }}
|
| 103 |
+
/>
|
| 104 |
+
</Stack.Navigator>
|
| 105 |
+
);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
export function AppNavigator() {
|
| 109 |
+
const { user, loading } = useAuth();
|
| 110 |
+
|
| 111 |
+
if (loading) {
|
| 112 |
+
return (
|
| 113 |
+
<View style={styles.loadingContainer}>
|
| 114 |
+
<Ionicons name="business" size={64} color={colors.primary.main} />
|
| 115 |
+
<ActivityIndicator size="large" color={colors.primary.main} style={styles.spinner} />
|
| 116 |
+
<Text style={styles.loadingText}>Loading...</Text>
|
| 117 |
+
</View>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
return (
|
| 122 |
+
<NavigationContainer>
|
| 123 |
+
{user ? <AppStack /> : <AuthStack />}
|
| 124 |
+
</NavigationContainer>
|
| 125 |
+
);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const styles = StyleSheet.create({
|
| 129 |
+
loadingContainer: {
|
| 130 |
+
flex: 1,
|
| 131 |
+
backgroundColor: colors.background.primary,
|
| 132 |
+
justifyContent: 'center',
|
| 133 |
+
alignItems: 'center',
|
| 134 |
+
},
|
| 135 |
+
spinner: {
|
| 136 |
+
marginTop: 24,
|
| 137 |
+
},
|
| 138 |
+
loadingText: {
|
| 139 |
+
color: colors.text.secondary,
|
| 140 |
+
fontSize: 16,
|
| 141 |
+
marginTop: 16,
|
| 142 |
+
},
|
| 143 |
+
tabBar: {
|
| 144 |
+
backgroundColor: colors.background.secondary,
|
| 145 |
+
borderTopWidth: 1,
|
| 146 |
+
borderTopColor: colors.border.light,
|
| 147 |
+
height: 80,
|
| 148 |
+
paddingTop: 8,
|
| 149 |
+
paddingBottom: 20,
|
| 150 |
+
},
|
| 151 |
+
tabBarLabel: {
|
| 152 |
+
fontSize: 12,
|
| 153 |
+
fontWeight: '500',
|
| 154 |
+
marginTop: 4,
|
| 155 |
+
},
|
| 156 |
+
});
|
User/src/screens/auth/LoginScreen.tsx
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import {
|
| 3 |
+
View,
|
| 4 |
+
Text,
|
| 5 |
+
StyleSheet,
|
| 6 |
+
Dimensions,
|
| 7 |
+
StatusBar,
|
| 8 |
+
Platform,
|
| 9 |
+
} from "react-native";
|
| 10 |
+
import { LinearGradient } from "expo-linear-gradient";
|
| 11 |
+
import { Ionicons } from "@expo/vector-icons";
|
| 12 |
+
import { Button } from "../../components/ui/Button";
|
| 13 |
+
import { useAuth } from "../../context/AuthContext";
|
| 14 |
+
import { colors, spacing, typography, borderRadius } from "../../theme";
|
| 15 |
+
|
| 16 |
+
const { width, height } = Dimensions.get("window");
|
| 17 |
+
|
| 18 |
+
export function LoginScreen() {
|
| 19 |
+
const { signInWithGoogle, continueWithDevMode, loading, configError } =
|
| 20 |
+
useAuth();
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<LinearGradient
|
| 24 |
+
colors={[
|
| 25 |
+
colors.background.primary, // #F8FAFC
|
| 26 |
+
"#F1F5F9", // Slate-100
|
| 27 |
+
colors.background.secondary, // #E2E8F0
|
| 28 |
+
]}
|
| 29 |
+
style={styles.container}
|
| 30 |
+
>
|
| 31 |
+
<StatusBar barStyle="dark-content" />
|
| 32 |
+
|
| 33 |
+
{/* Decorative Blur Circles */}
|
| 34 |
+
<View
|
| 35 |
+
style={[
|
| 36 |
+
styles.decorCircle,
|
| 37 |
+
{
|
| 38 |
+
top: -100,
|
| 39 |
+
right: -100,
|
| 40 |
+
backgroundColor: colors.primary.main + "08",
|
| 41 |
+
width: 400,
|
| 42 |
+
height: 400,
|
| 43 |
+
borderRadius: 200,
|
| 44 |
+
},
|
| 45 |
+
]}
|
| 46 |
+
/>
|
| 47 |
+
<View
|
| 48 |
+
style={[
|
| 49 |
+
styles.decorCircle,
|
| 50 |
+
{
|
| 51 |
+
bottom: 0,
|
| 52 |
+
left: -50,
|
| 53 |
+
backgroundColor: colors.secondary.main + "08",
|
| 54 |
+
width: 300,
|
| 55 |
+
height: 300,
|
| 56 |
+
borderRadius: 150,
|
| 57 |
+
},
|
| 58 |
+
]}
|
| 59 |
+
/>
|
| 60 |
+
|
| 61 |
+
<View style={styles.content}>
|
| 62 |
+
<View style={styles.headerSection}>
|
| 63 |
+
<View style={styles.logoContainer}>
|
| 64 |
+
<LinearGradient
|
| 65 |
+
colors={[colors.primary.main, colors.primary.dark]}
|
| 66 |
+
style={styles.logoBadge}
|
| 67 |
+
>
|
| 68 |
+
<Text style={styles.logoText}>U</Text>
|
| 69 |
+
</LinearGradient>
|
| 70 |
+
<View>
|
| 71 |
+
<Text style={styles.brandTitle}>
|
| 72 |
+
City<Text style={{ color: colors.primary.main }}>Tracker</Text>
|
| 73 |
+
</Text>
|
| 74 |
+
<Text style={styles.brandSubtitle}>City Issue Reporter</Text>
|
| 75 |
+
</View>
|
| 76 |
+
</View>
|
| 77 |
+
|
| 78 |
+
<Text style={styles.heroText}>
|
| 79 |
+
Building a Better City,{"\n"}
|
| 80 |
+
<Text style={{ color: colors.primary.main }}>Together.</Text>
|
| 81 |
+
</Text>
|
| 82 |
+
</View>
|
| 83 |
+
|
| 84 |
+
<View style={styles.features}>
|
| 85 |
+
<FeatureItem
|
| 86 |
+
icon="camera"
|
| 87 |
+
color={colors.primary.main}
|
| 88 |
+
title="Snap & Report"
|
| 89 |
+
desc="Take a photo, AI handles the details"
|
| 90 |
+
/>
|
| 91 |
+
<FeatureItem
|
| 92 |
+
icon="flash"
|
| 93 |
+
color="#F59E0B"
|
| 94 |
+
title="Instant Routing"
|
| 95 |
+
desc="Auto-assigned to the right team"
|
| 96 |
+
/>
|
| 97 |
+
<FeatureItem
|
| 98 |
+
icon="shield-checkmark"
|
| 99 |
+
color="#10B981"
|
| 100 |
+
title="Verified Resolution"
|
| 101 |
+
desc="Track progress in real-time"
|
| 102 |
+
/>
|
| 103 |
+
</View>
|
| 104 |
+
</View>
|
| 105 |
+
|
| 106 |
+
<View style={styles.footer}>
|
| 107 |
+
<Button
|
| 108 |
+
title="Continue with Google"
|
| 109 |
+
onPress={signInWithGoogle}
|
| 110 |
+
loading={loading}
|
| 111 |
+
disabled={!!configError}
|
| 112 |
+
fullWidth
|
| 113 |
+
size="lg"
|
| 114 |
+
variant="primary"
|
| 115 |
+
style={styles.googleButton}
|
| 116 |
+
icon={<Ionicons name="logo-google" size={20} color="#FFF" />}
|
| 117 |
+
/>
|
| 118 |
+
|
| 119 |
+
<Button
|
| 120 |
+
title="Continue with Dev Mode"
|
| 121 |
+
variant="ghost"
|
| 122 |
+
onPress={continueWithDevMode}
|
| 123 |
+
fullWidth
|
| 124 |
+
size="sm"
|
| 125 |
+
style={{ marginTop: spacing.md }}
|
| 126 |
+
textStyle={{ color: colors.text.tertiary }}
|
| 127 |
+
/>
|
| 128 |
+
|
| 129 |
+
<Text style={styles.versionText}>v1.2.0 • CityTracker Public Beta</Text>
|
| 130 |
+
</View>
|
| 131 |
+
</LinearGradient>
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const FeatureItem = ({ icon, color, title, desc }: any) => (
|
| 136 |
+
<View style={styles.feature}>
|
| 137 |
+
<View style={[styles.featureIcon, { backgroundColor: color + "15" }]}>
|
| 138 |
+
<Ionicons name={icon as any} size={24} color={color} />
|
| 139 |
+
</View>
|
| 140 |
+
<View style={styles.featureText}>
|
| 141 |
+
<Text style={styles.featureTitle}>{title}</Text>
|
| 142 |
+
<Text style={styles.featureDesc}>{desc}</Text>
|
| 143 |
+
</View>
|
| 144 |
+
</View>
|
| 145 |
+
);
|
| 146 |
+
|
| 147 |
+
const styles = StyleSheet.create({
|
| 148 |
+
container: {
|
| 149 |
+
flex: 1,
|
| 150 |
+
},
|
| 151 |
+
decorCircle: {
|
| 152 |
+
position: "absolute",
|
| 153 |
+
},
|
| 154 |
+
content: {
|
| 155 |
+
flex: 1,
|
| 156 |
+
paddingHorizontal: spacing.xl,
|
| 157 |
+
paddingTop: 80, // Increased top padding
|
| 158 |
+
},
|
| 159 |
+
headerSection: {
|
| 160 |
+
marginBottom: spacing.xxl,
|
| 161 |
+
},
|
| 162 |
+
logoContainer: {
|
| 163 |
+
flexDirection: "row",
|
| 164 |
+
alignItems: "center",
|
| 165 |
+
marginBottom: spacing.xl,
|
| 166 |
+
gap: spacing.md,
|
| 167 |
+
},
|
| 168 |
+
logoBadge: {
|
| 169 |
+
width: 48,
|
| 170 |
+
height: 48,
|
| 171 |
+
borderRadius: 14,
|
| 172 |
+
alignItems: "center",
|
| 173 |
+
justifyContent: "center",
|
| 174 |
+
shadowColor: colors.primary.main,
|
| 175 |
+
shadowOffset: { width: 0, height: 4 },
|
| 176 |
+
shadowOpacity: 0.3,
|
| 177 |
+
shadowRadius: 8,
|
| 178 |
+
elevation: 6,
|
| 179 |
+
},
|
| 180 |
+
logoText: {
|
| 181 |
+
fontSize: 28,
|
| 182 |
+
fontWeight: "900",
|
| 183 |
+
color: "#FFF",
|
| 184 |
+
fontFamily: Platform.OS === "ios" ? "System" : "Roboto",
|
| 185 |
+
},
|
| 186 |
+
brandTitle: {
|
| 187 |
+
fontSize: 24,
|
| 188 |
+
fontWeight: "800",
|
| 189 |
+
color: colors.text.primary,
|
| 190 |
+
lineHeight: 28,
|
| 191 |
+
letterSpacing: -0.5,
|
| 192 |
+
},
|
| 193 |
+
brandSubtitle: {
|
| 194 |
+
fontSize: 13,
|
| 195 |
+
color: colors.text.secondary,
|
| 196 |
+
fontWeight: "500",
|
| 197 |
+
},
|
| 198 |
+
heroText: {
|
| 199 |
+
fontSize: 34,
|
| 200 |
+
fontWeight: "800",
|
| 201 |
+
color: colors.text.primary,
|
| 202 |
+
lineHeight: 40,
|
| 203 |
+
letterSpacing: -1,
|
| 204 |
+
},
|
| 205 |
+
features: {
|
| 206 |
+
gap: spacing.lg,
|
| 207 |
+
},
|
| 208 |
+
feature: {
|
| 209 |
+
flexDirection: "row",
|
| 210 |
+
alignItems: "center",
|
| 211 |
+
marginBottom: spacing.md,
|
| 212 |
+
},
|
| 213 |
+
featureIcon: {
|
| 214 |
+
width: 44,
|
| 215 |
+
height: 44,
|
| 216 |
+
borderRadius: 12,
|
| 217 |
+
alignItems: "center",
|
| 218 |
+
justifyContent: "center",
|
| 219 |
+
},
|
| 220 |
+
featureText: {
|
| 221 |
+
marginLeft: spacing.md,
|
| 222 |
+
flex: 1,
|
| 223 |
+
},
|
| 224 |
+
featureTitle: {
|
| 225 |
+
fontSize: 16,
|
| 226 |
+
fontWeight: "700",
|
| 227 |
+
color: colors.text.primary,
|
| 228 |
+
marginBottom: 2,
|
| 229 |
+
},
|
| 230 |
+
featureDesc: {
|
| 231 |
+
fontSize: 13,
|
| 232 |
+
color: colors.text.secondary,
|
| 233 |
+
lineHeight: 18,
|
| 234 |
+
},
|
| 235 |
+
footer: {
|
| 236 |
+
paddingHorizontal: spacing.xl,
|
| 237 |
+
paddingBottom: spacing.xxl,
|
| 238 |
+
paddingTop: spacing.xl,
|
| 239 |
+
},
|
| 240 |
+
googleButton: {
|
| 241 |
+
shadowColor: colors.primary.main,
|
| 242 |
+
shadowOffset: { width: 0, height: 4 },
|
| 243 |
+
shadowOpacity: 0.2,
|
| 244 |
+
shadowRadius: 12,
|
| 245 |
+
elevation: 4,
|
| 246 |
+
},
|
| 247 |
+
versionText: {
|
| 248 |
+
fontSize: 11,
|
| 249 |
+
color: colors.text.tertiary,
|
| 250 |
+
textAlign: "center",
|
| 251 |
+
marginTop: spacing.xl,
|
| 252 |
+
},
|
| 253 |
+
});
|
User/src/screens/capture/CaptureScreen.tsx
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
View,
|
| 4 |
+
Text,
|
| 5 |
+
StyleSheet,
|
| 6 |
+
TouchableOpacity,
|
| 7 |
+
Image,
|
| 8 |
+
Alert,
|
| 9 |
+
Animated,
|
| 10 |
+
Dimensions,
|
| 11 |
+
ActivityIndicator,
|
| 12 |
+
Linking,
|
| 13 |
+
Platform,
|
| 14 |
+
TextInput,
|
| 15 |
+
KeyboardAvoidingView,
|
| 16 |
+
ScrollView,
|
| 17 |
+
} from 'react-native';
|
| 18 |
+
import { CameraView, useCameraPermissions } from 'expo-camera';
|
| 19 |
+
import * as Location from 'expo-location';
|
| 20 |
+
import { LinearGradient } from 'expo-linear-gradient';
|
| 21 |
+
import { useNavigation } from '@react-navigation/native';
|
| 22 |
+
import { Ionicons } from '@expo/vector-icons';
|
| 23 |
+
import { Button } from '../../components/ui/Button';
|
| 24 |
+
import { Card } from '../../components/ui/Card';
|
| 25 |
+
import { colors, spacing, borderRadius, typography } from '../../theme';
|
| 26 |
+
import { LocationData } from '../../types';
|
| 27 |
+
import {
|
| 28 |
+
isLocationAccurate,
|
| 29 |
+
checkLocationServicesEnabled,
|
| 30 |
+
watchLocationWithGpsCheck,
|
| 31 |
+
} from '../../services/locationService';
|
| 32 |
+
|
| 33 |
+
const { width, height } = Dimensions.get('window');
|
| 34 |
+
|
| 35 |
+
type CaptureScreenState = 'gps_check' | 'camera' | 'preview' | 'submitting';
|
| 36 |
+
|
| 37 |
+
export function CaptureScreen() {
|
| 38 |
+
const navigation = useNavigation<any>();
|
| 39 |
+
const cameraRef = useRef<CameraView>(null);
|
| 40 |
+
|
| 41 |
+
const [permission, requestPermission] = useCameraPermissions();
|
| 42 |
+
const [locationPermission, setLocationPermission] = useState<boolean>(false);
|
| 43 |
+
|
| 44 |
+
const [screenState, setScreenState] = useState<CaptureScreenState>('gps_check');
|
| 45 |
+
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
| 46 |
+
const [location, setLocation] = useState<LocationData | null>(null);
|
| 47 |
+
const [gpsAccuracy, setGpsAccuracy] = useState<number | null>(null);
|
| 48 |
+
const [isGpsEnabled, setIsGpsEnabled] = useState<boolean | null>(null);
|
| 49 |
+
const [isGpsReady, setIsGpsReady] = useState(false);
|
| 50 |
+
const [description, setDescription] = useState('');
|
| 51 |
+
|
| 52 |
+
const pulseAnim = useRef(new Animated.Value(1)).current;
|
| 53 |
+
const shutterAnim = useRef(new Animated.Value(1)).current;
|
| 54 |
+
const cleanupRef = useRef<(() => void) | null>(null);
|
| 55 |
+
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
initializeLocation();
|
| 58 |
+
|
| 59 |
+
return () => {
|
| 60 |
+
if (cleanupRef.current) {
|
| 61 |
+
cleanupRef.current();
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
}, []);
|
| 65 |
+
|
| 66 |
+
const initializeLocation = async () => {
|
| 67 |
+
const gpsEnabled = await checkLocationServicesEnabled();
|
| 68 |
+
setIsGpsEnabled(gpsEnabled);
|
| 69 |
+
|
| 70 |
+
if (!gpsEnabled) {
|
| 71 |
+
setScreenState('gps_check');
|
| 72 |
+
return;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const { status } = await Location.requestForegroundPermissionsAsync();
|
| 76 |
+
setLocationPermission(status === 'granted');
|
| 77 |
+
|
| 78 |
+
if (status === 'granted') {
|
| 79 |
+
startLocationTracking();
|
| 80 |
+
setScreenState('camera');
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const startLocationTracking = async () => {
|
| 85 |
+
cleanupRef.current = await watchLocationWithGpsCheck(
|
| 86 |
+
(newLocation) => {
|
| 87 |
+
setGpsAccuracy(newLocation.accuracy);
|
| 88 |
+
|
| 89 |
+
if (isLocationAccurate(newLocation.accuracy, 15)) {
|
| 90 |
+
setIsGpsReady(true);
|
| 91 |
+
setLocation(newLocation);
|
| 92 |
+
} else {
|
| 93 |
+
setIsGpsReady(false);
|
| 94 |
+
}
|
| 95 |
+
},
|
| 96 |
+
(enabled) => {
|
| 97 |
+
setIsGpsEnabled(enabled);
|
| 98 |
+
if (!enabled) {
|
| 99 |
+
setScreenState('gps_check');
|
| 100 |
+
setIsGpsReady(false);
|
| 101 |
+
}
|
| 102 |
+
},
|
| 103 |
+
15
|
| 104 |
+
);
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
useEffect(() => {
|
| 108 |
+
if (screenState === 'camera' && !isGpsReady) {
|
| 109 |
+
Animated.loop(
|
| 110 |
+
Animated.sequence([
|
| 111 |
+
Animated.timing(pulseAnim, {
|
| 112 |
+
toValue: 1.2,
|
| 113 |
+
duration: 1000,
|
| 114 |
+
useNativeDriver: true,
|
| 115 |
+
}),
|
| 116 |
+
Animated.timing(pulseAnim, {
|
| 117 |
+
toValue: 1,
|
| 118 |
+
duration: 1000,
|
| 119 |
+
useNativeDriver: true,
|
| 120 |
+
}),
|
| 121 |
+
])
|
| 122 |
+
).start();
|
| 123 |
+
} else {
|
| 124 |
+
pulseAnim.stopAnimation();
|
| 125 |
+
}
|
| 126 |
+
}, [isGpsReady, screenState]);
|
| 127 |
+
|
| 128 |
+
const handleOpenSettings = () => {
|
| 129 |
+
if (Platform.OS === 'ios') {
|
| 130 |
+
Linking.openURL('app-settings:');
|
| 131 |
+
} else {
|
| 132 |
+
Linking.openSettings();
|
| 133 |
+
}
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
const handleRetryGps = async () => {
|
| 137 |
+
const enabled = await checkLocationServicesEnabled();
|
| 138 |
+
setIsGpsEnabled(enabled);
|
| 139 |
+
|
| 140 |
+
if (enabled) {
|
| 141 |
+
const { status } = await Location.requestForegroundPermissionsAsync();
|
| 142 |
+
if (status === 'granted') {
|
| 143 |
+
startLocationTracking();
|
| 144 |
+
setScreenState('camera');
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
const handleCapture = async () => {
|
| 150 |
+
if (!cameraRef.current || !isGpsReady || !location) {
|
| 151 |
+
Alert.alert(
|
| 152 |
+
'GPS Not Ready',
|
| 153 |
+
'Please wait for GPS to lock (accuracy < 15m) before capturing. This is required to ensure accurate issue reporting.',
|
| 154 |
+
[{ text: 'OK' }]
|
| 155 |
+
);
|
| 156 |
+
return;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
Animated.sequence([
|
| 160 |
+
Animated.timing(shutterAnim, {
|
| 161 |
+
toValue: 0.8,
|
| 162 |
+
duration: 50,
|
| 163 |
+
useNativeDriver: true,
|
| 164 |
+
}),
|
| 165 |
+
Animated.timing(shutterAnim, {
|
| 166 |
+
toValue: 1,
|
| 167 |
+
duration: 50,
|
| 168 |
+
useNativeDriver: true,
|
| 169 |
+
}),
|
| 170 |
+
]).start();
|
| 171 |
+
|
| 172 |
+
try {
|
| 173 |
+
const photo = await cameraRef.current.takePictureAsync({
|
| 174 |
+
quality: 0.8,
|
| 175 |
+
base64: false,
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
if (photo) {
|
| 179 |
+
setCapturedImage(photo.uri);
|
| 180 |
+
setScreenState('preview');
|
| 181 |
+
}
|
| 182 |
+
} catch (error) {
|
| 183 |
+
console.error('Camera capture error:', error);
|
| 184 |
+
Alert.alert('Error', 'Failed to capture image');
|
| 185 |
+
}
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
const handleRetake = () => {
|
| 189 |
+
setCapturedImage(null);
|
| 190 |
+
setDescription('');
|
| 191 |
+
setScreenState('camera');
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
const handleSubmit = () => {
|
| 195 |
+
if (!capturedImage || !location) return;
|
| 196 |
+
|
| 197 |
+
navigation.navigate('Processing', {
|
| 198 |
+
imageUri: capturedImage,
|
| 199 |
+
location: location,
|
| 200 |
+
description: description.trim() || undefined,
|
| 201 |
+
});
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
if (screenState === 'gps_check' || isGpsEnabled === false) {
|
| 205 |
+
return (
|
| 206 |
+
<LinearGradient
|
| 207 |
+
colors={[colors.background.primary, colors.background.secondary]}
|
| 208 |
+
style={styles.container}
|
| 209 |
+
>
|
| 210 |
+
<View style={styles.gpsCheckContainer}>
|
| 211 |
+
<Card variant="glass" style={styles.gpsCheckCard}>
|
| 212 |
+
<View style={styles.gpsCheckIcon}>
|
| 213 |
+
<Ionicons name="location" size={48} color={colors.status.warning} />
|
| 214 |
+
</View>
|
| 215 |
+
|
| 216 |
+
<Text style={styles.gpsCheckTitle}>GPS Required</Text>
|
| 217 |
+
<Text style={styles.gpsCheckText}>
|
| 218 |
+
Location services must be enabled to report issues. This ensures accurate location data and prevents fraudulent reports.
|
| 219 |
+
</Text>
|
| 220 |
+
|
| 221 |
+
<View style={styles.requirementList}>
|
| 222 |
+
<View style={styles.requirementItem}>
|
| 223 |
+
<Ionicons name="checkmark-circle" size={20} color={colors.secondary.main} />
|
| 224 |
+
<Text style={styles.requirementText}>Precise location tracking</Text>
|
| 225 |
+
</View>
|
| 226 |
+
<View style={styles.requirementItem}>
|
| 227 |
+
<Ionicons name="checkmark-circle" size={20} color={colors.secondary.main} />
|
| 228 |
+
<Text style={styles.requirementText}>Anti-fraud protection</Text>
|
| 229 |
+
</View>
|
| 230 |
+
<View style={styles.requirementItem}>
|
| 231 |
+
<Ionicons name="checkmark-circle" size={20} color={colors.secondary.main} />
|
| 232 |
+
<Text style={styles.requirementText}>Accurate issue mapping</Text>
|
| 233 |
+
</View>
|
| 234 |
+
</View>
|
| 235 |
+
|
| 236 |
+
<Button
|
| 237 |
+
title="Enable GPS"
|
| 238 |
+
onPress={handleOpenSettings}
|
| 239 |
+
fullWidth
|
| 240 |
+
size="lg"
|
| 241 |
+
/>
|
| 242 |
+
|
| 243 |
+
<Button
|
| 244 |
+
title="Checker Again"
|
| 245 |
+
variant="ghost"
|
| 246 |
+
onPress={handleRetryGps}
|
| 247 |
+
fullWidth
|
| 248 |
+
size="sm"
|
| 249 |
+
style={{ marginTop: spacing.md }}
|
| 250 |
+
/>
|
| 251 |
+
</Card>
|
| 252 |
+
|
| 253 |
+
<TouchableOpacity
|
| 254 |
+
style={styles.backLink}
|
| 255 |
+
onPress={() => navigation.goBack()}
|
| 256 |
+
>
|
| 257 |
+
<Ionicons name="arrow-back" size={20} color={colors.text.secondary} />
|
| 258 |
+
<Text style={styles.backLinkText}>Go Back</Text>
|
| 259 |
+
</TouchableOpacity>
|
| 260 |
+
</View>
|
| 261 |
+
</LinearGradient>
|
| 262 |
+
);
|
| 263 |
+
}
|
| 264 |
+
// ... (Camera Permission block remains similar, skipping for brevity if unchanged)
|
| 265 |
+
|
| 266 |
+
if (screenState === 'preview' && capturedImage) {
|
| 267 |
+
return (
|
| 268 |
+
<View style={styles.previewContainer}>
|
| 269 |
+
<ScrollView contentContainerStyle={styles.previewContent}>
|
| 270 |
+
<View style={styles.previewImageWrapper}>
|
| 271 |
+
<Image source={{ uri: capturedImage }} style={styles.previewImageSmall} />
|
| 272 |
+
<TouchableOpacity style={styles.retakeOverlay} onPress={handleRetake}>
|
| 273 |
+
<Ionicons name="camera-reverse" size={16} color={colors.text.primary} />
|
| 274 |
+
<Text style={styles.retakeText}>Retake</Text>
|
| 275 |
+
</TouchableOpacity>
|
| 276 |
+
</View>
|
| 277 |
+
|
| 278 |
+
<View style={styles.previewForm}>
|
| 279 |
+
<Text style={styles.previewTitle}>Add Details</Text>
|
| 280 |
+
<TextInput
|
| 281 |
+
style={styles.descriptionInput}
|
| 282 |
+
placeholder="Describe the issue... (optional)"
|
| 283 |
+
placeholderTextColor={colors.text.tertiary}
|
| 284 |
+
multiline
|
| 285 |
+
value={description}
|
| 286 |
+
onChangeText={setDescription}
|
| 287 |
+
/>
|
| 288 |
+
|
| 289 |
+
<View style={styles.locationCard}>
|
| 290 |
+
<View style={styles.locationInfo}>
|
| 291 |
+
<Ionicons name="location" size={20} color={colors.primary.main} />
|
| 292 |
+
<Text style={styles.locationText}>
|
| 293 |
+
Lat: {location?.latitude.toFixed(4)}, Long: {location?.longitude.toFixed(4)}
|
| 294 |
+
</Text>
|
| 295 |
+
</View>
|
| 296 |
+
<View style={styles.accuracyBadge}>
|
| 297 |
+
<Text style={styles.accuracyText}>±{gpsAccuracy?.toFixed(0)}m</Text>
|
| 298 |
+
</View>
|
| 299 |
+
</View>
|
| 300 |
+
</View>
|
| 301 |
+
|
| 302 |
+
<View style={styles.previewActions}>
|
| 303 |
+
<Button
|
| 304 |
+
title="Submit Report"
|
| 305 |
+
onPress={handleSubmit}
|
| 306 |
+
size="lg"
|
| 307 |
+
fullWidth
|
| 308 |
+
icon={<Ionicons name="send" size={12} color="#FFF" />}
|
| 309 |
+
/>
|
| 310 |
+
<Button
|
| 311 |
+
title="Cancel"
|
| 312 |
+
variant="ghost"
|
| 313 |
+
onPress={() => navigation.goBack()}
|
| 314 |
+
fullWidth
|
| 315 |
+
size="sm"
|
| 316 |
+
style={{ marginTop: spacing.md }}
|
| 317 |
+
/>
|
| 318 |
+
</View>
|
| 319 |
+
</ScrollView>
|
| 320 |
+
</View>
|
| 321 |
+
);
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
return (
|
| 325 |
+
<View style={styles.container}>
|
| 326 |
+
<CameraView
|
| 327 |
+
ref={cameraRef}
|
| 328 |
+
style={styles.camera}
|
| 329 |
+
facing="back"
|
| 330 |
+
/>
|
| 331 |
+
|
| 332 |
+
<View style={styles.cameraOverlay}>
|
| 333 |
+
<LinearGradient
|
| 334 |
+
colors={['rgba(0,0,0,0.7)', 'transparent']}
|
| 335 |
+
style={styles.topBar}
|
| 336 |
+
>
|
| 337 |
+
<TouchableOpacity
|
| 338 |
+
style={styles.backButton}
|
| 339 |
+
onPress={() => navigation.goBack()}
|
| 340 |
+
>
|
| 341 |
+
<Ionicons name="arrow-back" size={24} color="#FFF" />
|
| 342 |
+
</TouchableOpacity>
|
| 343 |
+
|
| 344 |
+
<View style={[
|
| 345 |
+
styles.gpsIndicator,
|
| 346 |
+
isGpsReady ? styles.gpsReady : styles.gpsWaiting
|
| 347 |
+
]}>
|
| 348 |
+
<Ionicons
|
| 349 |
+
name={isGpsReady ? 'location' : 'hourglass'}
|
| 350 |
+
size={14}
|
| 351 |
+
color="#FFF"
|
| 352 |
+
/>
|
| 353 |
+
<Text style={styles.gpsText}>
|
| 354 |
+
{isGpsReady
|
| 355 |
+
? `GPS Locked ±${gpsAccuracy?.toFixed(0)}m`
|
| 356 |
+
: `Acquiring GPS...`
|
| 357 |
+
}
|
| 358 |
+
</Text>
|
| 359 |
+
</View>
|
| 360 |
+
</LinearGradient>
|
| 361 |
+
|
| 362 |
+
<View style={styles.frameGuide}>
|
| 363 |
+
<View style={[styles.corner, styles.cornerTL]} />
|
| 364 |
+
<View style={[styles.corner, styles.cornerTR]} />
|
| 365 |
+
<View style={[styles.corner, styles.cornerBL]} />
|
| 366 |
+
<View style={[styles.corner, styles.cornerBR]} />
|
| 367 |
+
|
| 368 |
+
{!isGpsReady && (
|
| 369 |
+
<View style={styles.gpsWarningOverlay}>
|
| 370 |
+
<View style={styles.gpsWarningBox}>
|
| 371 |
+
<ActivityIndicator color={colors.status.warning} size="small" />
|
| 372 |
+
<Text style={styles.gpsWarningText}>Waiting for detailed location...</Text>
|
| 373 |
+
</View>
|
| 374 |
+
</View>
|
| 375 |
+
)}
|
| 376 |
+
</View>
|
| 377 |
+
|
| 378 |
+
<LinearGradient
|
| 379 |
+
colors={['transparent', 'rgba(0,0,0,0.8)']}
|
| 380 |
+
style={styles.bottomBar}
|
| 381 |
+
>
|
| 382 |
+
<Text style={styles.instructionText}>
|
| 383 |
+
{isGpsReady
|
| 384 |
+
? 'Tap to Capture'
|
| 385 |
+
: 'Wait for GPS Lock'
|
| 386 |
+
}
|
| 387 |
+
</Text>
|
| 388 |
+
|
| 389 |
+
<Animated.View style={{ transform: [{ scale: shutterAnim }] }}>
|
| 390 |
+
<TouchableOpacity
|
| 391 |
+
style={[
|
| 392 |
+
styles.shutterButton,
|
| 393 |
+
!isGpsReady && { opacity: 0.5 }
|
| 394 |
+
]}
|
| 395 |
+
onPress={handleCapture}
|
| 396 |
+
disabled={!isGpsReady}
|
| 397 |
+
>
|
| 398 |
+
<Ionicons name="camera" size={36} color={colors.primary.main} />
|
| 399 |
+
</TouchableOpacity>
|
| 400 |
+
</Animated.View>
|
| 401 |
+
</LinearGradient>
|
| 402 |
+
</View>
|
| 403 |
+
</View>
|
| 404 |
+
);
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
const styles = StyleSheet.create({
|
| 408 |
+
container: {
|
| 409 |
+
flex: 1,
|
| 410 |
+
backgroundColor: "#000",
|
| 411 |
+
},
|
| 412 |
+
camera: {
|
| 413 |
+
...StyleSheet.absoluteFillObject,
|
| 414 |
+
},
|
| 415 |
+
cameraOverlay: {
|
| 416 |
+
...StyleSheet.absoluteFillObject,
|
| 417 |
+
justifyContent: 'space-between',
|
| 418 |
+
},
|
| 419 |
+
gpsCheckContainer: {
|
| 420 |
+
flex: 1,
|
| 421 |
+
padding: spacing.lg,
|
| 422 |
+
justifyContent: 'center',
|
| 423 |
+
},
|
| 424 |
+
gpsCheckCard: {
|
| 425 |
+
padding: spacing.xl,
|
| 426 |
+
alignItems: 'center',
|
| 427 |
+
backgroundColor: "rgba(255,255,255,0.7)",
|
| 428 |
+
},
|
| 429 |
+
gpsCheckIcon: {
|
| 430 |
+
marginBottom: spacing.lg,
|
| 431 |
+
width: 64,
|
| 432 |
+
height: 64,
|
| 433 |
+
borderRadius: 32,
|
| 434 |
+
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
| 435 |
+
alignItems: 'center',
|
| 436 |
+
justifyContent: 'center',
|
| 437 |
+
},
|
| 438 |
+
gpsCheckTitle: {
|
| 439 |
+
fontSize: 24,
|
| 440 |
+
fontWeight: "700",
|
| 441 |
+
color: colors.text.primary,
|
| 442 |
+
marginBottom: spacing.md,
|
| 443 |
+
},
|
| 444 |
+
gpsCheckText: {
|
| 445 |
+
fontSize: 14,
|
| 446 |
+
color: colors.text.secondary,
|
| 447 |
+
textAlign: 'center',
|
| 448 |
+
marginBottom: spacing.xl,
|
| 449 |
+
lineHeight: 20,
|
| 450 |
+
},
|
| 451 |
+
requirementList: {
|
| 452 |
+
width: '100%',
|
| 453 |
+
marginBottom: spacing.xl,
|
| 454 |
+
gap: spacing.sm,
|
| 455 |
+
},
|
| 456 |
+
requirementItem: {
|
| 457 |
+
flexDirection: 'row',
|
| 458 |
+
alignItems: 'center',
|
| 459 |
+
gap: spacing.sm,
|
| 460 |
+
},
|
| 461 |
+
requirementText: {
|
| 462 |
+
fontSize: 14,
|
| 463 |
+
color: colors.text.primary,
|
| 464 |
+
fontWeight: "500",
|
| 465 |
+
},
|
| 466 |
+
backLink: {
|
| 467 |
+
marginTop: spacing.xl,
|
| 468 |
+
padding: spacing.md,
|
| 469 |
+
flexDirection: 'row',
|
| 470 |
+
alignItems: 'center',
|
| 471 |
+
gap: spacing.xs,
|
| 472 |
+
},
|
| 473 |
+
backLinkText: {
|
| 474 |
+
color: colors.text.secondary,
|
| 475 |
+
fontSize: 16,
|
| 476 |
+
},
|
| 477 |
+
topBar: {
|
| 478 |
+
flexDirection: 'row',
|
| 479 |
+
justifyContent: 'space-between',
|
| 480 |
+
alignItems: 'center',
|
| 481 |
+
padding: spacing.lg,
|
| 482 |
+
paddingTop: 60,
|
| 483 |
+
},
|
| 484 |
+
backButton: {
|
| 485 |
+
width: 40,
|
| 486 |
+
height: 40,
|
| 487 |
+
borderRadius: 20,
|
| 488 |
+
backgroundColor: 'rgba(255,255,255,0.2)',
|
| 489 |
+
alignItems: 'center',
|
| 490 |
+
justifyContent: 'center',
|
| 491 |
+
},
|
| 492 |
+
gpsIndicator: {
|
| 493 |
+
flexDirection: 'row',
|
| 494 |
+
alignItems: 'center',
|
| 495 |
+
paddingHorizontal: 12,
|
| 496 |
+
paddingVertical: 6,
|
| 497 |
+
borderRadius: 20,
|
| 498 |
+
gap: 6,
|
| 499 |
+
},
|
| 500 |
+
gpsReady: {
|
| 501 |
+
backgroundColor: 'rgba(16, 185, 129, 0.8)', // Green
|
| 502 |
+
},
|
| 503 |
+
gpsWaiting: {
|
| 504 |
+
backgroundColor: 'rgba(245, 158, 11, 0.8)', // Amber
|
| 505 |
+
},
|
| 506 |
+
gpsText: {
|
| 507 |
+
color: "#FFF",
|
| 508 |
+
fontSize: 12,
|
| 509 |
+
fontWeight: '600',
|
| 510 |
+
},
|
| 511 |
+
frameGuide: {
|
| 512 |
+
flex: 1,
|
| 513 |
+
marginHorizontal: 40,
|
| 514 |
+
marginVertical: 80,
|
| 515 |
+
position: 'relative',
|
| 516 |
+
},
|
| 517 |
+
corner: {
|
| 518 |
+
position: 'absolute',
|
| 519 |
+
width: 40,
|
| 520 |
+
height: 40,
|
| 521 |
+
borderColor: "#FFF",
|
| 522 |
+
borderWidth: 4,
|
| 523 |
+
borderRadius: 8,
|
| 524 |
+
},
|
| 525 |
+
cornerTL: { top: 0, left: 0, borderRightWidth: 0, borderBottomWidth: 0 },
|
| 526 |
+
cornerTR: { top: 0, right: 0, borderLeftWidth: 0, borderBottomWidth: 0 },
|
| 527 |
+
cornerBL: { bottom: 0, left: 0, borderRightWidth: 0, borderTopWidth: 0 },
|
| 528 |
+
cornerBR: { bottom: 0, right: 0, borderLeftWidth: 0, borderTopWidth: 0 },
|
| 529 |
+
gpsWarningOverlay: {
|
| 530 |
+
position: 'absolute',
|
| 531 |
+
bottom: -60,
|
| 532 |
+
left: 0,
|
| 533 |
+
right: 0,
|
| 534 |
+
alignItems: 'center',
|
| 535 |
+
},
|
| 536 |
+
gpsWarningBox: {
|
| 537 |
+
flexDirection: 'row',
|
| 538 |
+
alignItems: 'center',
|
| 539 |
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
| 540 |
+
paddingHorizontal: 16,
|
| 541 |
+
paddingVertical: 8,
|
| 542 |
+
borderRadius: 20,
|
| 543 |
+
gap: 8,
|
| 544 |
+
},
|
| 545 |
+
gpsWarningText: {
|
| 546 |
+
color: "#FFF",
|
| 547 |
+
fontSize: 13,
|
| 548 |
+
fontWeight: '500',
|
| 549 |
+
},
|
| 550 |
+
bottomBar: {
|
| 551 |
+
alignItems: 'center',
|
| 552 |
+
paddingBottom: 50,
|
| 553 |
+
paddingTop: 20,
|
| 554 |
+
},
|
| 555 |
+
shutterButton: {
|
| 556 |
+
width: 80,
|
| 557 |
+
height: 80,
|
| 558 |
+
borderRadius: 40,
|
| 559 |
+
backgroundColor: "#FFFFFF",
|
| 560 |
+
marginTop: 20,
|
| 561 |
+
shadowColor: "#000",
|
| 562 |
+
shadowOffset: { width: 0, height: 4 },
|
| 563 |
+
shadowOpacity: 0.3,
|
| 564 |
+
shadowRadius: 5,
|
| 565 |
+
elevation: 8,
|
| 566 |
+
alignItems: 'center',
|
| 567 |
+
justifyContent: 'center',
|
| 568 |
+
},
|
| 569 |
+
shutterInner: {
|
| 570 |
+
// Removed nested circle design
|
| 571 |
+
display: 'none',
|
| 572 |
+
},
|
| 573 |
+
shutterDisabled: {
|
| 574 |
+
borderColor: "rgba(255,255,255,0.3)",
|
| 575 |
+
},
|
| 576 |
+
shutterInnerDisabled: {
|
| 577 |
+
backgroundColor: "transparent",
|
| 578 |
+
},
|
| 579 |
+
instructionText: {
|
| 580 |
+
color: "#FFF",
|
| 581 |
+
fontSize: 14,
|
| 582 |
+
fontWeight: "500",
|
| 583 |
+
textShadowColor: 'rgba(0,0,0,0.5)',
|
| 584 |
+
textShadowRadius: 4,
|
| 585 |
+
},
|
| 586 |
+
previewContainer: {
|
| 587 |
+
flex: 1,
|
| 588 |
+
backgroundColor: colors.background.primary,
|
| 589 |
+
},
|
| 590 |
+
previewContent: {
|
| 591 |
+
padding: spacing.lg,
|
| 592 |
+
paddingTop: spacing.xxl + 20,
|
| 593 |
+
paddingBottom: spacing.xxl,
|
| 594 |
+
},
|
| 595 |
+
previewImageWrapper: {
|
| 596 |
+
width: '100%',
|
| 597 |
+
height: 200,
|
| 598 |
+
borderRadius: borderRadius.lg,
|
| 599 |
+
overflow: 'hidden',
|
| 600 |
+
marginBottom: spacing.xl,
|
| 601 |
+
position: 'relative',
|
| 602 |
+
},
|
| 603 |
+
previewImageSmall: {
|
| 604 |
+
width: '100%',
|
| 605 |
+
height: '100%',
|
| 606 |
+
},
|
| 607 |
+
retakeOverlay: {
|
| 608 |
+
position: 'absolute',
|
| 609 |
+
top: spacing.md,
|
| 610 |
+
right: spacing.md,
|
| 611 |
+
flexDirection: 'row',
|
| 612 |
+
alignItems: 'center',
|
| 613 |
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
| 614 |
+
paddingHorizontal: spacing.md,
|
| 615 |
+
paddingVertical: spacing.sm,
|
| 616 |
+
borderRadius: borderRadius.full,
|
| 617 |
+
gap: spacing.xs,
|
| 618 |
+
},
|
| 619 |
+
retakeText: {
|
| 620 |
+
color: colors.text.primary,
|
| 621 |
+
fontSize: 14,
|
| 622 |
+
fontWeight: '500',
|
| 623 |
+
},
|
| 624 |
+
previewForm: {
|
| 625 |
+
marginBottom: spacing.xl,
|
| 626 |
+
},
|
| 627 |
+
previewTitle: {
|
| 628 |
+
...typography.h2,
|
| 629 |
+
color: colors.text.primary,
|
| 630 |
+
marginBottom: spacing.md,
|
| 631 |
+
},
|
| 632 |
+
descriptionInput: {
|
| 633 |
+
backgroundColor: colors.background.tertiary,
|
| 634 |
+
borderRadius: borderRadius.md,
|
| 635 |
+
padding: spacing.md,
|
| 636 |
+
color: colors.text.primary,
|
| 637 |
+
fontSize: 16,
|
| 638 |
+
minHeight: 100,
|
| 639 |
+
textAlignVertical: 'top',
|
| 640 |
+
marginBottom: spacing.lg,
|
| 641 |
+
borderWidth: 1,
|
| 642 |
+
borderColor: colors.border.light,
|
| 643 |
+
},
|
| 644 |
+
locationCard: {
|
| 645 |
+
flexDirection: 'row',
|
| 646 |
+
alignItems: 'center',
|
| 647 |
+
justifyContent: 'space-between',
|
| 648 |
+
backgroundColor: colors.background.tertiary,
|
| 649 |
+
padding: spacing.md,
|
| 650 |
+
borderRadius: borderRadius.md,
|
| 651 |
+
},
|
| 652 |
+
locationInfo: {
|
| 653 |
+
flexDirection: 'row',
|
| 654 |
+
alignItems: 'center',
|
| 655 |
+
gap: spacing.sm,
|
| 656 |
+
flex: 1,
|
| 657 |
+
},
|
| 658 |
+
locationText: {
|
| 659 |
+
color: colors.text.secondary,
|
| 660 |
+
fontSize: 14,
|
| 661 |
+
},
|
| 662 |
+
accuracyBadge: {
|
| 663 |
+
backgroundColor: colors.secondary.main,
|
| 664 |
+
paddingHorizontal: spacing.sm,
|
| 665 |
+
paddingVertical: 4,
|
| 666 |
+
borderRadius: borderRadius.sm,
|
| 667 |
+
},
|
| 668 |
+
accuracyText: {
|
| 669 |
+
color: colors.text.primary,
|
| 670 |
+
fontSize: 12,
|
| 671 |
+
fontWeight: '600',
|
| 672 |
+
},
|
| 673 |
+
previewActions: {
|
| 674 |
+
marginTop: spacing.md,
|
| 675 |
+
},
|
| 676 |
+
});
|
| 677 |
+
|
User/src/screens/capture/ProcessingScreen.tsx
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useRef } from "react";
|
| 2 |
+
import {
|
| 3 |
+
View,
|
| 4 |
+
Text,
|
| 5 |
+
StyleSheet,
|
| 6 |
+
Image,
|
| 7 |
+
Animated,
|
| 8 |
+
Dimensions,
|
| 9 |
+
Alert,
|
| 10 |
+
ScrollView,
|
| 11 |
+
ActivityIndicator,
|
| 12 |
+
} from "react-native";
|
| 13 |
+
import { useRoute, useNavigation, RouteProp } from "@react-navigation/native";
|
| 14 |
+
import { LinearGradient } from "expo-linear-gradient";
|
| 15 |
+
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
| 16 |
+
import { Button } from "../../components/ui/Button";
|
| 17 |
+
import { Card } from "../../components/ui/Card";
|
| 18 |
+
import { issueService } from "../../services/issueService";
|
| 19 |
+
import { cacheService } from "../../services/cacheService";
|
| 20 |
+
import { useAuth } from "../../context/AuthContext";
|
| 21 |
+
import { colors, spacing, typography, borderRadius } from "../../theme";
|
| 22 |
+
import { LocationData, FlowStep } from "../../types";
|
| 23 |
+
|
| 24 |
+
const { width } = Dimensions.get("window");
|
| 25 |
+
|
| 26 |
+
type ProcessingRouteParams = {
|
| 27 |
+
Processing: {
|
| 28 |
+
imageUri: string;
|
| 29 |
+
location: LocationData;
|
| 30 |
+
description?: string;
|
| 31 |
+
};
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
interface AgentStep {
|
| 35 |
+
name: string;
|
| 36 |
+
iconName: keyof typeof Ionicons.glyphMap;
|
| 37 |
+
label: string;
|
| 38 |
+
status: "pending" | "running" | "done" | "error";
|
| 39 |
+
decision?: string;
|
| 40 |
+
reasoning?: string;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const initialAgents: AgentStep[] = [
|
| 44 |
+
{
|
| 45 |
+
name: "LocationStep",
|
| 46 |
+
iconName: "location",
|
| 47 |
+
label: "Verifying Location",
|
| 48 |
+
status: "pending",
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
name: "UploadStep",
|
| 52 |
+
iconName: "cloud-upload",
|
| 53 |
+
label: "Secure Upload",
|
| 54 |
+
status: "pending",
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
name: "VisionAgent",
|
| 58 |
+
iconName: "eye",
|
| 59 |
+
label: "Vision Agent",
|
| 60 |
+
status: "pending",
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
name: "GeoDeduplicateAgent",
|
| 64 |
+
iconName: "map",
|
| 65 |
+
label: "Geo Agent",
|
| 66 |
+
status: "pending",
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
name: "PriorityAgent",
|
| 70 |
+
iconName: "alert-circle",
|
| 71 |
+
label: "Priority Agent",
|
| 72 |
+
status: "pending",
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
name: "RoutingAgent",
|
| 76 |
+
iconName: "git-branch",
|
| 77 |
+
label: "Routing Agent",
|
| 78 |
+
status: "pending",
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
name: "NotificationAgent",
|
| 82 |
+
iconName: "notifications",
|
| 83 |
+
label: "Notification Agent",
|
| 84 |
+
status: "pending",
|
| 85 |
+
},
|
| 86 |
+
];
|
| 87 |
+
|
| 88 |
+
export function ProcessingScreen() {
|
| 89 |
+
const route = useRoute<RouteProp<ProcessingRouteParams, "Processing">>();
|
| 90 |
+
const navigation = useNavigation<any>();
|
| 91 |
+
const { session } = useAuth();
|
| 92 |
+
|
| 93 |
+
const { imageUri, location, description } = route.params;
|
| 94 |
+
|
| 95 |
+
const [agents, setAgents] = useState<AgentStep[]>(initialAgents);
|
| 96 |
+
const [issueId, setIssueId] = useState<string | null>(null);
|
| 97 |
+
const [error, setError] = useState<string | null>(null);
|
| 98 |
+
const [isComplete, setIsComplete] = useState(false);
|
| 99 |
+
|
| 100 |
+
const progressAnim = useRef(new Animated.Value(0)).current;
|
| 101 |
+
const scanLineAnim = useRef(new Animated.Value(0)).current;
|
| 102 |
+
const pulseAnims = useRef(
|
| 103 |
+
initialAgents.map(() => new Animated.Value(1)),
|
| 104 |
+
).current;
|
| 105 |
+
|
| 106 |
+
useEffect(() => {
|
| 107 |
+
submitIssue();
|
| 108 |
+
|
| 109 |
+
Animated.loop(
|
| 110 |
+
Animated.sequence([
|
| 111 |
+
Animated.timing(scanLineAnim, {
|
| 112 |
+
toValue: 1,
|
| 113 |
+
duration: 2000,
|
| 114 |
+
useNativeDriver: true,
|
| 115 |
+
}),
|
| 116 |
+
Animated.timing(scanLineAnim, {
|
| 117 |
+
toValue: 0,
|
| 118 |
+
duration: 0,
|
| 119 |
+
useNativeDriver: true,
|
| 120 |
+
})
|
| 121 |
+
])
|
| 122 |
+
).start();
|
| 123 |
+
}, []);
|
| 124 |
+
|
| 125 |
+
useEffect(() => {
|
| 126 |
+
let cleanupFn: (() => void) | undefined;
|
| 127 |
+
|
| 128 |
+
const connect = async () => {
|
| 129 |
+
if (!issueId) return;
|
| 130 |
+
|
| 131 |
+
try {
|
| 132 |
+
cleanupFn = await issueService.connectToFlowStream(
|
| 133 |
+
issueId,
|
| 134 |
+
(event) => {
|
| 135 |
+
handleStreamEvent(event);
|
| 136 |
+
},
|
| 137 |
+
(err) => {},
|
| 138 |
+
);
|
| 139 |
+
} catch (e) {
|
| 140 |
+
console.error("Failed to connect to stream", e);
|
| 141 |
+
}
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
connect();
|
| 145 |
+
|
| 146 |
+
return () => {
|
| 147 |
+
if (cleanupFn) cleanupFn();
|
| 148 |
+
};
|
| 149 |
+
}, [issueId]);
|
| 150 |
+
|
| 151 |
+
const handleStreamEvent = (event: any) => {
|
| 152 |
+
if (event.type === "step_started") {
|
| 153 |
+
const { agent_name } = event.data;
|
| 154 |
+
setAgents((prev) =>
|
| 155 |
+
prev.map((a) =>
|
| 156 |
+
a.name === agent_name ? { ...a, status: "running" } : a,
|
| 157 |
+
),
|
| 158 |
+
);
|
| 159 |
+
|
| 160 |
+
const idx = agents.findIndex((a) => a.name === agent_name);
|
| 161 |
+
if (idx !== -1) {
|
| 162 |
+
pulseAnims[idx].setValue(1);
|
| 163 |
+
Animated.loop(
|
| 164 |
+
Animated.sequence([
|
| 165 |
+
Animated.timing(pulseAnims[idx], {
|
| 166 |
+
toValue: 1.1,
|
| 167 |
+
duration: 500,
|
| 168 |
+
useNativeDriver: true,
|
| 169 |
+
}),
|
| 170 |
+
Animated.timing(pulseAnims[idx], {
|
| 171 |
+
toValue: 1,
|
| 172 |
+
duration: 500,
|
| 173 |
+
useNativeDriver: true,
|
| 174 |
+
}),
|
| 175 |
+
]),
|
| 176 |
+
).start();
|
| 177 |
+
}
|
| 178 |
+
} else if (event.type === "step_completed") {
|
| 179 |
+
const { agent_name, decision, reasoning, result } = event.data;
|
| 180 |
+
|
| 181 |
+
setAgents((prev) =>
|
| 182 |
+
prev.map((a) =>
|
| 183 |
+
a.name === agent_name
|
| 184 |
+
? { ...a, status: "done", decision, reasoning }
|
| 185 |
+
: a,
|
| 186 |
+
),
|
| 187 |
+
);
|
| 188 |
+
|
| 189 |
+
const idx = agents.findIndex((a) => a.name === agent_name);
|
| 190 |
+
if (idx !== -1) {
|
| 191 |
+
pulseAnims[idx].stopAnimation();
|
| 192 |
+
pulseAnims[idx].setValue(1);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
if (agent_name === "VisionAgent" && result?.needs_confirmation) {
|
| 196 |
+
showConfirmationDialog();
|
| 197 |
+
}
|
| 198 |
+
} else if (event.type === "flow_completed") {
|
| 199 |
+
setIsComplete(true);
|
| 200 |
+
Animated.timing(progressAnim, {
|
| 201 |
+
toValue: 1,
|
| 202 |
+
duration: 300,
|
| 203 |
+
useNativeDriver: false,
|
| 204 |
+
}).start();
|
| 205 |
+
|
| 206 |
+
if (event.data.final_result?.needs_confirmation) {
|
| 207 |
+
setIsComplete(false);
|
| 208 |
+
showConfirmationDialog();
|
| 209 |
+
}
|
| 210 |
+
} else if (event.type === "flow_error") {
|
| 211 |
+
setError(event.data.error || "Unknown error");
|
| 212 |
+
}
|
| 213 |
+
};
|
| 214 |
+
|
| 215 |
+
useEffect(() => {
|
| 216 |
+
const doneCount = agents.filter((a) => a.status === "done").length;
|
| 217 |
+
const runningCount = agents.filter((a) => a.status === "running").length;
|
| 218 |
+
const total = agents.length;
|
| 219 |
+
|
| 220 |
+
const progress = (doneCount + (runningCount ? 0.5 : 0)) / total;
|
| 221 |
+
Animated.timing(progressAnim, {
|
| 222 |
+
toValue: progress,
|
| 223 |
+
duration: 500,
|
| 224 |
+
useNativeDriver: false,
|
| 225 |
+
}).start();
|
| 226 |
+
}, [agents]);
|
| 227 |
+
|
| 228 |
+
const showConfirmationDialog = () => {
|
| 229 |
+
Alert.alert(
|
| 230 |
+
"Zero Detections Found",
|
| 231 |
+
"Our AI didn't find any specific issues in this image.\n\nDo you want to submit this for manual review?\n\n⚠️ WARNING: False reports may result in account ban.",
|
| 232 |
+
[
|
| 233 |
+
{
|
| 234 |
+
text: "Cancel",
|
| 235 |
+
style: "cancel",
|
| 236 |
+
onPress: async () => {
|
| 237 |
+
try {
|
| 238 |
+
if (issueId) await issueService.confirmIssue(issueId, false);
|
| 239 |
+
navigation.goBack();
|
| 240 |
+
} catch (e) {
|
| 241 |
+
console.error(e);
|
| 242 |
+
}
|
| 243 |
+
},
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
text: "Submit for Manual Review",
|
| 247 |
+
style: "default",
|
| 248 |
+
onPress: async () => {
|
| 249 |
+
try {
|
| 250 |
+
if (issueId) {
|
| 251 |
+
await issueService.confirmIssue(issueId, true);
|
| 252 |
+
setIsComplete(true);
|
| 253 |
+
}
|
| 254 |
+
} catch (e) {
|
| 255 |
+
setError("Failed to confirm issue");
|
| 256 |
+
}
|
| 257 |
+
},
|
| 258 |
+
},
|
| 259 |
+
],
|
| 260 |
+
);
|
| 261 |
+
};
|
| 262 |
+
|
| 263 |
+
const submitIssue = async () => {
|
| 264 |
+
try {
|
| 265 |
+
console.log("[ProcessingScreen] Submitting issue - session present:", !!session, "access_token present:", !!session?.access_token);
|
| 266 |
+
const result = await issueService.createIssue(
|
| 267 |
+
imageUri,
|
| 268 |
+
location,
|
| 269 |
+
description,
|
| 270 |
+
session?.access_token,
|
| 271 |
+
);
|
| 272 |
+
|
| 273 |
+
setIssueId(result.issue_id);
|
| 274 |
+
} catch (err: any) {
|
| 275 |
+
console.error("Submit error:", err);
|
| 276 |
+
setError(err.message || "Failed to submit issue");
|
| 277 |
+
}
|
| 278 |
+
};
|
| 279 |
+
|
| 280 |
+
const handleViewDetails = async () => {
|
| 281 |
+
if (issueId) {
|
| 282 |
+
await cacheService.clearCache();
|
| 283 |
+
navigation.navigate("IssueDetail", { issueId });
|
| 284 |
+
}
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
const handleGoHome = async () => {
|
| 288 |
+
await cacheService.clearCache();
|
| 289 |
+
navigation.reset({
|
| 290 |
+
index: 0,
|
| 291 |
+
routes: [{ name: "MainTabs" }],
|
| 292 |
+
});
|
| 293 |
+
};
|
| 294 |
+
|
| 295 |
+
const progressInterpolate = progressAnim.interpolate({
|
| 296 |
+
inputRange: [0, 1],
|
| 297 |
+
outputRange: ["0%", "100%"],
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
+
const scanTranslateY = scanLineAnim.interpolate({
|
| 301 |
+
inputRange: [0, 1],
|
| 302 |
+
outputRange: [0, 180], // Match image height
|
| 303 |
+
});
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
return (
|
| 307 |
+
<LinearGradient
|
| 308 |
+
colors={[colors.background.primary, colors.background.secondary]}
|
| 309 |
+
style={styles.container}
|
| 310 |
+
>
|
| 311 |
+
<View style={styles.header}>
|
| 312 |
+
<View style={[styles.headerIcon, isComplete && styles.headerIconComplete]}>
|
| 313 |
+
{isComplete ? (
|
| 314 |
+
<Ionicons name="sparkles" size={32} color={colors.secondary.main} />
|
| 315 |
+
) : (
|
| 316 |
+
<MaterialCommunityIcons
|
| 317 |
+
name="robot-outline"
|
| 318 |
+
size={32}
|
| 319 |
+
color={colors.primary.main}
|
| 320 |
+
/>
|
| 321 |
+
)}
|
| 322 |
+
</View>
|
| 323 |
+
<Text style={styles.title}>
|
| 324 |
+
{isComplete ? "Report Processed!" : "AI Analysis in Progress"}
|
| 325 |
+
</Text>
|
| 326 |
+
<Text style={styles.subtitle}>
|
| 327 |
+
{isComplete
|
| 328 |
+
? "Your issue has been categorized and routed."
|
| 329 |
+
: "Our intelligent agents are analyzing your report."}
|
| 330 |
+
</Text>
|
| 331 |
+
</View>
|
| 332 |
+
|
| 333 |
+
<View style={styles.imageContainer}>
|
| 334 |
+
<Image source={{ uri: imageUri }} style={styles.image} />
|
| 335 |
+
{!isComplete && (
|
| 336 |
+
<View style={styles.imageOverlay}>
|
| 337 |
+
<Animated.View
|
| 338 |
+
style={[
|
| 339 |
+
styles.scanLine,
|
| 340 |
+
{ transform: [{ translateY: scanTranslateY }] }
|
| 341 |
+
]}
|
| 342 |
+
/>
|
| 343 |
+
<View style={styles.gridOverlay} />
|
| 344 |
+
</View>
|
| 345 |
+
)}
|
| 346 |
+
</View>
|
| 347 |
+
|
| 348 |
+
<View style={styles.progressContainer}>
|
| 349 |
+
<View style={styles.progressTrack}>
|
| 350 |
+
<Animated.View
|
| 351 |
+
style={[styles.progressFill, { width: progressInterpolate }]}
|
| 352 |
+
/>
|
| 353 |
+
</View>
|
| 354 |
+
<Text style={styles.progressText}>
|
| 355 |
+
{Math.round((agents.filter(a => a.status === 'done').length / agents.length) * 100)}% Complete
|
| 356 |
+
</Text>
|
| 357 |
+
</View>
|
| 358 |
+
|
| 359 |
+
<ScrollView
|
| 360 |
+
style={styles.agentsContainer}
|
| 361 |
+
contentContainerStyle={{ paddingBottom: spacing.xxl }}
|
| 362 |
+
showsVerticalScrollIndicator={false}
|
| 363 |
+
>
|
| 364 |
+
{agents.map((agent, index) => (
|
| 365 |
+
<Animated.View
|
| 366 |
+
key={agent.name}
|
| 367 |
+
style={[
|
| 368 |
+
styles.agentRow,
|
| 369 |
+
{
|
| 370 |
+
transform: [{ scale: pulseAnims[index] }],
|
| 371 |
+
opacity: agent.status === "pending" ? 0.5 : 1,
|
| 372 |
+
borderColor:
|
| 373 |
+
agent.status === "running"
|
| 374 |
+
? colors.primary.main
|
| 375 |
+
: "rgba(255,255,255,0.1)",
|
| 376 |
+
},
|
| 377 |
+
]}
|
| 378 |
+
>
|
| 379 |
+
<View
|
| 380 |
+
style={[
|
| 381 |
+
styles.agentIcon,
|
| 382 |
+
agent.status === "done" && styles.agentIconDone,
|
| 383 |
+
agent.status === "running" && styles.agentIconRunning,
|
| 384 |
+
]}
|
| 385 |
+
>
|
| 386 |
+
<Ionicons
|
| 387 |
+
name={agent.status === "done" ? "checkmark" : agent.iconName}
|
| 388 |
+
size={20}
|
| 389 |
+
color={
|
| 390 |
+
agent.status === "done"
|
| 391 |
+
? colors.secondary.main
|
| 392 |
+
: agent.status === "running"
|
| 393 |
+
? colors.primary.main
|
| 394 |
+
: colors.text.tertiary
|
| 395 |
+
}
|
| 396 |
+
/>
|
| 397 |
+
</View>
|
| 398 |
+
|
| 399 |
+
<View style={styles.agentInfo}>
|
| 400 |
+
<Text
|
| 401 |
+
style={[
|
| 402 |
+
styles.agentLabel,
|
| 403 |
+
agent.status === "running" && styles.agentLabelRunning,
|
| 404 |
+
]}
|
| 405 |
+
>
|
| 406 |
+
{agent.label}
|
| 407 |
+
</Text>
|
| 408 |
+
|
| 409 |
+
{agent.decision ? (
|
| 410 |
+
<Text style={styles.agentDecision}>Result: {agent.decision}</Text>
|
| 411 |
+
) : null}
|
| 412 |
+
</View>
|
| 413 |
+
|
| 414 |
+
{agent.status === "running" && (
|
| 415 |
+
<ActivityIndicator size="small" color={colors.primary.main} />
|
| 416 |
+
)}
|
| 417 |
+
</Animated.View>
|
| 418 |
+
))}
|
| 419 |
+
</ScrollView>
|
| 420 |
+
|
| 421 |
+
{error ? (
|
| 422 |
+
<Card style={styles.errorCard} variant="glass">
|
| 423 |
+
<View style={styles.errorContent}>
|
| 424 |
+
<Ionicons
|
| 425 |
+
name="alert-circle"
|
| 426 |
+
size={24}
|
| 427 |
+
color={colors.status.error}
|
| 428 |
+
/>
|
| 429 |
+
<Text style={styles.errorText}>{error}</Text>
|
| 430 |
+
</View>
|
| 431 |
+
<Button
|
| 432 |
+
title="Retry Upload"
|
| 433 |
+
variant="outline"
|
| 434 |
+
onPress={submitIssue}
|
| 435 |
+
size="sm"
|
| 436 |
+
style={{ borderColor: colors.status.error }}
|
| 437 |
+
textStyle={{ color: colors.status.error }}
|
| 438 |
+
/>
|
| 439 |
+
</Card>
|
| 440 |
+
) : null}
|
| 441 |
+
|
| 442 |
+
{isComplete && (
|
| 443 |
+
<View style={styles.actions}>
|
| 444 |
+
<Button title="View Receipt" onPress={handleViewDetails} fullWidth size="lg" />
|
| 445 |
+
<Button
|
| 446 |
+
title="Return Home"
|
| 447 |
+
variant="ghost"
|
| 448 |
+
onPress={handleGoHome}
|
| 449 |
+
fullWidth
|
| 450 |
+
style={{ marginTop: spacing.sm }}
|
| 451 |
+
/>
|
| 452 |
+
</View>
|
| 453 |
+
)}
|
| 454 |
+
</LinearGradient>
|
| 455 |
+
);
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
const styles = StyleSheet.create({
|
| 459 |
+
container: {
|
| 460 |
+
flex: 1,
|
| 461 |
+
padding: spacing.lg,
|
| 462 |
+
paddingTop: 60,
|
| 463 |
+
},
|
| 464 |
+
header: {
|
| 465 |
+
marginBottom: spacing.xl,
|
| 466 |
+
alignItems: "center",
|
| 467 |
+
},
|
| 468 |
+
headerIcon: {
|
| 469 |
+
width: 64,
|
| 470 |
+
height: 64,
|
| 471 |
+
borderRadius: 32,
|
| 472 |
+
backgroundColor: "rgba(59, 130, 246, 0.1)", // Blue tint
|
| 473 |
+
alignItems: "center",
|
| 474 |
+
justifyContent: "center",
|
| 475 |
+
marginBottom: spacing.md,
|
| 476 |
+
borderWidth: 1,
|
| 477 |
+
borderColor: "rgba(59, 130, 246, 0.2)",
|
| 478 |
+
},
|
| 479 |
+
headerIconComplete: {
|
| 480 |
+
backgroundColor: "rgba(16, 185, 129, 0.1)", // Green tint
|
| 481 |
+
borderColor: "rgba(16, 185, 129, 0.2)",
|
| 482 |
+
},
|
| 483 |
+
title: {
|
| 484 |
+
fontSize: 24,
|
| 485 |
+
fontWeight: "700",
|
| 486 |
+
color: colors.text.primary,
|
| 487 |
+
marginBottom: 4,
|
| 488 |
+
},
|
| 489 |
+
subtitle: {
|
| 490 |
+
fontSize: 14,
|
| 491 |
+
color: colors.text.secondary,
|
| 492 |
+
textAlign: "center",
|
| 493 |
+
maxWidth: '80%',
|
| 494 |
+
},
|
| 495 |
+
imageContainer: {
|
| 496 |
+
height: 200,
|
| 497 |
+
borderRadius: 20,
|
| 498 |
+
overflow: "hidden",
|
| 499 |
+
backgroundColor: colors.background.card,
|
| 500 |
+
marginBottom: spacing.xl,
|
| 501 |
+
borderWidth: 1,
|
| 502 |
+
borderColor: colors.border.light,
|
| 503 |
+
shadowColor: "#000",
|
| 504 |
+
shadowOffset: { width: 0, height: 4 },
|
| 505 |
+
shadowOpacity: 0.1,
|
| 506 |
+
shadowRadius: 10,
|
| 507 |
+
elevation: 5,
|
| 508 |
+
},
|
| 509 |
+
image: {
|
| 510 |
+
width: "100%",
|
| 511 |
+
height: "100%",
|
| 512 |
+
resizeMode: "cover",
|
| 513 |
+
},
|
| 514 |
+
imageOverlay: {
|
| 515 |
+
...StyleSheet.absoluteFillObject,
|
| 516 |
+
backgroundColor: "transparent",
|
| 517 |
+
overflow: 'hidden',
|
| 518 |
+
},
|
| 519 |
+
gridOverlay: {
|
| 520 |
+
...StyleSheet.absoluteFillObject,
|
| 521 |
+
backgroundColor: "transparent",
|
| 522 |
+
// Add grid background image or implementation if feasible, otherwise keep transparent
|
| 523 |
+
},
|
| 524 |
+
scanLine: {
|
| 525 |
+
height: 2,
|
| 526 |
+
backgroundColor: colors.secondary.main, // Green laser
|
| 527 |
+
width: "100%",
|
| 528 |
+
shadowColor: colors.secondary.main,
|
| 529 |
+
shadowOpacity: 1,
|
| 530 |
+
shadowRadius: 10,
|
| 531 |
+
elevation: 5,
|
| 532 |
+
},
|
| 533 |
+
progressContainer: {
|
| 534 |
+
marginBottom: spacing.lg,
|
| 535 |
+
flexDirection: 'row',
|
| 536 |
+
alignItems: 'center',
|
| 537 |
+
gap: 12,
|
| 538 |
+
},
|
| 539 |
+
progressTrack: {
|
| 540 |
+
flex: 1,
|
| 541 |
+
height: 8,
|
| 542 |
+
backgroundColor: "rgba(0,0,0,0.05)",
|
| 543 |
+
borderRadius: 4,
|
| 544 |
+
overflow: "hidden",
|
| 545 |
+
},
|
| 546 |
+
progressFill: {
|
| 547 |
+
height: "100%",
|
| 548 |
+
backgroundColor: colors.primary.main,
|
| 549 |
+
borderRadius: 4,
|
| 550 |
+
},
|
| 551 |
+
progressText: {
|
| 552 |
+
fontSize: 12,
|
| 553 |
+
color: colors.text.secondary,
|
| 554 |
+
fontWeight: "600",
|
| 555 |
+
width: 90,
|
| 556 |
+
textAlign: 'right',
|
| 557 |
+
},
|
| 558 |
+
agentsContainer: {
|
| 559 |
+
flex: 1,
|
| 560 |
+
},
|
| 561 |
+
agentRow: {
|
| 562 |
+
flexDirection: "row",
|
| 563 |
+
alignItems: "center",
|
| 564 |
+
padding: 12,
|
| 565 |
+
borderRadius: 16,
|
| 566 |
+
marginBottom: 8,
|
| 567 |
+
backgroundColor: "rgba(255,255,255,0.6)",
|
| 568 |
+
borderWidth: 1,
|
| 569 |
+
borderColor: "transparent",
|
| 570 |
+
},
|
| 571 |
+
agentIcon: {
|
| 572 |
+
width: 36,
|
| 573 |
+
height: 36,
|
| 574 |
+
borderRadius: 12,
|
| 575 |
+
backgroundColor: colors.background.tertiary,
|
| 576 |
+
alignItems: "center",
|
| 577 |
+
justifyContent: "center",
|
| 578 |
+
marginRight: 12,
|
| 579 |
+
},
|
| 580 |
+
agentIconDone: {
|
| 581 |
+
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
| 582 |
+
},
|
| 583 |
+
agentIconRunning: {
|
| 584 |
+
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
| 585 |
+
},
|
| 586 |
+
agentInfo: {
|
| 587 |
+
flex: 1,
|
| 588 |
+
},
|
| 589 |
+
agentLabel: {
|
| 590 |
+
fontSize: 15,
|
| 591 |
+
fontWeight: "600",
|
| 592 |
+
color: colors.text.primary,
|
| 593 |
+
},
|
| 594 |
+
agentLabelRunning: {
|
| 595 |
+
color: colors.primary.main,
|
| 596 |
+
},
|
| 597 |
+
agentDecision: {
|
| 598 |
+
fontSize: 12,
|
| 599 |
+
color: colors.secondary.main,
|
| 600 |
+
marginTop: 2,
|
| 601 |
+
fontWeight: "500",
|
| 602 |
+
},
|
| 603 |
+
agentReasoning: {
|
| 604 |
+
fontSize: 12,
|
| 605 |
+
color: colors.text.tertiary,
|
| 606 |
+
marginTop: 2,
|
| 607 |
+
},
|
| 608 |
+
errorCard: {
|
| 609 |
+
marginTop: spacing.md,
|
| 610 |
+
backgroundColor: "rgba(254, 226, 226, 0.5)", // Red tint
|
| 611 |
+
borderColor: "rgba(248, 113, 113, 0.3)",
|
| 612 |
+
padding: spacing.md,
|
| 613 |
+
},
|
| 614 |
+
errorContent: {
|
| 615 |
+
flexDirection: "row",
|
| 616 |
+
alignItems: "center",
|
| 617 |
+
marginBottom: spacing.md,
|
| 618 |
+
},
|
| 619 |
+
errorText: {
|
| 620 |
+
fontSize: 14,
|
| 621 |
+
color: colors.status.error,
|
| 622 |
+
marginLeft: spacing.sm,
|
| 623 |
+
flex: 1,
|
| 624 |
+
fontWeight: "500",
|
| 625 |
+
},
|
| 626 |
+
actions: {
|
| 627 |
+
position: 'absolute',
|
| 628 |
+
bottom: 40,
|
| 629 |
+
left: 20,
|
| 630 |
+
right: 20,
|
| 631 |
+
},
|
| 632 |
+
});
|
User/src/screens/home/DashboardScreen.tsx
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, RefreshControl, Platform } from 'react-native';
|
| 3 |
+
import { SafeAreaView } from 'react-native-safe-area-context';
|
| 4 |
+
import { Ionicons } from '@expo/vector-icons';
|
| 5 |
+
import { BlurView } from 'expo-blur';
|
| 6 |
+
import { LinearGradient } from 'expo-linear-gradient';
|
| 7 |
+
import { useNavigation } from '@react-navigation/native';
|
| 8 |
+
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
| 9 |
+
|
| 10 |
+
import { useAuth } from '../../context/AuthContext';
|
| 11 |
+
import { colors, spacing, typography, borderRadius, shadows } from '../../theme';
|
| 12 |
+
import { config } from '../../config/env';
|
| 13 |
+
|
| 14 |
+
// Define navigation types since we are using useNavigation
|
| 15 |
+
type RootStackParamList = {
|
| 16 |
+
Capture: undefined;
|
| 17 |
+
MyIssues: undefined;
|
| 18 |
+
Profile: undefined;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
| 22 |
+
|
| 23 |
+
interface UserStats {
|
| 24 |
+
total_reported: number;
|
| 25 |
+
resolved: number;
|
| 26 |
+
impact_score: number;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export function DashboardScreen() {
|
| 30 |
+
const { user, signOut, session } = useAuth();
|
| 31 |
+
const navigation = useNavigation<NavigationProp>();
|
| 32 |
+
const [stats, setStats] = useState<UserStats | null>(null);
|
| 33 |
+
const [loading, setLoading] = useState(true);
|
| 34 |
+
const [refreshing, setRefreshing] = useState(false);
|
| 35 |
+
|
| 36 |
+
const fetchStats = async () => {
|
| 37 |
+
try {
|
| 38 |
+
const response = await fetch(`${config.API_BASE_URL}/issues/user/stats`, {
|
| 39 |
+
headers: {
|
| 40 |
+
'Authorization': `Bearer ${session?.access_token}`
|
| 41 |
+
}
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
if (response.ok) {
|
| 45 |
+
const data = await response.json();
|
| 46 |
+
setStats(data);
|
| 47 |
+
}
|
| 48 |
+
} catch (error) {
|
| 49 |
+
console.error('Failed to fetch stats', error);
|
| 50 |
+
} finally {
|
| 51 |
+
setLoading(false);
|
| 52 |
+
setRefreshing(false);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
fetchStats();
|
| 58 |
+
}, []);
|
| 59 |
+
|
| 60 |
+
const onRefresh = () => {
|
| 61 |
+
setRefreshing(true);
|
| 62 |
+
fetchStats();
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const StatCard = ({ label, value, icon, color }: { label: string, value: string | number, icon: any, color: string }) => (
|
| 66 |
+
<View style={[styles.statCardContainer, { borderColor: color + '40' }]}>
|
| 67 |
+
<BlurView intensity={20} tint="dark" style={styles.statCard}>
|
| 68 |
+
<View style={[styles.iconContainer, { backgroundColor: color + '20' }]}>
|
| 69 |
+
<Ionicons name={icon} size={24} color={color} />
|
| 70 |
+
</View>
|
| 71 |
+
<Text style={styles.statValue}>{value}</Text>
|
| 72 |
+
<Text style={styles.statLabel}>{label}</Text>
|
| 73 |
+
</BlurView>
|
| 74 |
+
</View>
|
| 75 |
+
);
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<View style={styles.container}>
|
| 79 |
+
<LinearGradient
|
| 80 |
+
colors={colors.background.gradient}
|
| 81 |
+
style={StyleSheet.absoluteFill}
|
| 82 |
+
/>
|
| 83 |
+
|
| 84 |
+
<SafeAreaView style={styles.safeArea}>
|
| 85 |
+
<ScrollView
|
| 86 |
+
contentContainerStyle={styles.scrollContent}
|
| 87 |
+
refreshControl={
|
| 88 |
+
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.primary.main} />
|
| 89 |
+
}
|
| 90 |
+
>
|
| 91 |
+
{/* Header */}
|
| 92 |
+
<View style={styles.header}>
|
| 93 |
+
<View>
|
| 94 |
+
<Text style={styles.greeting}>Welcome back,</Text>
|
| 95 |
+
<Text style={styles.username}>{user?.user_metadata?.full_name?.split(' ')[0] || 'User'}</Text>
|
| 96 |
+
</View>
|
| 97 |
+
<TouchableOpacity onPress={signOut} style={styles.profileButton}>
|
| 98 |
+
<BlurView intensity={30} tint="dark" style={styles.profileIconBlur}>
|
| 99 |
+
<Ionicons name="person" size={20} color={colors.primary.light} />
|
| 100 |
+
</BlurView>
|
| 101 |
+
</TouchableOpacity>
|
| 102 |
+
</View>
|
| 103 |
+
|
| 104 |
+
{/* Stats Grid */}
|
| 105 |
+
<View style={styles.statsGrid}>
|
| 106 |
+
<StatCard
|
| 107 |
+
label="Impact Score"
|
| 108 |
+
value={stats?.impact_score || 0}
|
| 109 |
+
icon="flash"
|
| 110 |
+
color={colors.secondary.main}
|
| 111 |
+
/>
|
| 112 |
+
<StatCard
|
| 113 |
+
label="Issues Fixed"
|
| 114 |
+
value={stats?.resolved || 0}
|
| 115 |
+
icon="checkmark-circle"
|
| 116 |
+
color={colors.status.success}
|
| 117 |
+
/>
|
| 118 |
+
<StatCard
|
| 119 |
+
label="Reported"
|
| 120 |
+
value={stats?.total_reported || 0}
|
| 121 |
+
icon="megaphone"
|
| 122 |
+
color={colors.primary.main}
|
| 123 |
+
/>
|
| 124 |
+
</View>
|
| 125 |
+
|
| 126 |
+
{/* Main Action - REPORT */}
|
| 127 |
+
<TouchableOpacity
|
| 128 |
+
style={styles.mainAction}
|
| 129 |
+
onPress={() => navigation.navigate('Capture')}
|
| 130 |
+
activeOpacity={0.9}
|
| 131 |
+
>
|
| 132 |
+
<LinearGradient
|
| 133 |
+
colors={[colors.secondary.main, colors.secondary.dark]}
|
| 134 |
+
start={{ x: 0, y: 0 }}
|
| 135 |
+
end={{ x: 1, y: 1 }}
|
| 136 |
+
style={styles.actionGradient}
|
| 137 |
+
>
|
| 138 |
+
<Ionicons name="camera" size={32} color="white" />
|
| 139 |
+
<Text style={styles.actionText}>Report New Issue</Text>
|
| 140 |
+
<Ionicons name="arrow-forward" size={24} color="white" style={styles.actionArrow} />
|
| 141 |
+
</LinearGradient>
|
| 142 |
+
</TouchableOpacity>
|
| 143 |
+
|
| 144 |
+
{/* Quick Links */}
|
| 145 |
+
<Text style={styles.sectionTitle}>Quick Access</Text>
|
| 146 |
+
<View style={styles.quickLinks}>
|
| 147 |
+
<TouchableOpacity
|
| 148 |
+
style={styles.linkCard}
|
| 149 |
+
onPress={() => navigation.navigate('MyIssues')}
|
| 150 |
+
>
|
| 151 |
+
<BlurView intensity={20} tint="dark" style={styles.linkContent}>
|
| 152 |
+
<Ionicons name="time" size={24} color={colors.primary.light} />
|
| 153 |
+
<Text style={styles.linkText}>History</Text>
|
| 154 |
+
</BlurView>
|
| 155 |
+
</TouchableOpacity>
|
| 156 |
+
|
| 157 |
+
<TouchableOpacity
|
| 158 |
+
style={styles.linkCard}
|
| 159 |
+
onPress={() => navigation.navigate('Profile')}
|
| 160 |
+
>
|
| 161 |
+
<BlurView intensity={20} tint="dark" style={styles.linkContent}>
|
| 162 |
+
<Ionicons name="settings" size={24} color={colors.text.tertiary} />
|
| 163 |
+
<Text style={styles.linkText}>Settings</Text>
|
| 164 |
+
</BlurView>
|
| 165 |
+
</TouchableOpacity>
|
| 166 |
+
</View>
|
| 167 |
+
|
| 168 |
+
</ScrollView>
|
| 169 |
+
</SafeAreaView>
|
| 170 |
+
</View>
|
| 171 |
+
);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
const styles = StyleSheet.create({
|
| 175 |
+
container: {
|
| 176 |
+
flex: 1,
|
| 177 |
+
backgroundColor: colors.background.primary,
|
| 178 |
+
},
|
| 179 |
+
safeArea: {
|
| 180 |
+
flex: 1,
|
| 181 |
+
},
|
| 182 |
+
scrollContent: {
|
| 183 |
+
padding: spacing.lg,
|
| 184 |
+
paddingBottom: 100,
|
| 185 |
+
},
|
| 186 |
+
header: {
|
| 187 |
+
flexDirection: 'row',
|
| 188 |
+
justifyContent: 'space-between',
|
| 189 |
+
alignItems: 'center',
|
| 190 |
+
marginBottom: spacing.xl,
|
| 191 |
+
},
|
| 192 |
+
greeting: {
|
| 193 |
+
...typography.caption,
|
| 194 |
+
fontSize: 14,
|
| 195 |
+
color: colors.text.secondary,
|
| 196 |
+
},
|
| 197 |
+
username: {
|
| 198 |
+
...typography.h1,
|
| 199 |
+
fontSize: 28,
|
| 200 |
+
},
|
| 201 |
+
profileButton: {
|
| 202 |
+
borderRadius: borderRadius.full,
|
| 203 |
+
overflow: 'hidden',
|
| 204 |
+
},
|
| 205 |
+
profileIconBlur: {
|
| 206 |
+
width: 44,
|
| 207 |
+
height: 44,
|
| 208 |
+
justifyContent: 'center',
|
| 209 |
+
alignItems: 'center',
|
| 210 |
+
backgroundColor: 'rgba(255,255,255,0.05)',
|
| 211 |
+
},
|
| 212 |
+
statsGrid: {
|
| 213 |
+
flexDirection: 'row',
|
| 214 |
+
justifyContent: 'space-between',
|
| 215 |
+
gap: spacing.sm,
|
| 216 |
+
marginBottom: spacing.xl,
|
| 217 |
+
},
|
| 218 |
+
statCardContainer: {
|
| 219 |
+
flex: 1,
|
| 220 |
+
borderRadius: borderRadius.lg,
|
| 221 |
+
overflow: 'hidden',
|
| 222 |
+
borderWidth: 1,
|
| 223 |
+
backgroundColor: 'rgba(30, 41, 59, 0.4)',
|
| 224 |
+
},
|
| 225 |
+
statCard: {
|
| 226 |
+
padding: spacing.md,
|
| 227 |
+
alignItems: 'center',
|
| 228 |
+
height: 110,
|
| 229 |
+
justifyContent: 'center',
|
| 230 |
+
},
|
| 231 |
+
iconContainer: {
|
| 232 |
+
width: 36,
|
| 233 |
+
height: 36,
|
| 234 |
+
borderRadius: borderRadius.full,
|
| 235 |
+
justifyContent: 'center',
|
| 236 |
+
alignItems: 'center',
|
| 237 |
+
marginBottom: spacing.sm,
|
| 238 |
+
},
|
| 239 |
+
statValue: {
|
| 240 |
+
...typography.h2,
|
| 241 |
+
fontSize: 20,
|
| 242 |
+
marginBottom: 2,
|
| 243 |
+
},
|
| 244 |
+
statLabel: {
|
| 245 |
+
...typography.caption,
|
| 246 |
+
fontSize: 10,
|
| 247 |
+
textAlign: 'center',
|
| 248 |
+
},
|
| 249 |
+
mainAction: {
|
| 250 |
+
borderRadius: borderRadius.xl,
|
| 251 |
+
overflow: 'hidden',
|
| 252 |
+
marginBottom: spacing.xl,
|
| 253 |
+
...shadows.glow,
|
| 254 |
+
},
|
| 255 |
+
actionGradient: {
|
| 256 |
+
flexDirection: 'row',
|
| 257 |
+
alignItems: 'center',
|
| 258 |
+
padding: spacing.lg,
|
| 259 |
+
paddingVertical: spacing.xl,
|
| 260 |
+
},
|
| 261 |
+
actionText: {
|
| 262 |
+
...typography.h3,
|
| 263 |
+
color: 'white',
|
| 264 |
+
flex: 1,
|
| 265 |
+
marginLeft: spacing.md,
|
| 266 |
+
},
|
| 267 |
+
actionArrow: {
|
| 268 |
+
opacity: 0.8,
|
| 269 |
+
},
|
| 270 |
+
sectionTitle: {
|
| 271 |
+
...typography.h3,
|
| 272 |
+
fontSize: 18,
|
| 273 |
+
marginBottom: spacing.md,
|
| 274 |
+
color: colors.text.secondary,
|
| 275 |
+
},
|
| 276 |
+
quickLinks: {
|
| 277 |
+
flexDirection: 'row',
|
| 278 |
+
gap: spacing.md,
|
| 279 |
+
},
|
| 280 |
+
linkCard: {
|
| 281 |
+
flex: 1,
|
| 282 |
+
height: 80,
|
| 283 |
+
borderRadius: borderRadius.md,
|
| 284 |
+
overflow: 'hidden',
|
| 285 |
+
borderWidth: 1,
|
| 286 |
+
borderColor: colors.border.accent,
|
| 287 |
+
},
|
| 288 |
+
linkContent: {
|
| 289 |
+
flex: 1,
|
| 290 |
+
flexDirection: 'row',
|
| 291 |
+
alignItems: 'center',
|
| 292 |
+
justifyContent: 'center',
|
| 293 |
+
gap: spacing.sm,
|
| 294 |
+
backgroundColor: 'rgba(30, 41, 59, 0.3)',
|
| 295 |
+
},
|
| 296 |
+
linkText: {
|
| 297 |
+
...typography.button,
|
| 298 |
+
color: colors.text.primary,
|
| 299 |
+
},
|
| 300 |
+
});
|
User/src/screens/home/HomeScreen.tsx
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useCallback } from "react";
|
| 2 |
+
import {
|
| 3 |
+
View,
|
| 4 |
+
Text,
|
| 5 |
+
StyleSheet,
|
| 6 |
+
FlatList,
|
| 7 |
+
RefreshControl,
|
| 8 |
+
TouchableOpacity,
|
| 9 |
+
Dimensions,
|
| 10 |
+
AppState,
|
| 11 |
+
} from "react-native";
|
| 12 |
+
import { useNavigation, useFocusEffect } from "@react-navigation/native";
|
| 13 |
+
import { LinearGradient } from "expo-linear-gradient";
|
| 14 |
+
import * as Location from "expo-location";
|
| 15 |
+
import { Ionicons } from "@expo/vector-icons";
|
| 16 |
+
import { Button } from "../../components/ui/Button";
|
| 17 |
+
import { Card } from "../../components/ui/Card";
|
| 18 |
+
import { Loader } from "../../components/ui/Loader";
|
| 19 |
+
import { IssueCard } from "../../components/issues/IssueCard";
|
| 20 |
+
import { useAuth } from "../../context/AuthContext";
|
| 21 |
+
import { issueService } from "../../services/issueService";
|
| 22 |
+
import { colors, spacing, typography, borderRadius } from "../../theme";
|
| 23 |
+
import { Issue } from "../../types";
|
| 24 |
+
|
| 25 |
+
const { width } = Dimensions.get("window");
|
| 26 |
+
|
| 27 |
+
export function HomeScreen() {
|
| 28 |
+
const navigation = useNavigation<any>();
|
| 29 |
+
const { user, signOut, isDevMode } = useAuth();
|
| 30 |
+
// ... rest of code
|
| 31 |
+
|
| 32 |
+
const [issues, setIssues] = useState<Issue[]>([]);
|
| 33 |
+
const [loading, setLoading] = useState(true);
|
| 34 |
+
const [refreshing, setRefreshing] = useState(false);
|
| 35 |
+
const [locationEnabled, setLocationEnabled] = useState<boolean | null>(null);
|
| 36 |
+
|
| 37 |
+
useFocusEffect(
|
| 38 |
+
useCallback(() => {
|
| 39 |
+
checkLocationServices();
|
| 40 |
+
setRefreshing(true);
|
| 41 |
+
fetchIssues();
|
| 42 |
+
}, [user?.id, isDevMode]),
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
| 47 |
+
if (nextAppState === "active") {
|
| 48 |
+
checkLocationServices();
|
| 49 |
+
}
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
return () => {
|
| 53 |
+
subscription.remove();
|
| 54 |
+
};
|
| 55 |
+
}, []);
|
| 56 |
+
|
| 57 |
+
const checkLocationServices = async () => {
|
| 58 |
+
const enabled = await Location.hasServicesEnabledAsync();
|
| 59 |
+
setLocationEnabled(enabled);
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const fetchIssues = async () => {
|
| 63 |
+
try {
|
| 64 |
+
const userId = isDevMode ? undefined : user?.id;
|
| 65 |
+
const response = await issueService.listIssues(1, 10, undefined, userId);
|
| 66 |
+
setIssues(response.items);
|
| 67 |
+
} catch (error) {
|
| 68 |
+
console.error("Failed to fetch issues:", error);
|
| 69 |
+
} finally {
|
| 70 |
+
setLoading(false);
|
| 71 |
+
setRefreshing(false);
|
| 72 |
+
}
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const handleRefresh = () => {
|
| 76 |
+
setRefreshing(true);
|
| 77 |
+
fetchIssues();
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const handleReportIssue = async () => {
|
| 81 |
+
const enabled = await Location.hasServicesEnabledAsync();
|
| 82 |
+
if (!enabled) {
|
| 83 |
+
setLocationEnabled(false);
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
navigation.navigate("Capture");
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
const handleIssuePress = (issue: Issue) => {
|
| 90 |
+
navigation.navigate("IssueDetail", { issueId: issue.id });
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
const renderHeader = () => (
|
| 94 |
+
<View style={styles.header}>
|
| 95 |
+
<View>
|
| 96 |
+
<Text style={styles.greetingText}>Welcome,</Text>
|
| 97 |
+
<Text style={styles.userName}>
|
| 98 |
+
{user?.user_metadata?.full_name ||
|
| 99 |
+
user?.email?.split("@")[0] ||
|
| 100 |
+
"Citizen"}
|
| 101 |
+
</Text>
|
| 102 |
+
</View>
|
| 103 |
+
|
| 104 |
+
<TouchableOpacity style={styles.profileButton} onPress={signOut}>
|
| 105 |
+
<LinearGradient
|
| 106 |
+
colors={[colors.primary.light, colors.primary.main]}
|
| 107 |
+
style={styles.profileGradient}
|
| 108 |
+
>
|
| 109 |
+
<Ionicons name="person" size={20} color="#FFF" />
|
| 110 |
+
</LinearGradient>
|
| 111 |
+
</TouchableOpacity>
|
| 112 |
+
</View>
|
| 113 |
+
);
|
| 114 |
+
|
| 115 |
+
const renderQuickActions = () => (
|
| 116 |
+
<View style={styles.quickActions}>
|
| 117 |
+
<TouchableOpacity onPress={handleReportIssue} activeOpacity={0.9}>
|
| 118 |
+
<View style={styles.reportCard}>
|
| 119 |
+
<LinearGradient
|
| 120 |
+
colors={[colors.primary.main, colors.primary.dark]}
|
| 121 |
+
start={{ x: 0, y: 0 }}
|
| 122 |
+
end={{ x: 1, y: 1 }}
|
| 123 |
+
style={styles.reportCardGradient}
|
| 124 |
+
>
|
| 125 |
+
<View style={styles.reportCardContent}>
|
| 126 |
+
<View style={styles.reportInfo}>
|
| 127 |
+
<Text style={styles.reportTitle}>Report New Issue</Text>
|
| 128 |
+
<Text style={styles.reportSubtitle}>
|
| 129 |
+
Snap a photo • AI Analysis • Track Fixes
|
| 130 |
+
</Text>
|
| 131 |
+
</View>
|
| 132 |
+
<View style={{
|
| 133 |
+
backgroundColor: 'rgba(255,255,255,0.2)',
|
| 134 |
+
padding: 10,
|
| 135 |
+
borderRadius: 12,
|
| 136 |
+
borderWidth: 1,
|
| 137 |
+
borderColor: 'rgba(255,255,255,0.3)'
|
| 138 |
+
}}>
|
| 139 |
+
<Ionicons name="camera" size={24} color="#FFF" />
|
| 140 |
+
</View>
|
| 141 |
+
</View>
|
| 142 |
+
</LinearGradient>
|
| 143 |
+
</View>
|
| 144 |
+
</TouchableOpacity>
|
| 145 |
+
|
| 146 |
+
{locationEnabled === false && (
|
| 147 |
+
<Card style={styles.gpsWarning}>
|
| 148 |
+
<View style={{flexDirection: 'row', alignItems: 'center', flex: 1}}>
|
| 149 |
+
<Ionicons name="location" size={24} color="#EF4444" />
|
| 150 |
+
<Text style={styles.gpsWarningText}>
|
| 151 |
+
Enable Location Services to report issues nearby.
|
| 152 |
+
</Text>
|
| 153 |
+
</View>
|
| 154 |
+
<Button
|
| 155 |
+
title="Enable"
|
| 156 |
+
variant="ghost"
|
| 157 |
+
size="sm"
|
| 158 |
+
onPress={async () => {
|
| 159 |
+
try {
|
| 160 |
+
await Location.enableNetworkProviderAsync();
|
| 161 |
+
setTimeout(checkLocationServices, 1000);
|
| 162 |
+
} catch (e) {
|
| 163 |
+
checkLocationServices();
|
| 164 |
+
}
|
| 165 |
+
}}
|
| 166 |
+
textStyle={{ color: "#EF4444", fontWeight: '700' }}
|
| 167 |
+
style={{ backgroundColor: 'rgba(239,68,68,0.1)' }}
|
| 168 |
+
/>
|
| 169 |
+
</Card>
|
| 170 |
+
)}
|
| 171 |
+
</View>
|
| 172 |
+
);
|
| 173 |
+
|
| 174 |
+
const renderStats = () => (
|
| 175 |
+
<View style={styles.statsContainer}>
|
| 176 |
+
<View style={styles.statCard}>
|
| 177 |
+
<Text style={[styles.statValue, { color: colors.primary.main }]}>{issues.length}</Text>
|
| 178 |
+
<Text style={styles.statLabel}>Total</Text>
|
| 179 |
+
</View>
|
| 180 |
+
<View style={styles.statCard}>
|
| 181 |
+
<Text style={[styles.statValue, { color: '#10B981' }]}>
|
| 182 |
+
{issues.filter((i) => i.state === "resolved").length}
|
| 183 |
+
</Text>
|
| 184 |
+
<Text style={styles.statLabel}>Fixed</Text>
|
| 185 |
+
</View>
|
| 186 |
+
<View style={styles.statCard}>
|
| 187 |
+
<Text style={[styles.statValue, { color: '#F59E0B' }]}>
|
| 188 |
+
{
|
| 189 |
+
issues.filter((i) => ["assigned", "in_progress"].includes(i.state))
|
| 190 |
+
.length
|
| 191 |
+
}
|
| 192 |
+
</Text>
|
| 193 |
+
<Text style={styles.statLabel}>Pending</Text>
|
| 194 |
+
</View>
|
| 195 |
+
</View>
|
| 196 |
+
);
|
| 197 |
+
|
| 198 |
+
const renderRecentHeader = () => (
|
| 199 |
+
<View style={styles.sectionHeader}>
|
| 200 |
+
<Text style={styles.sectionTitle}>Recent Updates</Text>
|
| 201 |
+
<TouchableOpacity onPress={() => navigation.navigate("MyIssues")}>
|
| 202 |
+
<Text style={styles.seeAllText}>See All</Text>
|
| 203 |
+
</TouchableOpacity>
|
| 204 |
+
</View>
|
| 205 |
+
);
|
| 206 |
+
|
| 207 |
+
const renderEmpty = () => (
|
| 208 |
+
<Card style={styles.emptyCard} variant="glass">
|
| 209 |
+
<View style={styles.emptyIconBg}>
|
| 210 |
+
<Ionicons
|
| 211 |
+
name="images-outline"
|
| 212 |
+
size={32}
|
| 213 |
+
color={colors.primary.main}
|
| 214 |
+
/>
|
| 215 |
+
</View>
|
| 216 |
+
<Text style={styles.emptyTitle}>No Reports Found</Text>
|
| 217 |
+
<Text style={styles.emptyText}>
|
| 218 |
+
You haven't reported any issues yet.{"\n"}Help improve your city today!
|
| 219 |
+
</Text>
|
| 220 |
+
</Card>
|
| 221 |
+
);
|
| 222 |
+
|
| 223 |
+
if (loading && !refreshing) {
|
| 224 |
+
return <Loader fullScreen size="large" />;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
return (
|
| 228 |
+
<LinearGradient
|
| 229 |
+
colors={[colors.background.primary, "#E2E8F0", colors.background.tertiary]}
|
| 230 |
+
style={styles.container}
|
| 231 |
+
>
|
| 232 |
+
<FlatList
|
| 233 |
+
data={issues.slice(0, 5)}
|
| 234 |
+
keyExtractor={(item) => item.id}
|
| 235 |
+
renderItem={({ item }) => (
|
| 236 |
+
<IssueCard issue={item} onPress={() => handleIssuePress(item)} />
|
| 237 |
+
)}
|
| 238 |
+
ListHeaderComponent={
|
| 239 |
+
<View style={styles.headerContainer}>
|
| 240 |
+
{renderHeader()}
|
| 241 |
+
{renderQuickActions()}
|
| 242 |
+
{renderStats()}
|
| 243 |
+
{issues.length > 0 && renderRecentHeader()}
|
| 244 |
+
</View>
|
| 245 |
+
}
|
| 246 |
+
ListEmptyComponent={!loading ? renderEmpty : null}
|
| 247 |
+
contentContainerStyle={styles.listContent}
|
| 248 |
+
refreshControl={
|
| 249 |
+
<RefreshControl
|
| 250 |
+
refreshing={refreshing}
|
| 251 |
+
onRefresh={handleRefresh}
|
| 252 |
+
tintColor={colors.primary.main}
|
| 253 |
+
/>
|
| 254 |
+
}
|
| 255 |
+
showsVerticalScrollIndicator={false}
|
| 256 |
+
/>
|
| 257 |
+
</LinearGradient>
|
| 258 |
+
);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
const styles = StyleSheet.create({
|
| 262 |
+
container: {
|
| 263 |
+
flex: 1,
|
| 264 |
+
},
|
| 265 |
+
listContent: {
|
| 266 |
+
padding: spacing.lg,
|
| 267 |
+
paddingTop: 60, // Reduced from 80
|
| 268 |
+
},
|
| 269 |
+
headerContainer: {
|
| 270 |
+
marginBottom: spacing.lg,
|
| 271 |
+
},
|
| 272 |
+
header: {
|
| 273 |
+
flexDirection: "row",
|
| 274 |
+
justifyContent: "space-between",
|
| 275 |
+
alignItems: "center",
|
| 276 |
+
marginBottom: spacing.xl,
|
| 277 |
+
},
|
| 278 |
+
greetingText: {
|
| 279 |
+
fontSize: 14,
|
| 280 |
+
color: colors.text.secondary,
|
| 281 |
+
fontWeight: "500",
|
| 282 |
+
},
|
| 283 |
+
userName: {
|
| 284 |
+
fontSize: 22,
|
| 285 |
+
fontWeight: "700",
|
| 286 |
+
color: colors.text.primary,
|
| 287 |
+
letterSpacing: -0.5,
|
| 288 |
+
},
|
| 289 |
+
profileButton: {
|
| 290 |
+
shadowColor: colors.primary.main,
|
| 291 |
+
shadowOffset: { width: 0, height: 4 },
|
| 292 |
+
shadowOpacity: 0.2,
|
| 293 |
+
shadowRadius: 8,
|
| 294 |
+
elevation: 4,
|
| 295 |
+
},
|
| 296 |
+
profileGradient: {
|
| 297 |
+
width: 44,
|
| 298 |
+
height: 44,
|
| 299 |
+
borderRadius: 14,
|
| 300 |
+
alignItems: "center",
|
| 301 |
+
justifyContent: "center",
|
| 302 |
+
},
|
| 303 |
+
quickActions: {
|
| 304 |
+
marginBottom: spacing.xl,
|
| 305 |
+
},
|
| 306 |
+
reportCard: {
|
| 307 |
+
marginBottom: spacing.md,
|
| 308 |
+
borderRadius: 20,
|
| 309 |
+
padding: 0, // Reset padding for internal gradient
|
| 310 |
+
overflow: 'hidden', // Ensure gradient is clipped
|
| 311 |
+
borderWidth: 1,
|
| 312 |
+
borderColor: 'rgba(255,255,255,0.2)',
|
| 313 |
+
elevation: 8,
|
| 314 |
+
shadowColor: colors.primary.main,
|
| 315 |
+
shadowOffset: { width: 0, height: 8 },
|
| 316 |
+
shadowOpacity: 0.3,
|
| 317 |
+
shadowRadius: 12,
|
| 318 |
+
},
|
| 319 |
+
reportCardGradient: {
|
| 320 |
+
padding: 20,
|
| 321 |
+
width: '100%',
|
| 322 |
+
},
|
| 323 |
+
reportCardContent: {
|
| 324 |
+
flexDirection: "row",
|
| 325 |
+
alignItems: "center",
|
| 326 |
+
justifyContent: "space-between",
|
| 327 |
+
},
|
| 328 |
+
reportInfo: {
|
| 329 |
+
flex: 1,
|
| 330 |
+
marginRight: spacing.md,
|
| 331 |
+
},
|
| 332 |
+
reportTitle: {
|
| 333 |
+
fontSize: 20,
|
| 334 |
+
fontWeight: "800",
|
| 335 |
+
color: "#FFF",
|
| 336 |
+
marginBottom: 4,
|
| 337 |
+
shadowColor: "#000",
|
| 338 |
+
shadowOffset: { width: 0, height: 1 },
|
| 339 |
+
shadowOpacity: 0.2,
|
| 340 |
+
shadowRadius: 2,
|
| 341 |
+
},
|
| 342 |
+
reportSubtitle: {
|
| 343 |
+
fontSize: 13,
|
| 344 |
+
color: "rgba(255,255,255,0.9)",
|
| 345 |
+
fontWeight: "600",
|
| 346 |
+
},
|
| 347 |
+
gpsWarning: {
|
| 348 |
+
flexDirection: "row",
|
| 349 |
+
alignItems: "center",
|
| 350 |
+
justifyContent: "space-between",
|
| 351 |
+
backgroundColor: "#FFFFFF",
|
| 352 |
+
borderColor: "#EF4444", // Red-500
|
| 353 |
+
borderWidth: 1,
|
| 354 |
+
padding: spacing.md,
|
| 355 |
+
borderRadius: 12, // Reduced radius for "alert" feel
|
| 356 |
+
shadowColor: "#EF4444",
|
| 357 |
+
shadowOpacity: 0.1,
|
| 358 |
+
shadowRadius: 4,
|
| 359 |
+
elevation: 3,
|
| 360 |
+
},
|
| 361 |
+
gpsWarningText: {
|
| 362 |
+
flex: 1,
|
| 363 |
+
flexShrink: 1, // Fix overflow
|
| 364 |
+
color: "#EF4444",
|
| 365 |
+
fontSize: 13,
|
| 366 |
+
fontWeight: "600",
|
| 367 |
+
marginLeft: 10,
|
| 368 |
+
marginRight: 10,
|
| 369 |
+
},
|
| 370 |
+
statsContainer: {
|
| 371 |
+
flexDirection: "row",
|
| 372 |
+
marginBottom: spacing.xl,
|
| 373 |
+
gap: spacing.md,
|
| 374 |
+
},
|
| 375 |
+
statCard: {
|
| 376 |
+
flex: 1,
|
| 377 |
+
backgroundColor: "rgba(255,255,255,0.9)", // Increased opacity
|
| 378 |
+
borderRadius: 16,
|
| 379 |
+
padding: spacing.md,
|
| 380 |
+
alignItems: "center",
|
| 381 |
+
borderWidth: 1,
|
| 382 |
+
borderColor: "#E2E8F0", // Slate 200 border
|
| 383 |
+
shadowColor: "#64748B", // Slate 500 shadow
|
| 384 |
+
shadowOffset: { width: 0, height: 4 },
|
| 385 |
+
shadowOpacity: 0.1,
|
| 386 |
+
shadowRadius: 8,
|
| 387 |
+
elevation: 3,
|
| 388 |
+
},
|
| 389 |
+
statValue: {
|
| 390 |
+
fontSize: 20,
|
| 391 |
+
fontWeight: "800",
|
| 392 |
+
marginBottom: 2,
|
| 393 |
+
},
|
| 394 |
+
statLabel: {
|
| 395 |
+
fontSize: 11,
|
| 396 |
+
color: colors.text.secondary,
|
| 397 |
+
fontWeight: "600",
|
| 398 |
+
textTransform: "uppercase",
|
| 399 |
+
},
|
| 400 |
+
sectionHeader: {
|
| 401 |
+
flexDirection: "row",
|
| 402 |
+
justifyContent: "space-between",
|
| 403 |
+
alignItems: "center",
|
| 404 |
+
marginBottom: spacing.md,
|
| 405 |
+
paddingHorizontal: 4,
|
| 406 |
+
},
|
| 407 |
+
sectionTitle: {
|
| 408 |
+
fontSize: 18,
|
| 409 |
+
fontWeight: "700",
|
| 410 |
+
color: colors.text.primary,
|
| 411 |
+
},
|
| 412 |
+
seeAllText: {
|
| 413 |
+
color: colors.primary.main,
|
| 414 |
+
fontSize: 14,
|
| 415 |
+
fontWeight: "600",
|
| 416 |
+
},
|
| 417 |
+
emptyCard: {
|
| 418 |
+
alignItems: "center",
|
| 419 |
+
padding: spacing.xl * 1.5,
|
| 420 |
+
backgroundColor: "rgba(255,255,255,0.5)",
|
| 421 |
+
borderStyle: 'dashed',
|
| 422 |
+
borderColor: colors.border.medium || "rgba(0,0,0,0.1)",
|
| 423 |
+
},
|
| 424 |
+
emptyIconBg: {
|
| 425 |
+
width: 64,
|
| 426 |
+
height: 64,
|
| 427 |
+
borderRadius: 32,
|
| 428 |
+
backgroundColor: colors.primary.main + "10",
|
| 429 |
+
alignItems: "center",
|
| 430 |
+
justifyContent: "center",
|
| 431 |
+
marginBottom: spacing.md,
|
| 432 |
+
},
|
| 433 |
+
emptyTitle: {
|
| 434 |
+
fontSize: 16,
|
| 435 |
+
fontWeight: "700",
|
| 436 |
+
color: colors.text.primary,
|
| 437 |
+
marginBottom: spacing.xs,
|
| 438 |
+
},
|
| 439 |
+
emptyText: {
|
| 440 |
+
fontSize: 14,
|
| 441 |
+
color: colors.text.secondary,
|
| 442 |
+
textAlign: "center",
|
| 443 |
+
lineHeight: 20,
|
| 444 |
+
},
|
| 445 |
+
});
|
User/src/screens/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { LoginScreen } from './auth/LoginScreen';
|
| 2 |
+
export { HomeScreen } from './home/HomeScreen';
|
| 3 |
+
export { CaptureScreen } from './capture/CaptureScreen';
|
| 4 |
+
export { ProcessingScreen } from './capture/ProcessingScreen';
|
| 5 |
+
export { MyIssuesScreen } from './issues/MyIssuesScreen';
|
| 6 |
+
export { IssueDetailScreen } from './issues/IssueDetailScreen';
|
| 7 |
+
export { ProfileScreen } from './profile/ProfileScreen';
|
User/src/screens/issues/IssueDetailScreen.tsx
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
View,
|
| 4 |
+
Text,
|
| 5 |
+
StyleSheet,
|
| 6 |
+
ScrollView,
|
| 7 |
+
Image,
|
| 8 |
+
Dimensions,
|
| 9 |
+
TouchableOpacity,
|
| 10 |
+
ActivityIndicator,
|
| 11 |
+
} from 'react-native';
|
| 12 |
+
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
|
| 13 |
+
import { LinearGradient } from 'expo-linear-gradient';
|
| 14 |
+
import { Ionicons } from '@expo/vector-icons';
|
| 15 |
+
import { Card } from '../../components/ui/Card';
|
| 16 |
+
import { Button } from '../../components/ui/Button';
|
| 17 |
+
import { issueService } from '../../services/issueService';
|
| 18 |
+
import { colors, spacing, typography, borderRadius } from '../../theme';
|
| 19 |
+
import { Issue } from '../../types';
|
| 20 |
+
|
| 21 |
+
const { width } = Dimensions.get('window');
|
| 22 |
+
|
| 23 |
+
type IssueDetailRouteParams = {
|
| 24 |
+
IssueDetail: {
|
| 25 |
+
issueId: string;
|
| 26 |
+
};
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const priorityConfig: Record<number, { color: string; label: string }> = {
|
| 30 |
+
1: { color: colors.priority.critical, label: 'CRITICAL' },
|
| 31 |
+
2: { color: colors.priority.high, label: 'HIGH' },
|
| 32 |
+
3: { color: colors.priority.medium, label: 'MEDIUM' },
|
| 33 |
+
4: { color: colors.priority.low, label: 'LOW' },
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const stateConfig: Record<string, { color: string; label: string; iconName: keyof typeof Ionicons.glyphMap }> = {
|
| 37 |
+
reported: { color: colors.status.info, label: 'Reported', iconName: 'document-text' },
|
| 38 |
+
validated: { color: colors.accent.purple, label: 'Validated', iconName: 'checkmark-circle' },
|
| 39 |
+
assigned: { color: colors.accent.cyan, label: 'Assigned', iconName: 'person' },
|
| 40 |
+
in_progress: { color: colors.status.warning, label: 'In Progress', iconName: 'construct' },
|
| 41 |
+
resolved: { color: colors.status.success, label: 'Resolved', iconName: 'checkmark-done-circle' },
|
| 42 |
+
closed: { color: colors.text.tertiary, label: 'Closed', iconName: 'archive' },
|
| 43 |
+
rejected: { color: colors.status.error, label: 'Rejected', iconName: 'close-circle' },
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
export function IssueDetailScreen() {
|
| 47 |
+
const route = useRoute<RouteProp<IssueDetailRouteParams, 'IssueDetail'>>();
|
| 48 |
+
const navigation = useNavigation();
|
| 49 |
+
|
| 50 |
+
const { issueId } = route.params;
|
| 51 |
+
|
| 52 |
+
const [issue, setIssue] = useState<Issue | null>(null);
|
| 53 |
+
const [loading, setLoading] = useState(true);
|
| 54 |
+
const [activeImage, setActiveImage] = useState(0);
|
| 55 |
+
const [showAnnotated, setShowAnnotated] = useState(true);
|
| 56 |
+
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
fetchIssue();
|
| 59 |
+
}, [issueId]);
|
| 60 |
+
|
| 61 |
+
const fetchIssue = async () => {
|
| 62 |
+
try {
|
| 63 |
+
const data = await issueService.getIssue(issueId);
|
| 64 |
+
setIssue(data);
|
| 65 |
+
} catch (error) {
|
| 66 |
+
console.error('Failed to fetch issue:', error);
|
| 67 |
+
} finally {
|
| 68 |
+
setLoading(false);
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const formatDate = (dateStr: string) => {
|
| 73 |
+
const date = new Date(dateStr);
|
| 74 |
+
return date.toLocaleDateString('en-US', {
|
| 75 |
+
weekday: 'long',
|
| 76 |
+
year: 'numeric',
|
| 77 |
+
month: 'long',
|
| 78 |
+
day: 'numeric',
|
| 79 |
+
hour: '2-digit',
|
| 80 |
+
minute: '2-digit',
|
| 81 |
+
});
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
if (loading) {
|
| 85 |
+
return (
|
| 86 |
+
<View style={styles.loadingContainer}>
|
| 87 |
+
<ActivityIndicator size="large" color={colors.primary.main} />
|
| 88 |
+
</View>
|
| 89 |
+
);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
if (!issue) {
|
| 93 |
+
return (
|
| 94 |
+
<View style={styles.errorContainer}>
|
| 95 |
+
<Ionicons name="sad-outline" size={64} color={colors.text.tertiary} />
|
| 96 |
+
<Text style={styles.errorText}>Issue not found</Text>
|
| 97 |
+
<Button title="Go Back" onPress={() => navigation.goBack()} />
|
| 98 |
+
</View>
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const priorityInfo = issue.priority ? priorityConfig[issue.priority] : null;
|
| 103 |
+
const stateInfo = stateConfig[issue.state] || stateConfig.reported;
|
| 104 |
+
const displayImages = showAnnotated && issue.annotated_urls.length > 0
|
| 105 |
+
? issue.annotated_urls
|
| 106 |
+
: issue.image_urls;
|
| 107 |
+
|
| 108 |
+
return (
|
| 109 |
+
<LinearGradient
|
| 110 |
+
colors={[colors.background.primary, colors.background.secondary]}
|
| 111 |
+
style={styles.container}
|
| 112 |
+
>
|
| 113 |
+
<ScrollView showsVerticalScrollIndicator={false}>
|
| 114 |
+
<View style={styles.imageContainer}>
|
| 115 |
+
{displayImages.length > 0 ? (
|
| 116 |
+
<Image
|
| 117 |
+
source={{ uri: displayImages[activeImage] }}
|
| 118 |
+
style={styles.mainImage}
|
| 119 |
+
resizeMode="cover"
|
| 120 |
+
/>
|
| 121 |
+
) : (
|
| 122 |
+
<View style={[styles.mainImage, styles.placeholderImage]}>
|
| 123 |
+
<Ionicons name="camera" size={48} color={colors.text.tertiary} />
|
| 124 |
+
</View>
|
| 125 |
+
)}
|
| 126 |
+
|
| 127 |
+
<LinearGradient
|
| 128 |
+
colors={['transparent', 'rgba(0,0,0,0.7)']}
|
| 129 |
+
style={styles.imageGradient}
|
| 130 |
+
/>
|
| 131 |
+
|
| 132 |
+
<TouchableOpacity
|
| 133 |
+
style={styles.backButton}
|
| 134 |
+
onPress={() => navigation.goBack()}
|
| 135 |
+
>
|
| 136 |
+
<Ionicons name="arrow-back" size={24} color={colors.text.primary} />
|
| 137 |
+
</TouchableOpacity>
|
| 138 |
+
|
| 139 |
+
{issue.annotated_urls.length > 0 && (
|
| 140 |
+
<View style={styles.imageToggle}>
|
| 141 |
+
<TouchableOpacity
|
| 142 |
+
style={[styles.toggleButton, !showAnnotated && styles.toggleActive]}
|
| 143 |
+
onPress={() => setShowAnnotated(false)}
|
| 144 |
+
>
|
| 145 |
+
<Text style={styles.toggleText}>Original</Text>
|
| 146 |
+
</TouchableOpacity>
|
| 147 |
+
<TouchableOpacity
|
| 148 |
+
style={[styles.toggleButton, showAnnotated && styles.toggleActive]}
|
| 149 |
+
onPress={() => setShowAnnotated(true)}
|
| 150 |
+
>
|
| 151 |
+
<Text style={styles.toggleText}>AI View</Text>
|
| 152 |
+
</TouchableOpacity>
|
| 153 |
+
</View>
|
| 154 |
+
)}
|
| 155 |
+
</View>
|
| 156 |
+
|
| 157 |
+
<View style={styles.content}>
|
| 158 |
+
<View style={styles.badges}>
|
| 159 |
+
{priorityInfo ? (
|
| 160 |
+
<View style={[styles.badge, { backgroundColor: priorityInfo.color }]}>
|
| 161 |
+
<Text style={styles.badgeText}>{priorityInfo.label}</Text>
|
| 162 |
+
</View>
|
| 163 |
+
) : null}
|
| 164 |
+
<View style={[styles.badge, styles.stateBadge, { borderColor: stateInfo.color }]}>
|
| 165 |
+
<Ionicons name={stateInfo.iconName} size={14} color={stateInfo.color} />
|
| 166 |
+
<Text style={[styles.badgeText, { color: stateInfo.color }]}>
|
| 167 |
+
{stateInfo.label}
|
| 168 |
+
</Text>
|
| 169 |
+
</View>
|
| 170 |
+
</View>
|
| 171 |
+
|
| 172 |
+
{issue.category ? (
|
| 173 |
+
<Text style={styles.category}>{issue.category}</Text>
|
| 174 |
+
) : null}
|
| 175 |
+
|
| 176 |
+
{issue.confidence !== undefined && issue.confidence !== null ? (
|
| 177 |
+
<Card style={styles.confidenceCard} variant="glass">
|
| 178 |
+
<View style={styles.confidenceRow}>
|
| 179 |
+
<Text style={styles.confidenceLabel}>AI Confidence</Text>
|
| 180 |
+
<Text style={styles.confidenceValue}>
|
| 181 |
+
{(issue.confidence * 100).toFixed(0)}%
|
| 182 |
+
</Text>
|
| 183 |
+
</View>
|
| 184 |
+
<View style={styles.confidenceBar}>
|
| 185 |
+
<View
|
| 186 |
+
style={[
|
| 187 |
+
styles.confidenceFill,
|
| 188 |
+
{ width: `${issue.confidence * 100}%` }
|
| 189 |
+
]}
|
| 190 |
+
/>
|
| 191 |
+
</View>
|
| 192 |
+
</Card>
|
| 193 |
+
) : null}
|
| 194 |
+
|
| 195 |
+
{issue.description ? (
|
| 196 |
+
<Card style={styles.descriptionCard} variant="glass">
|
| 197 |
+
<Text style={styles.sectionTitle}>Description</Text>
|
| 198 |
+
<Text style={styles.description}>{issue.description}</Text>
|
| 199 |
+
</Card>
|
| 200 |
+
) : null}
|
| 201 |
+
|
| 202 |
+
<Card style={styles.detailsCard} variant="glass">
|
| 203 |
+
<Text style={styles.sectionTitle}>Details</Text>
|
| 204 |
+
|
| 205 |
+
<View style={styles.detailRow}>
|
| 206 |
+
<View style={styles.detailLabelContainer}>
|
| 207 |
+
<Ionicons name="location" size={16} color={colors.text.secondary} />
|
| 208 |
+
<Text style={styles.detailLabel}>Location</Text>
|
| 209 |
+
</View>
|
| 210 |
+
<Text style={styles.detailValue}>
|
| 211 |
+
{issue.latitude.toFixed(6)}, {issue.longitude.toFixed(6)}
|
| 212 |
+
</Text>
|
| 213 |
+
</View>
|
| 214 |
+
|
| 215 |
+
<View style={styles.detailRow}>
|
| 216 |
+
<View style={styles.detailLabelContainer}>
|
| 217 |
+
<Ionicons name="time" size={16} color={colors.text.secondary} />
|
| 218 |
+
<Text style={styles.detailLabel}>Reported</Text>
|
| 219 |
+
</View>
|
| 220 |
+
<Text style={styles.detailValue}>{formatDate(issue.created_at)}</Text>
|
| 221 |
+
</View>
|
| 222 |
+
|
| 223 |
+
{issue.is_duplicate ? (
|
| 224 |
+
<View style={styles.detailRow}>
|
| 225 |
+
<View style={styles.detailLabelContainer}>
|
| 226 |
+
<Ionicons name="link" size={16} color={colors.status.warning} />
|
| 227 |
+
<Text style={styles.detailLabel}>Status</Text>
|
| 228 |
+
</View>
|
| 229 |
+
<Text style={[styles.detailValue, { color: colors.status.warning }]}>
|
| 230 |
+
Linked to existing report
|
| 231 |
+
</Text>
|
| 232 |
+
</View>
|
| 233 |
+
) : null}
|
| 234 |
+
|
| 235 |
+
{issue.geo_status ? (
|
| 236 |
+
<View style={styles.detailRow}>
|
| 237 |
+
<View style={styles.detailLabelContainer}>
|
| 238 |
+
<Ionicons name="analytics" size={16} color={colors.text.secondary} />
|
| 239 |
+
<Text style={styles.detailLabel}>Geo Status</Text>
|
| 240 |
+
</View>
|
| 241 |
+
<Text style={styles.detailValue}>{issue.geo_status}</Text>
|
| 242 |
+
</View>
|
| 243 |
+
) : null}
|
| 244 |
+
</Card>
|
| 245 |
+
|
| 246 |
+
<View style={styles.timeline}>
|
| 247 |
+
<Text style={styles.sectionTitle}>Status Timeline</Text>
|
| 248 |
+
<View style={styles.timelineItem}>
|
| 249 |
+
<View style={[styles.timelineDot, { backgroundColor: stateInfo.color }]} />
|
| 250 |
+
<View style={styles.timelineContent}>
|
| 251 |
+
<Text style={styles.timelineTitle}>{stateInfo.label}</Text>
|
| 252 |
+
<Text style={styles.timelineDate}>{formatDate(issue.updated_at)}</Text>
|
| 253 |
+
</View>
|
| 254 |
+
</View>
|
| 255 |
+
</View>
|
| 256 |
+
</View>
|
| 257 |
+
</ScrollView>
|
| 258 |
+
</LinearGradient>
|
| 259 |
+
);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
const styles = StyleSheet.create({
|
| 263 |
+
container: {
|
| 264 |
+
flex: 1,
|
| 265 |
+
},
|
| 266 |
+
loadingContainer: {
|
| 267 |
+
flex: 1,
|
| 268 |
+
backgroundColor: colors.background.primary,
|
| 269 |
+
justifyContent: 'center',
|
| 270 |
+
alignItems: 'center',
|
| 271 |
+
},
|
| 272 |
+
errorContainer: {
|
| 273 |
+
flex: 1,
|
| 274 |
+
backgroundColor: colors.background.primary,
|
| 275 |
+
justifyContent: 'center',
|
| 276 |
+
alignItems: 'center',
|
| 277 |
+
padding: spacing.xl,
|
| 278 |
+
},
|
| 279 |
+
errorText: {
|
| 280 |
+
...typography.h3,
|
| 281 |
+
color: colors.text.primary,
|
| 282 |
+
marginBottom: spacing.xl,
|
| 283 |
+
marginTop: spacing.lg,
|
| 284 |
+
},
|
| 285 |
+
imageContainer: {
|
| 286 |
+
width: width,
|
| 287 |
+
height: 300,
|
| 288 |
+
position: 'relative',
|
| 289 |
+
},
|
| 290 |
+
mainImage: {
|
| 291 |
+
width: '100%',
|
| 292 |
+
height: '100%',
|
| 293 |
+
},
|
| 294 |
+
placeholderImage: {
|
| 295 |
+
backgroundColor: colors.background.tertiary,
|
| 296 |
+
alignItems: 'center',
|
| 297 |
+
justifyContent: 'center',
|
| 298 |
+
},
|
| 299 |
+
imageGradient: {
|
| 300 |
+
position: 'absolute',
|
| 301 |
+
bottom: 0,
|
| 302 |
+
left: 0,
|
| 303 |
+
right: 0,
|
| 304 |
+
height: 100,
|
| 305 |
+
},
|
| 306 |
+
backButton: {
|
| 307 |
+
position: 'absolute',
|
| 308 |
+
top: spacing.xxl,
|
| 309 |
+
left: spacing.lg,
|
| 310 |
+
width: 44,
|
| 311 |
+
height: 44,
|
| 312 |
+
borderRadius: 22,
|
| 313 |
+
backgroundColor: 'rgba(255,255,255,0.2)', // Light glass
|
| 314 |
+
borderWidth: 1,
|
| 315 |
+
borderColor: 'rgba(255,255,255,0.3)',
|
| 316 |
+
alignItems: 'center',
|
| 317 |
+
justifyContent: 'center',
|
| 318 |
+
},
|
| 319 |
+
imageToggle: {
|
| 320 |
+
position: 'absolute',
|
| 321 |
+
bottom: spacing.lg,
|
| 322 |
+
right: spacing.lg,
|
| 323 |
+
flexDirection: 'row',
|
| 324 |
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
| 325 |
+
borderRadius: borderRadius.full,
|
| 326 |
+
padding: 4,
|
| 327 |
+
borderWidth: 1,
|
| 328 |
+
borderColor: 'rgba(255,255,255,0.1)',
|
| 329 |
+
},
|
| 330 |
+
toggleButton: {
|
| 331 |
+
paddingHorizontal: spacing.md,
|
| 332 |
+
paddingVertical: spacing.sm,
|
| 333 |
+
borderRadius: borderRadius.full,
|
| 334 |
+
},
|
| 335 |
+
toggleActive: {
|
| 336 |
+
backgroundColor: colors.primary.main,
|
| 337 |
+
},
|
| 338 |
+
toggleText: {
|
| 339 |
+
color: colors.text.primary,
|
| 340 |
+
fontSize: 12,
|
| 341 |
+
fontWeight: '600',
|
| 342 |
+
},
|
| 343 |
+
content: {
|
| 344 |
+
padding: spacing.lg,
|
| 345 |
+
},
|
| 346 |
+
badges: {
|
| 347 |
+
flexDirection: 'row',
|
| 348 |
+
flexWrap: 'wrap',
|
| 349 |
+
gap: spacing.sm,
|
| 350 |
+
marginBottom: spacing.md,
|
| 351 |
+
},
|
| 352 |
+
badge: {
|
| 353 |
+
flexDirection: 'row',
|
| 354 |
+
alignItems: 'center',
|
| 355 |
+
paddingHorizontal: spacing.md,
|
| 356 |
+
paddingVertical: spacing.sm,
|
| 357 |
+
borderRadius: borderRadius.full,
|
| 358 |
+
gap: spacing.xs,
|
| 359 |
+
},
|
| 360 |
+
stateBadge: {
|
| 361 |
+
backgroundColor: 'rgba(255,255,255,0.1)',
|
| 362 |
+
borderWidth: 1,
|
| 363 |
+
},
|
| 364 |
+
badgeText: {
|
| 365 |
+
color: colors.text.primary,
|
| 366 |
+
fontSize: 12,
|
| 367 |
+
fontWeight: '700',
|
| 368 |
+
letterSpacing: 0.5,
|
| 369 |
+
},
|
| 370 |
+
category: {
|
| 371 |
+
fontSize: 28,
|
| 372 |
+
fontWeight: '800',
|
| 373 |
+
color: colors.text.primary,
|
| 374 |
+
marginBottom: spacing.lg,
|
| 375 |
+
letterSpacing: -0.5,
|
| 376 |
+
},
|
| 377 |
+
confidenceCard: {
|
| 378 |
+
marginBottom: spacing.md,
|
| 379 |
+
backgroundColor: 'rgba(255,255,255,0.05)',
|
| 380 |
+
},
|
| 381 |
+
confidenceRow: {
|
| 382 |
+
flexDirection: 'row',
|
| 383 |
+
justifyContent: 'space-between',
|
| 384 |
+
alignItems: 'center',
|
| 385 |
+
marginBottom: spacing.sm,
|
| 386 |
+
},
|
| 387 |
+
confidenceLabel: {
|
| 388 |
+
fontSize: 14,
|
| 389 |
+
color: colors.text.secondary,
|
| 390 |
+
fontWeight: '500',
|
| 391 |
+
},
|
| 392 |
+
confidenceValue: {
|
| 393 |
+
fontSize: 24,
|
| 394 |
+
fontWeight: '700',
|
| 395 |
+
color: colors.secondary.main,
|
| 396 |
+
fontFamily: 'monospace', // Assuming Fira Code availability or fallback
|
| 397 |
+
},
|
| 398 |
+
confidenceBar: {
|
| 399 |
+
height: 6,
|
| 400 |
+
backgroundColor: 'rgba(255,255,255,0.1)',
|
| 401 |
+
borderRadius: 3,
|
| 402 |
+
overflow: 'hidden',
|
| 403 |
+
},
|
| 404 |
+
confidenceFill: {
|
| 405 |
+
height: '100%',
|
| 406 |
+
backgroundColor: colors.secondary.main,
|
| 407 |
+
borderRadius: 3,
|
| 408 |
+
},
|
| 409 |
+
descriptionCard: {
|
| 410 |
+
marginBottom: spacing.md,
|
| 411 |
+
},
|
| 412 |
+
sectionTitle: {
|
| 413 |
+
fontSize: 18,
|
| 414 |
+
fontWeight: '700',
|
| 415 |
+
color: colors.text.primary,
|
| 416 |
+
marginBottom: spacing.md,
|
| 417 |
+
},
|
| 418 |
+
description: {
|
| 419 |
+
fontSize: 15,
|
| 420 |
+
color: colors.text.secondary,
|
| 421 |
+
lineHeight: 24,
|
| 422 |
+
},
|
| 423 |
+
detailsCard: {
|
| 424 |
+
marginBottom: spacing.lg,
|
| 425 |
+
},
|
| 426 |
+
detailRow: {
|
| 427 |
+
flexDirection: 'row',
|
| 428 |
+
justifyContent: 'space-between',
|
| 429 |
+
alignItems: 'center',
|
| 430 |
+
paddingVertical: 12,
|
| 431 |
+
borderBottomWidth: 1,
|
| 432 |
+
borderBottomColor: 'rgba(255,255,255,0.05)',
|
| 433 |
+
},
|
| 434 |
+
detailLabelContainer: {
|
| 435 |
+
flexDirection: 'row',
|
| 436 |
+
alignItems: 'center',
|
| 437 |
+
gap: spacing.sm,
|
| 438 |
+
},
|
| 439 |
+
detailLabel: {
|
| 440 |
+
fontSize: 14,
|
| 441 |
+
color: colors.text.secondary,
|
| 442 |
+
fontWeight: '500',
|
| 443 |
+
},
|
| 444 |
+
detailValue: {
|
| 445 |
+
fontSize: 15,
|
| 446 |
+
color: colors.text.primary,
|
| 447 |
+
flex: 1,
|
| 448 |
+
textAlign: 'right',
|
| 449 |
+
marginLeft: spacing.md,
|
| 450 |
+
fontWeight: '600',
|
| 451 |
+
},
|
| 452 |
+
timeline: {
|
| 453 |
+
marginBottom: spacing.xl,
|
| 454 |
+
},
|
| 455 |
+
timelineItem: {
|
| 456 |
+
flexDirection: 'row',
|
| 457 |
+
alignItems: 'center',
|
| 458 |
+
},
|
| 459 |
+
timelineDot: {
|
| 460 |
+
width: 12,
|
| 461 |
+
height: 12,
|
| 462 |
+
borderRadius: 6,
|
| 463 |
+
marginRight: spacing.md,
|
| 464 |
+
shadowColor: colors.primary.main, // Glow effect placeholder
|
| 465 |
+
shadowOpacity: 0.5,
|
| 466 |
+
shadowRadius: 4,
|
| 467 |
+
},
|
| 468 |
+
timelineContent: {
|
| 469 |
+
flex: 1,
|
| 470 |
+
},
|
| 471 |
+
timelineTitle: {
|
| 472 |
+
fontSize: 16,
|
| 473 |
+
color: colors.text.primary,
|
| 474 |
+
fontWeight: '700',
|
| 475 |
+
},
|
| 476 |
+
timelineDate: {
|
| 477 |
+
fontSize: 13,
|
| 478 |
+
color: colors.text.tertiary,
|
| 479 |
+
marginTop: 2,
|
| 480 |
+
},
|
| 481 |
+
});
|
User/src/screens/issues/MyIssuesScreen.tsx
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useCallback } from "react";
|
| 2 |
+
import {
|
| 3 |
+
View,
|
| 4 |
+
Text,
|
| 5 |
+
StyleSheet,
|
| 6 |
+
FlatList,
|
| 7 |
+
RefreshControl,
|
| 8 |
+
TouchableOpacity,
|
| 9 |
+
ActivityIndicator,
|
| 10 |
+
} from "react-native";
|
| 11 |
+
import { useNavigation, useFocusEffect } from "@react-navigation/native";
|
| 12 |
+
import { LinearGradient } from "expo-linear-gradient";
|
| 13 |
+
import { Ionicons } from "@expo/vector-icons";
|
| 14 |
+
import { Card } from "../../components/ui/Card";
|
| 15 |
+
import { IssueCard } from "../../components/issues/IssueCard";
|
| 16 |
+
import { issueService } from "../../services/issueService";
|
| 17 |
+
import { cacheService } from "../../services/cacheService";
|
| 18 |
+
import { useAuth } from "../../context/AuthContext";
|
| 19 |
+
import { colors, spacing, typography, borderRadius } from "../../theme";
|
| 20 |
+
import { Issue } from "../../types";
|
| 21 |
+
|
| 22 |
+
const ITEMS_PER_PAGE = 10;
|
| 23 |
+
|
| 24 |
+
export function MyIssuesScreen() {
|
| 25 |
+
const navigation = useNavigation<any>();
|
| 26 |
+
const { user, isDevMode } = useAuth();
|
| 27 |
+
|
| 28 |
+
const [issues, setIssues] = useState<Issue[]>([]);
|
| 29 |
+
const [loading, setLoading] = useState(true);
|
| 30 |
+
const [refreshing, setRefreshing] = useState(false);
|
| 31 |
+
const [page, setPage] = useState(1);
|
| 32 |
+
const [hasMore, setHasMore] = useState(true);
|
| 33 |
+
const [filter, setFilter] = useState<string | null>(null);
|
| 34 |
+
|
| 35 |
+
useFocusEffect(
|
| 36 |
+
useCallback(() => {
|
| 37 |
+
loadIssues();
|
| 38 |
+
}, [filter, user?.id]),
|
| 39 |
+
);
|
| 40 |
+
|
| 41 |
+
const loadIssues = async () => {
|
| 42 |
+
setLoading(true);
|
| 43 |
+
await cacheService.clearCache();
|
| 44 |
+
await fetchIssues(1, true);
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const fetchIssues = async (pageNum: number, reset: boolean = false) => {
|
| 48 |
+
try {
|
| 49 |
+
const response = await issueService.listIssues(
|
| 50 |
+
pageNum,
|
| 51 |
+
ITEMS_PER_PAGE,
|
| 52 |
+
filter || undefined,
|
| 53 |
+
isDevMode ? undefined : user?.id,
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
const filtered = response.items;
|
| 57 |
+
|
| 58 |
+
if (reset) {
|
| 59 |
+
setIssues(filtered);
|
| 60 |
+
await cacheService.setIssuesCache(filtered);
|
| 61 |
+
} else {
|
| 62 |
+
const newIssues = [...issues, ...filtered];
|
| 63 |
+
setIssues(newIssues);
|
| 64 |
+
await cacheService.setIssuesCache(newIssues);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
setHasMore(response.items.length === ITEMS_PER_PAGE);
|
| 68 |
+
setPage(pageNum);
|
| 69 |
+
} catch (error) {
|
| 70 |
+
console.error("Failed to fetch issues:", error);
|
| 71 |
+
} finally {
|
| 72 |
+
setLoading(false);
|
| 73 |
+
setRefreshing(false);
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
const handleRefresh = () => {
|
| 78 |
+
setRefreshing(true);
|
| 79 |
+
fetchIssues(1, true);
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
const handleForceRefresh = async () => {
|
| 83 |
+
setLoading(true);
|
| 84 |
+
await cacheService.clearCache();
|
| 85 |
+
await fetchIssues(1, true);
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const handleLoadMore = () => {
|
| 89 |
+
if (hasMore && !loading) {
|
| 90 |
+
fetchIssues(page + 1);
|
| 91 |
+
}
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
const handleIssuePress = (issue: Issue) => {
|
| 95 |
+
navigation.navigate("IssueDetail", { issueId: issue.id });
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const filters = [
|
| 99 |
+
{ key: null, label: "All" },
|
| 100 |
+
{ key: "reported", label: "Reported" },
|
| 101 |
+
{ key: "assigned", label: "Assigned" },
|
| 102 |
+
{ key: "in_progress", label: "In Progress" },
|
| 103 |
+
{ key: "pending_verification", label: "Review" },
|
| 104 |
+
{ key: "resolved", label: "Resolved" },
|
| 105 |
+
];
|
| 106 |
+
|
| 107 |
+
const renderHeader = () => (
|
| 108 |
+
<View style={styles.header}>
|
| 109 |
+
<TouchableOpacity
|
| 110 |
+
style={styles.backButton}
|
| 111 |
+
onPress={() => navigation.goBack()}
|
| 112 |
+
>
|
| 113 |
+
<Ionicons name="arrow-back" size={24} color={colors.text.primary} />
|
| 114 |
+
</TouchableOpacity>
|
| 115 |
+
<Text style={styles.title}>My Reports</Text>
|
| 116 |
+
<TouchableOpacity
|
| 117 |
+
style={styles.refreshButton}
|
| 118 |
+
onPress={handleForceRefresh}
|
| 119 |
+
>
|
| 120 |
+
<Ionicons name="refresh" size={24} color={colors.primary.main} />
|
| 121 |
+
</TouchableOpacity>
|
| 122 |
+
</View>
|
| 123 |
+
);
|
| 124 |
+
|
| 125 |
+
const renderFilters = () => (
|
| 126 |
+
<View style={styles.filters}>
|
| 127 |
+
{filters.map((f) => (
|
| 128 |
+
<TouchableOpacity
|
| 129 |
+
key={f.key || "all"}
|
| 130 |
+
style={[styles.filterButton, filter === f.key && styles.filterActive]}
|
| 131 |
+
onPress={() => setFilter(f.key)}
|
| 132 |
+
>
|
| 133 |
+
<Text
|
| 134 |
+
style={[
|
| 135 |
+
styles.filterText,
|
| 136 |
+
filter === f.key && styles.filterTextActive,
|
| 137 |
+
]}
|
| 138 |
+
>
|
| 139 |
+
{f.label}
|
| 140 |
+
</Text>
|
| 141 |
+
</TouchableOpacity>
|
| 142 |
+
))}
|
| 143 |
+
</View>
|
| 144 |
+
);
|
| 145 |
+
|
| 146 |
+
const renderEmpty = () => (
|
| 147 |
+
<Card style={styles.emptyCard}>
|
| 148 |
+
<Ionicons
|
| 149 |
+
name="documents-outline"
|
| 150 |
+
size={48}
|
| 151 |
+
color={colors.text.tertiary}
|
| 152 |
+
/>
|
| 153 |
+
<Text style={styles.emptyTitle}>No Reports Found</Text>
|
| 154 |
+
<Text style={styles.emptyText}>
|
| 155 |
+
{filter
|
| 156 |
+
? "No issues match this filter"
|
| 157 |
+
: "You haven't reported any issues yet"}
|
| 158 |
+
</Text>
|
| 159 |
+
</Card>
|
| 160 |
+
);
|
| 161 |
+
|
| 162 |
+
const renderFooter = () => {
|
| 163 |
+
if (!hasMore) return null;
|
| 164 |
+
return (
|
| 165 |
+
<View style={styles.footer}>
|
| 166 |
+
<ActivityIndicator size="small" color={colors.primary.main} />
|
| 167 |
+
</View>
|
| 168 |
+
);
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
return (
|
| 172 |
+
<LinearGradient
|
| 173 |
+
colors={[colors.background.primary, colors.background.secondary]}
|
| 174 |
+
style={styles.container}
|
| 175 |
+
>
|
| 176 |
+
<FlatList
|
| 177 |
+
data={issues}
|
| 178 |
+
keyExtractor={(item) => item.id}
|
| 179 |
+
renderItem={({ item }) => (
|
| 180 |
+
<IssueCard issue={item} onPress={() => handleIssuePress(item)} />
|
| 181 |
+
)}
|
| 182 |
+
ListHeaderComponent={
|
| 183 |
+
<>
|
| 184 |
+
{renderHeader()}
|
| 185 |
+
{renderFilters()}
|
| 186 |
+
</>
|
| 187 |
+
}
|
| 188 |
+
ListEmptyComponent={!loading ? renderEmpty : null}
|
| 189 |
+
ListFooterComponent={renderFooter}
|
| 190 |
+
contentContainerStyle={styles.listContent}
|
| 191 |
+
refreshControl={
|
| 192 |
+
<RefreshControl
|
| 193 |
+
refreshing={refreshing}
|
| 194 |
+
onRefresh={handleRefresh}
|
| 195 |
+
tintColor={colors.primary.main}
|
| 196 |
+
/>
|
| 197 |
+
}
|
| 198 |
+
onEndReached={handleLoadMore}
|
| 199 |
+
onEndReachedThreshold={0.5}
|
| 200 |
+
showsVerticalScrollIndicator={false}
|
| 201 |
+
/>
|
| 202 |
+
</LinearGradient>
|
| 203 |
+
);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
const styles = StyleSheet.create({
|
| 207 |
+
container: {
|
| 208 |
+
flex: 1,
|
| 209 |
+
},
|
| 210 |
+
listContent: {
|
| 211 |
+
padding: spacing.lg,
|
| 212 |
+
paddingTop: spacing.xxl * 2,
|
| 213 |
+
},
|
| 214 |
+
header: {
|
| 215 |
+
flexDirection: "row",
|
| 216 |
+
alignItems: "center",
|
| 217 |
+
justifyContent: "space-between",
|
| 218 |
+
marginBottom: spacing.lg,
|
| 219 |
+
},
|
| 220 |
+
backButton: {
|
| 221 |
+
width: 40,
|
| 222 |
+
height: 40,
|
| 223 |
+
borderRadius: 20,
|
| 224 |
+
backgroundColor: colors.background.tertiary,
|
| 225 |
+
alignItems: "center",
|
| 226 |
+
justifyContent: "center",
|
| 227 |
+
},
|
| 228 |
+
refreshButton: {
|
| 229 |
+
width: 40,
|
| 230 |
+
height: 40,
|
| 231 |
+
borderRadius: 20,
|
| 232 |
+
backgroundColor: colors.background.tertiary,
|
| 233 |
+
alignItems: "center",
|
| 234 |
+
justifyContent: "center",
|
| 235 |
+
},
|
| 236 |
+
title: {
|
| 237 |
+
...typography.h2,
|
| 238 |
+
color: colors.text.primary,
|
| 239 |
+
},
|
| 240 |
+
filters: {
|
| 241 |
+
flexDirection: "row",
|
| 242 |
+
marginBottom: spacing.lg,
|
| 243 |
+
gap: spacing.sm,
|
| 244 |
+
},
|
| 245 |
+
filterButton: {
|
| 246 |
+
paddingHorizontal: spacing.md,
|
| 247 |
+
paddingVertical: spacing.sm,
|
| 248 |
+
borderRadius: borderRadius.full,
|
| 249 |
+
backgroundColor: colors.background.tertiary,
|
| 250 |
+
},
|
| 251 |
+
filterActive: {
|
| 252 |
+
backgroundColor: colors.primary.main,
|
| 253 |
+
},
|
| 254 |
+
filterText: {
|
| 255 |
+
color: colors.text.secondary,
|
| 256 |
+
fontSize: 14,
|
| 257 |
+
},
|
| 258 |
+
filterTextActive: {
|
| 259 |
+
color: colors.text.primary,
|
| 260 |
+
fontWeight: "600",
|
| 261 |
+
},
|
| 262 |
+
emptyCard: {
|
| 263 |
+
alignItems: "center",
|
| 264 |
+
padding: spacing.xl,
|
| 265 |
+
},
|
| 266 |
+
emptyTitle: {
|
| 267 |
+
...typography.h3,
|
| 268 |
+
color: colors.text.primary,
|
| 269 |
+
marginBottom: spacing.sm,
|
| 270 |
+
marginTop: spacing.md,
|
| 271 |
+
},
|
| 272 |
+
emptyText: {
|
| 273 |
+
...typography.body,
|
| 274 |
+
color: colors.text.secondary,
|
| 275 |
+
textAlign: "center",
|
| 276 |
+
},
|
| 277 |
+
footer: {
|
| 278 |
+
paddingVertical: spacing.lg,
|
| 279 |
+
alignItems: "center",
|
| 280 |
+
},
|
| 281 |
+
});
|
User/src/screens/profile/ProfileScreen.tsx
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import {
|
| 3 |
+
View,
|
| 4 |
+
Text,
|
| 5 |
+
StyleSheet,
|
| 6 |
+
ScrollView,
|
| 7 |
+
TouchableOpacity,
|
| 8 |
+
Image,
|
| 9 |
+
Linking,
|
| 10 |
+
} from 'react-native';
|
| 11 |
+
import { LinearGradient } from 'expo-linear-gradient';
|
| 12 |
+
import { Ionicons } from '@expo/vector-icons';
|
| 13 |
+
import { useAuth } from '../../context/AuthContext';
|
| 14 |
+
import { Card } from '../../components/ui/Card';
|
| 15 |
+
import { Button } from '../../components/ui/Button';
|
| 16 |
+
import { colors, spacing, typography, borderRadius } from '../../theme';
|
| 17 |
+
|
| 18 |
+
export function ProfileScreen() {
|
| 19 |
+
const { user, signOut } = useAuth();
|
| 20 |
+
|
| 21 |
+
const handleSignOut = async () => {
|
| 22 |
+
try {
|
| 23 |
+
await signOut();
|
| 24 |
+
} catch (error) {
|
| 25 |
+
console.error('Sign out error:', error);
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const menuItems: { iconName: keyof typeof Ionicons.glyphMap; label: string; onPress: () => void; value?: string }[] = [
|
| 30 |
+
{ iconName: 'notifications-outline', label: 'Notifications', onPress: () => {} },
|
| 31 |
+
{ iconName: 'moon-outline', label: 'Dark Mode', onPress: () => {}, value: 'On' },
|
| 32 |
+
{ iconName: 'location-outline', label: 'Location Settings', onPress: () => Linking.openSettings() },
|
| 33 |
+
{ iconName: 'help-circle-outline', label: 'Help & Support', onPress: () => {} },
|
| 34 |
+
{ iconName: 'document-text-outline', label: 'Terms of Service', onPress: () => {} },
|
| 35 |
+
{ iconName: 'lock-closed-outline', label: 'Privacy Policy', onPress: () => {} },
|
| 36 |
+
];
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<LinearGradient
|
| 40 |
+
colors={[colors.background.primary, colors.background.secondary]}
|
| 41 |
+
style={styles.container}
|
| 42 |
+
>
|
| 43 |
+
<ScrollView showsVerticalScrollIndicator={false}>
|
| 44 |
+
<View style={styles.header}>
|
| 45 |
+
<View style={styles.avatarContainer}>
|
| 46 |
+
{user?.user_metadata?.avatar_url ? (
|
| 47 |
+
<Image
|
| 48 |
+
source={{ uri: user.user_metadata.avatar_url }}
|
| 49 |
+
style={styles.avatar}
|
| 50 |
+
/>
|
| 51 |
+
) : (
|
| 52 |
+
<View style={styles.avatarPlaceholder}>
|
| 53 |
+
<Ionicons name="person" size={48} color={colors.text.tertiary} />
|
| 54 |
+
</View>
|
| 55 |
+
)}
|
| 56 |
+
<View style={styles.verifiedBadge}>
|
| 57 |
+
<Ionicons name="checkmark" size={14} color={colors.primary.contrast} />
|
| 58 |
+
</View>
|
| 59 |
+
</View>
|
| 60 |
+
|
| 61 |
+
<Text style={styles.userName}>
|
| 62 |
+
{user?.user_metadata?.full_name || 'Citizen'}
|
| 63 |
+
</Text>
|
| 64 |
+
<Text style={styles.userEmail}>{user?.email}</Text>
|
| 65 |
+
|
| 66 |
+
<View style={styles.statsRow}>
|
| 67 |
+
<View style={styles.statItem}>
|
| 68 |
+
<Text style={styles.statValue}>12</Text>
|
| 69 |
+
<Text style={styles.statLabel}>Reports</Text>
|
| 70 |
+
</View>
|
| 71 |
+
<View style={styles.statDivider} />
|
| 72 |
+
<View style={styles.statItem}>
|
| 73 |
+
<Text style={styles.statValue}>8</Text>
|
| 74 |
+
<Text style={styles.statLabel}>Resolved</Text>
|
| 75 |
+
</View>
|
| 76 |
+
<View style={styles.statDivider} />
|
| 77 |
+
<View style={styles.statItem}>
|
| 78 |
+
<Text style={styles.statValue}>95%</Text>
|
| 79 |
+
<Text style={styles.statLabel}>Accuracy</Text>
|
| 80 |
+
</View>
|
| 81 |
+
</View>
|
| 82 |
+
</View>
|
| 83 |
+
|
| 84 |
+
<Card variant="gradient" style={styles.badgeCard}>
|
| 85 |
+
<View style={styles.badgeCardContent}>
|
| 86 |
+
<View style={styles.badgeInfo}>
|
| 87 |
+
<Ionicons name="trophy" size={32} color={colors.status.warning} />
|
| 88 |
+
<View style={styles.badgeTextContainer}>
|
| 89 |
+
<Text style={styles.badgeTitle}>Civic Champion</Text>
|
| 90 |
+
<Text style={styles.badgeSubtitle}>Top 10% contributor in your area</Text>
|
| 91 |
+
</View>
|
| 92 |
+
</View>
|
| 93 |
+
</View>
|
| 94 |
+
</Card>
|
| 95 |
+
|
| 96 |
+
<View style={styles.menuSection}>
|
| 97 |
+
<Text style={styles.sectionTitle}>Settings</Text>
|
| 98 |
+
<Card>
|
| 99 |
+
{menuItems.map((item, index) => (
|
| 100 |
+
<TouchableOpacity
|
| 101 |
+
key={item.label}
|
| 102 |
+
style={[
|
| 103 |
+
styles.menuItem,
|
| 104 |
+
index < menuItems.length - 1 && styles.menuItemBorder,
|
| 105 |
+
]}
|
| 106 |
+
onPress={item.onPress}
|
| 107 |
+
>
|
| 108 |
+
<View style={styles.menuLeft}>
|
| 109 |
+
<Ionicons name={item.iconName} size={20} color={colors.text.secondary} />
|
| 110 |
+
<Text style={styles.menuLabel}>{item.label}</Text>
|
| 111 |
+
</View>
|
| 112 |
+
<View style={styles.menuRight}>
|
| 113 |
+
{item.value ? (
|
| 114 |
+
<Text style={styles.menuValue}>{item.value}</Text>
|
| 115 |
+
) : null}
|
| 116 |
+
<Ionicons name="chevron-forward" size={16} color={colors.text.tertiary} />
|
| 117 |
+
</View>
|
| 118 |
+
</TouchableOpacity>
|
| 119 |
+
))}
|
| 120 |
+
</Card>
|
| 121 |
+
</View>
|
| 122 |
+
|
| 123 |
+
<View style={styles.signOutSection}>
|
| 124 |
+
<Button
|
| 125 |
+
title="Sign Out"
|
| 126 |
+
variant="outline"
|
| 127 |
+
onPress={handleSignOut}
|
| 128 |
+
fullWidth
|
| 129 |
+
/>
|
| 130 |
+
</View>
|
| 131 |
+
|
| 132 |
+
<Text style={styles.version}>Version 1.0.0</Text>
|
| 133 |
+
</ScrollView>
|
| 134 |
+
</LinearGradient>
|
| 135 |
+
);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const styles = StyleSheet.create({
|
| 139 |
+
container: {
|
| 140 |
+
flex: 1,
|
| 141 |
+
},
|
| 142 |
+
header: {
|
| 143 |
+
alignItems: 'center',
|
| 144 |
+
paddingTop: spacing.xxl * 2,
|
| 145 |
+
paddingBottom: spacing.xl,
|
| 146 |
+
},
|
| 147 |
+
avatarContainer: {
|
| 148 |
+
position: 'relative',
|
| 149 |
+
marginBottom: spacing.lg,
|
| 150 |
+
},
|
| 151 |
+
avatar: {
|
| 152 |
+
width: 100,
|
| 153 |
+
height: 100,
|
| 154 |
+
borderRadius: 50,
|
| 155 |
+
borderWidth: 3,
|
| 156 |
+
borderColor: colors.primary.main,
|
| 157 |
+
},
|
| 158 |
+
avatarPlaceholder: {
|
| 159 |
+
width: 100,
|
| 160 |
+
height: 100,
|
| 161 |
+
borderRadius: 50,
|
| 162 |
+
backgroundColor: colors.background.tertiary,
|
| 163 |
+
alignItems: 'center',
|
| 164 |
+
justifyContent: 'center',
|
| 165 |
+
borderWidth: 3,
|
| 166 |
+
borderColor: colors.primary.main,
|
| 167 |
+
},
|
| 168 |
+
verifiedBadge: {
|
| 169 |
+
position: 'absolute',
|
| 170 |
+
bottom: 0,
|
| 171 |
+
right: 0,
|
| 172 |
+
width: 28,
|
| 173 |
+
height: 28,
|
| 174 |
+
borderRadius: 14,
|
| 175 |
+
backgroundColor: colors.secondary.main,
|
| 176 |
+
alignItems: 'center',
|
| 177 |
+
justifyContent: 'center',
|
| 178 |
+
borderWidth: 3,
|
| 179 |
+
borderColor: colors.background.primary,
|
| 180 |
+
},
|
| 181 |
+
userName: {
|
| 182 |
+
...typography.h2,
|
| 183 |
+
color: colors.text.primary,
|
| 184 |
+
},
|
| 185 |
+
userEmail: {
|
| 186 |
+
...typography.body,
|
| 187 |
+
color: colors.text.secondary,
|
| 188 |
+
marginTop: spacing.xs,
|
| 189 |
+
},
|
| 190 |
+
statsRow: {
|
| 191 |
+
flexDirection: 'row',
|
| 192 |
+
alignItems: 'center',
|
| 193 |
+
marginTop: spacing.xl,
|
| 194 |
+
paddingHorizontal: spacing.xl,
|
| 195 |
+
},
|
| 196 |
+
statItem: {
|
| 197 |
+
flex: 1,
|
| 198 |
+
alignItems: 'center',
|
| 199 |
+
},
|
| 200 |
+
statValue: {
|
| 201 |
+
...typography.h2,
|
| 202 |
+
color: colors.primary.main,
|
| 203 |
+
},
|
| 204 |
+
statLabel: {
|
| 205 |
+
...typography.caption,
|
| 206 |
+
color: colors.text.secondary,
|
| 207 |
+
marginTop: spacing.xs,
|
| 208 |
+
},
|
| 209 |
+
statDivider: {
|
| 210 |
+
width: 1,
|
| 211 |
+
height: 40,
|
| 212 |
+
backgroundColor: colors.border.light,
|
| 213 |
+
},
|
| 214 |
+
badgeCard: {
|
| 215 |
+
marginHorizontal: spacing.lg,
|
| 216 |
+
marginBottom: spacing.xl,
|
| 217 |
+
},
|
| 218 |
+
badgeCardContent: {},
|
| 219 |
+
badgeInfo: {
|
| 220 |
+
flexDirection: 'row',
|
| 221 |
+
alignItems: 'center',
|
| 222 |
+
gap: spacing.md,
|
| 223 |
+
},
|
| 224 |
+
badgeTextContainer: {
|
| 225 |
+
flex: 1,
|
| 226 |
+
},
|
| 227 |
+
badgeTitle: {
|
| 228 |
+
...typography.body,
|
| 229 |
+
color: colors.text.primary,
|
| 230 |
+
fontWeight: '600',
|
| 231 |
+
},
|
| 232 |
+
badgeSubtitle: {
|
| 233 |
+
...typography.caption,
|
| 234 |
+
color: colors.text.secondary,
|
| 235 |
+
marginTop: 2,
|
| 236 |
+
},
|
| 237 |
+
menuSection: {
|
| 238 |
+
paddingHorizontal: spacing.lg,
|
| 239 |
+
marginBottom: spacing.xl,
|
| 240 |
+
},
|
| 241 |
+
sectionTitle: {
|
| 242 |
+
...typography.h3,
|
| 243 |
+
color: colors.text.primary,
|
| 244 |
+
marginBottom: spacing.md,
|
| 245 |
+
},
|
| 246 |
+
menuItem: {
|
| 247 |
+
flexDirection: 'row',
|
| 248 |
+
justifyContent: 'space-between',
|
| 249 |
+
alignItems: 'center',
|
| 250 |
+
paddingVertical: spacing.md,
|
| 251 |
+
},
|
| 252 |
+
menuItemBorder: {
|
| 253 |
+
borderBottomWidth: 1,
|
| 254 |
+
borderBottomColor: colors.border.light,
|
| 255 |
+
},
|
| 256 |
+
menuLeft: {
|
| 257 |
+
flexDirection: 'row',
|
| 258 |
+
alignItems: 'center',
|
| 259 |
+
gap: spacing.md,
|
| 260 |
+
},
|
| 261 |
+
menuLabel: {
|
| 262 |
+
...typography.body,
|
| 263 |
+
color: colors.text.primary,
|
| 264 |
+
},
|
| 265 |
+
menuRight: {
|
| 266 |
+
flexDirection: 'row',
|
| 267 |
+
alignItems: 'center',
|
| 268 |
+
gap: spacing.sm,
|
| 269 |
+
},
|
| 270 |
+
menuValue: {
|
| 271 |
+
...typography.body,
|
| 272 |
+
color: colors.text.secondary,
|
| 273 |
+
},
|
| 274 |
+
signOutSection: {
|
| 275 |
+
paddingHorizontal: spacing.lg,
|
| 276 |
+
marginBottom: spacing.lg,
|
| 277 |
+
},
|
| 278 |
+
version: {
|
| 279 |
+
...typography.caption,
|
| 280 |
+
color: colors.text.tertiary,
|
| 281 |
+
textAlign: 'center',
|
| 282 |
+
marginBottom: spacing.xxl,
|
| 283 |
+
},
|
| 284 |
+
});
|
User/src/services/cacheService.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 2 |
+
import { Issue } from '../types';
|
| 3 |
+
|
| 4 |
+
const CACHE_KEY = 'ISSUES_CACHE';
|
| 5 |
+
const CACHE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
| 6 |
+
|
| 7 |
+
interface CacheData {
|
| 8 |
+
issues: Issue[];
|
| 9 |
+
timestamp: number;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export const cacheService = {
|
| 13 |
+
/**
|
| 14 |
+
* Retrieves issues from cache.
|
| 15 |
+
* @param ignoreExpiry If true, returns cached data even if expired (useful for initial render while fetching)
|
| 16 |
+
*/
|
| 17 |
+
async getIssuesCache(ignoreExpiry = false): Promise<Issue[] | null> {
|
| 18 |
+
try {
|
| 19 |
+
const cached = await AsyncStorage.getItem(CACHE_KEY);
|
| 20 |
+
if (!cached) return null;
|
| 21 |
+
|
| 22 |
+
const data: CacheData = JSON.parse(cached);
|
| 23 |
+
const isExpired = Date.now() - data.timestamp > CACHE_EXPIRY_MS;
|
| 24 |
+
|
| 25 |
+
if (isExpired && !ignoreExpiry) {
|
| 26 |
+
// We don't clear here if we want to allow stale retrieval later
|
| 27 |
+
return null;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return data.issues;
|
| 31 |
+
} catch {
|
| 32 |
+
return null;
|
| 33 |
+
}
|
| 34 |
+
},
|
| 35 |
+
|
| 36 |
+
async setIssuesCache(issues: Issue[]): Promise<void> {
|
| 37 |
+
try {
|
| 38 |
+
const data: CacheData = {
|
| 39 |
+
issues,
|
| 40 |
+
timestamp: Date.now(),
|
| 41 |
+
};
|
| 42 |
+
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(data));
|
| 43 |
+
} catch {
|
| 44 |
+
// Ignore write errors
|
| 45 |
+
}
|
| 46 |
+
},
|
| 47 |
+
|
| 48 |
+
async clearCache(): Promise<void> {
|
| 49 |
+
try {
|
| 50 |
+
await AsyncStorage.removeItem(CACHE_KEY);
|
| 51 |
+
} catch {
|
| 52 |
+
// Ignore errors
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
};
|
User/src/services/issueService.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { API_BASE_URL } from "../config/supabase";
|
| 2 |
+
import { Issue, IssueListResponse, LocationData } from "../types";
|
| 3 |
+
import EventSource, { EventSourceListener } from "react-native-sse";
|
| 4 |
+
import { cacheService } from "./cacheService";
|
| 5 |
+
|
| 6 |
+
class IssueService {
|
| 7 |
+
private baseUrl: string;
|
| 8 |
+
|
| 9 |
+
constructor() {
|
| 10 |
+
this.baseUrl = API_BASE_URL;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
async createIssue(
|
| 14 |
+
imageUri: string,
|
| 15 |
+
location: LocationData,
|
| 16 |
+
description?: string,
|
| 17 |
+
accessToken?: string,
|
| 18 |
+
): Promise<{ issue_id: string; stream_url: string }> {
|
| 19 |
+
const formData = new FormData();
|
| 20 |
+
|
| 21 |
+
const imageFile = {
|
| 22 |
+
uri: imageUri,
|
| 23 |
+
type: "image/jpeg",
|
| 24 |
+
name: "issue_photo.jpg",
|
| 25 |
+
} as any;
|
| 26 |
+
|
| 27 |
+
formData.append("images", imageFile);
|
| 28 |
+
formData.append("latitude", location.latitude.toString());
|
| 29 |
+
formData.append("longitude", location.longitude.toString());
|
| 30 |
+
formData.append("accuracy_meters", location.accuracy.toString());
|
| 31 |
+
formData.append("platform", "mobile_app");
|
| 32 |
+
formData.append("device_model", "expo_device");
|
| 33 |
+
|
| 34 |
+
if (description) {
|
| 35 |
+
formData.append("description", description);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if (accessToken) {
|
| 39 |
+
formData.append("authorization", `Bearer ${accessToken}`);
|
| 40 |
+
console.log("[IssueService] Authorization token appended to form data");
|
| 41 |
+
} else {
|
| 42 |
+
console.warn("[IssueService] No access token provided - issue will be created without user_id");
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const headers: Record<string, string> = {
|
| 46 |
+
"Content-Type": "multipart/form-data",
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const response = await fetch(`${this.baseUrl}/issues/stream`, {
|
| 50 |
+
method: "POST",
|
| 51 |
+
headers,
|
| 52 |
+
body: formData,
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
if (!response.ok) {
|
| 56 |
+
const error = await response.json();
|
| 57 |
+
throw new Error(error.detail || "Failed to create issue");
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
await cacheService.clearCache();
|
| 61 |
+
|
| 62 |
+
return response.json();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async getIssue(issueId: string): Promise<Issue> {
|
| 66 |
+
const response = await fetch(`${this.baseUrl}/issues/${issueId}`);
|
| 67 |
+
|
| 68 |
+
if (!response.ok) {
|
| 69 |
+
throw new Error("Failed to fetch issue");
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
return response.json();
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
async listIssues(
|
| 76 |
+
page: number = 1,
|
| 77 |
+
pageSize: number = 20,
|
| 78 |
+
state?: string,
|
| 79 |
+
userId?: string,
|
| 80 |
+
): Promise<IssueListResponse> {
|
| 81 |
+
const params = new URLSearchParams({
|
| 82 |
+
page: page.toString(),
|
| 83 |
+
page_size: pageSize.toString(),
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
if (state) {
|
| 87 |
+
params.append("state", state);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
if (userId) {
|
| 91 |
+
params.append("user_id", userId);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const response = await fetch(`${this.baseUrl}/issues?${params}`);
|
| 95 |
+
|
| 96 |
+
if (!response.ok) {
|
| 97 |
+
throw new Error("Failed to fetch issues");
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return response.json();
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
async connectToFlowStream(
|
| 104 |
+
issueId: string,
|
| 105 |
+
onMessage: (data: any) => void,
|
| 106 |
+
onError: (error: Error) => void,
|
| 107 |
+
): Promise<() => void> {
|
| 108 |
+
const eventSource = new EventSource(`${this.baseUrl}/flow/flow/${issueId}`);
|
| 109 |
+
|
| 110 |
+
const listener: EventSourceListener = (event) => {
|
| 111 |
+
if (event.type === "message") {
|
| 112 |
+
try {
|
| 113 |
+
const data = JSON.parse(event.data || "{}");
|
| 114 |
+
onMessage(data);
|
| 115 |
+
} catch (e) {
|
| 116 |
+
console.error("Failed to parse SSE message:", e);
|
| 117 |
+
}
|
| 118 |
+
} else if (event.type === "error") {
|
| 119 |
+
onError(new Error("SSE connection error"));
|
| 120 |
+
}
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
eventSource.addEventListener("message", listener);
|
| 124 |
+
eventSource.addEventListener("error", listener);
|
| 125 |
+
|
| 126 |
+
return () => {
|
| 127 |
+
eventSource.removeAllEventListeners();
|
| 128 |
+
eventSource.close();
|
| 129 |
+
};
|
| 130 |
+
}
|
| 131 |
+
async confirmIssue(issueId: string, confirmed: boolean): Promise<Issue> {
|
| 132 |
+
const response = await fetch(`${this.baseUrl}/issues/${issueId}/confirm`, {
|
| 133 |
+
method: "POST",
|
| 134 |
+
headers: {
|
| 135 |
+
"Content-Type": "application/json",
|
| 136 |
+
},
|
| 137 |
+
body: JSON.stringify({ confirmed }),
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
if (!response.ok) {
|
| 141 |
+
throw new Error("Failed to confirm issue");
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
return response.json();
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
export const issueService = new IssueService();
|
User/src/services/locationService.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as Location from 'expo-location';
|
| 2 |
+
import { Linking, Alert, Platform } from 'react-native';
|
| 3 |
+
import { LocationData } from '../types';
|
| 4 |
+
|
| 5 |
+
export async function checkLocationServicesEnabled(): Promise<boolean> {
|
| 6 |
+
const enabled = await Location.hasServicesEnabledAsync();
|
| 7 |
+
return enabled;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export async function requestLocationPermission(): Promise<boolean> {
|
| 11 |
+
const { status } = await Location.requestForegroundPermissionsAsync();
|
| 12 |
+
return status === 'granted';
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export async function ensureLocationEnabled(): Promise<{ enabled: boolean; permission: boolean }> {
|
| 16 |
+
const enabled = await checkLocationServicesEnabled();
|
| 17 |
+
|
| 18 |
+
if (!enabled) {
|
| 19 |
+
return { enabled: false, permission: false };
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const permission = await requestLocationPermission();
|
| 23 |
+
return { enabled: true, permission };
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export async function promptEnableLocation(): Promise<void> {
|
| 27 |
+
Alert.alert(
|
| 28 |
+
'Location Required',
|
| 29 |
+
'GPS must be enabled to report issues. This ensures accurate location data and prevents fraudulent reports.',
|
| 30 |
+
[
|
| 31 |
+
{ text: 'Cancel', style: 'cancel' },
|
| 32 |
+
{
|
| 33 |
+
text: 'Open Settings',
|
| 34 |
+
onPress: () => {
|
| 35 |
+
if (Platform.OS === 'ios') {
|
| 36 |
+
Linking.openURL('app-settings:');
|
| 37 |
+
} else {
|
| 38 |
+
Linking.openSettings();
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
},
|
| 42 |
+
]
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export async function getCurrentLocation(
|
| 47 |
+
minAccuracy: number = 20,
|
| 48 |
+
timeout: number = 30000
|
| 49 |
+
): Promise<LocationData> {
|
| 50 |
+
const startTime = Date.now();
|
| 51 |
+
|
| 52 |
+
while (Date.now() - startTime < timeout) {
|
| 53 |
+
const location = await Location.getCurrentPositionAsync({
|
| 54 |
+
accuracy: Location.Accuracy.BestForNavigation,
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
if (location.coords.accuracy !== null && location.coords.accuracy <= minAccuracy) {
|
| 58 |
+
return {
|
| 59 |
+
latitude: location.coords.latitude,
|
| 60 |
+
longitude: location.coords.longitude,
|
| 61 |
+
accuracy: location.coords.accuracy,
|
| 62 |
+
heading: location.coords.heading ?? undefined,
|
| 63 |
+
altitude: location.coords.altitude ?? undefined,
|
| 64 |
+
};
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const location = await Location.getCurrentPositionAsync({
|
| 71 |
+
accuracy: Location.Accuracy.High,
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
return {
|
| 75 |
+
latitude: location.coords.latitude,
|
| 76 |
+
longitude: location.coords.longitude,
|
| 77 |
+
accuracy: location.coords.accuracy ?? 999,
|
| 78 |
+
heading: location.coords.heading ?? undefined,
|
| 79 |
+
altitude: location.coords.altitude ?? undefined,
|
| 80 |
+
};
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export function isLocationAccurate(accuracy: number, threshold: number = 15): boolean {
|
| 84 |
+
return accuracy <= threshold;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
export async function watchLocationWithGpsCheck(
|
| 88 |
+
onLocationUpdate: (location: LocationData) => void,
|
| 89 |
+
onGpsStatusChange: (enabled: boolean) => void,
|
| 90 |
+
accuracyThreshold: number = 15
|
| 91 |
+
): Promise<() => void> {
|
| 92 |
+
let subscription: Location.LocationSubscription | null = null;
|
| 93 |
+
let gpsCheckInterval: ReturnType<typeof setInterval> | null = null;
|
| 94 |
+
|
| 95 |
+
const checkGpsAndStart = async () => {
|
| 96 |
+
const enabled = await checkLocationServicesEnabled();
|
| 97 |
+
onGpsStatusChange(enabled);
|
| 98 |
+
|
| 99 |
+
if (enabled && !subscription) {
|
| 100 |
+
subscription = await Location.watchPositionAsync(
|
| 101 |
+
{
|
| 102 |
+
accuracy: Location.Accuracy.BestForNavigation,
|
| 103 |
+
timeInterval: 1000,
|
| 104 |
+
distanceInterval: 1,
|
| 105 |
+
},
|
| 106 |
+
(newLocation) => {
|
| 107 |
+
const accuracy = newLocation.coords.accuracy ?? 999;
|
| 108 |
+
onLocationUpdate({
|
| 109 |
+
latitude: newLocation.coords.latitude,
|
| 110 |
+
longitude: newLocation.coords.longitude,
|
| 111 |
+
accuracy: accuracy,
|
| 112 |
+
heading: newLocation.coords.heading ?? undefined,
|
| 113 |
+
altitude: newLocation.coords.altitude ?? undefined,
|
| 114 |
+
});
|
| 115 |
+
}
|
| 116 |
+
);
|
| 117 |
+
} else if (!enabled && subscription) {
|
| 118 |
+
subscription.remove();
|
| 119 |
+
subscription = null;
|
| 120 |
+
}
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
await checkGpsAndStart();
|
| 124 |
+
|
| 125 |
+
gpsCheckInterval = setInterval(async () => {
|
| 126 |
+
const enabled = await checkLocationServicesEnabled();
|
| 127 |
+
onGpsStatusChange(enabled);
|
| 128 |
+
|
| 129 |
+
if (!enabled && subscription) {
|
| 130 |
+
subscription.remove();
|
| 131 |
+
subscription = null;
|
| 132 |
+
} else if (enabled && !subscription) {
|
| 133 |
+
await checkGpsAndStart();
|
| 134 |
+
}
|
| 135 |
+
}, 3000);
|
| 136 |
+
|
| 137 |
+
return () => {
|
| 138 |
+
if (subscription) {
|
| 139 |
+
subscription.remove();
|
| 140 |
+
}
|
| 141 |
+
if (gpsCheckInterval) {
|
| 142 |
+
clearInterval(gpsCheckInterval);
|
| 143 |
+
}
|
| 144 |
+
};
|
| 145 |
+
}
|
User/src/theme/index.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const colors = {
|
| 2 |
+
primary: {
|
| 3 |
+
main: "#3B82F6",
|
| 4 |
+
light: "#60A5FA",
|
| 5 |
+
dark: "#2563EB",
|
| 6 |
+
contrast: "#FFFFFF",
|
| 7 |
+
},
|
| 8 |
+
secondary: {
|
| 9 |
+
main: "#60A5FA",
|
| 10 |
+
light: "#93C5FD",
|
| 11 |
+
dark: "#3B82F6",
|
| 12 |
+
contrast: "#FFFFFF",
|
| 13 |
+
},
|
| 14 |
+
accent: {
|
| 15 |
+
cta: "#F97316",
|
| 16 |
+
orange: "#F97316",
|
| 17 |
+
pink: "#EC4899",
|
| 18 |
+
cyan: "#06B6D4",
|
| 19 |
+
purple: "#8B5CF6",
|
| 20 |
+
},
|
| 21 |
+
background: {
|
| 22 |
+
primary: "#F1F5F9", // Slate 100
|
| 23 |
+
secondary: "#E2E8F0", // Slate 200 (Darker for contrast)
|
| 24 |
+
tertiary: "#CBD5E1", // Slate 300
|
| 25 |
+
card: "#FFFFFF",
|
| 26 |
+
gradient: ["#F8FAFC", "#E2E8F0"] as const,
|
| 27 |
+
},
|
| 28 |
+
text: {
|
| 29 |
+
primary: "#1E293B",
|
| 30 |
+
secondary: "#475569",
|
| 31 |
+
tertiary: "#94A3B8",
|
| 32 |
+
inverse: "#FFFFFF",
|
| 33 |
+
},
|
| 34 |
+
border: {
|
| 35 |
+
light: "#E2E8F0",
|
| 36 |
+
medium: "#CBD5E1",
|
| 37 |
+
accent: "rgba(59, 130, 246, 0.3)",
|
| 38 |
+
},
|
| 39 |
+
status: {
|
| 40 |
+
success: "#10B981",
|
| 41 |
+
warning: "#F59E0B",
|
| 42 |
+
error: "#EF4444",
|
| 43 |
+
info: "#3B82F6",
|
| 44 |
+
},
|
| 45 |
+
priority: {
|
| 46 |
+
critical: "#EF4444",
|
| 47 |
+
high: "#F97316",
|
| 48 |
+
medium: "#F59E0B",
|
| 49 |
+
low: "#10B981",
|
| 50 |
+
},
|
| 51 |
+
glass: {
|
| 52 |
+
background: "rgba(255, 255, 255, 0.7)",
|
| 53 |
+
border: "rgba(255, 255, 255, 0.5)",
|
| 54 |
+
},
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
export const spacing = {
|
| 58 |
+
xs: 4,
|
| 59 |
+
sm: 8,
|
| 60 |
+
md: 16,
|
| 61 |
+
lg: 24,
|
| 62 |
+
xl: 32,
|
| 63 |
+
xxl: 48,
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
export const borderRadius = {
|
| 67 |
+
sm: 8,
|
| 68 |
+
md: 12,
|
| 69 |
+
lg: 16,
|
| 70 |
+
xl: 24,
|
| 71 |
+
full: 9999,
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
export const typography = {
|
| 75 |
+
h1: {
|
| 76 |
+
fontSize: 32,
|
| 77 |
+
fontWeight: "700" as const,
|
| 78 |
+
lineHeight: 40,
|
| 79 |
+
},
|
| 80 |
+
h2: {
|
| 81 |
+
fontSize: 24,
|
| 82 |
+
fontWeight: "600" as const,
|
| 83 |
+
lineHeight: 32,
|
| 84 |
+
},
|
| 85 |
+
h3: {
|
| 86 |
+
fontSize: 20,
|
| 87 |
+
fontWeight: "600" as const,
|
| 88 |
+
lineHeight: 28,
|
| 89 |
+
},
|
| 90 |
+
body: {
|
| 91 |
+
fontSize: 16,
|
| 92 |
+
fontWeight: "400" as const,
|
| 93 |
+
lineHeight: 24,
|
| 94 |
+
},
|
| 95 |
+
bodySmall: {
|
| 96 |
+
fontSize: 14,
|
| 97 |
+
fontWeight: "400" as const,
|
| 98 |
+
lineHeight: 20,
|
| 99 |
+
},
|
| 100 |
+
caption: {
|
| 101 |
+
fontSize: 12,
|
| 102 |
+
fontWeight: "400" as const,
|
| 103 |
+
lineHeight: 16,
|
| 104 |
+
},
|
| 105 |
+
button: {
|
| 106 |
+
fontSize: 16,
|
| 107 |
+
fontWeight: "600" as const,
|
| 108 |
+
lineHeight: 24,
|
| 109 |
+
},
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
export const shadows = {
|
| 113 |
+
sm: {
|
| 114 |
+
shadowColor: "#000",
|
| 115 |
+
shadowOffset: { width: 0, height: 2 },
|
| 116 |
+
shadowOpacity: 0.1,
|
| 117 |
+
shadowRadius: 4,
|
| 118 |
+
elevation: 2,
|
| 119 |
+
},
|
| 120 |
+
md: {
|
| 121 |
+
shadowColor: "#000",
|
| 122 |
+
shadowOffset: { width: 0, height: 4 },
|
| 123 |
+
shadowOpacity: 0.15,
|
| 124 |
+
shadowRadius: 8,
|
| 125 |
+
elevation: 4,
|
| 126 |
+
},
|
| 127 |
+
lg: {
|
| 128 |
+
shadowColor: "#000",
|
| 129 |
+
shadowOffset: { width: 0, height: 8 },
|
| 130 |
+
shadowOpacity: 0.2,
|
| 131 |
+
shadowRadius: 16,
|
| 132 |
+
elevation: 8,
|
| 133 |
+
},
|
| 134 |
+
glow: {
|
| 135 |
+
shadowColor: "#6366F1",
|
| 136 |
+
shadowOffset: { width: 0, height: 0 },
|
| 137 |
+
shadowOpacity: 0.4,
|
| 138 |
+
shadowRadius: 20,
|
| 139 |
+
elevation: 10,
|
| 140 |
+
},
|
| 141 |
+
};
|
User/src/types/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface User {
|
| 2 |
+
id: string;
|
| 3 |
+
email: string;
|
| 4 |
+
name?: string;
|
| 5 |
+
avatar_url?: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export interface Issue {
|
| 9 |
+
id: string;
|
| 10 |
+
description?: string;
|
| 11 |
+
latitude: number;
|
| 12 |
+
longitude: number;
|
| 13 |
+
state: IssueState;
|
| 14 |
+
priority?: number;
|
| 15 |
+
category?: string;
|
| 16 |
+
confidence?: number;
|
| 17 |
+
image_urls: string[];
|
| 18 |
+
annotated_urls: string[];
|
| 19 |
+
validation_source?: string;
|
| 20 |
+
is_duplicate: boolean;
|
| 21 |
+
parent_issue_id?: string;
|
| 22 |
+
geo_status?: string;
|
| 23 |
+
created_at: string;
|
| 24 |
+
updated_at: string;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export type IssueState = 'reported' | 'validated' | 'assigned' | 'in_progress' | 'resolved' | 'closed' | 'rejected' | 'pending_confirmation';
|
| 28 |
+
|
| 29 |
+
export interface CreateIssuePayload {
|
| 30 |
+
images: File[];
|
| 31 |
+
description?: string;
|
| 32 |
+
latitude: number;
|
| 33 |
+
longitude: number;
|
| 34 |
+
accuracy_meters?: number;
|
| 35 |
+
platform: string;
|
| 36 |
+
device_model?: string;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export interface IssueListResponse {
|
| 40 |
+
items: Issue[];
|
| 41 |
+
total: number;
|
| 42 |
+
page: number;
|
| 43 |
+
page_size: number;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export interface LocationData {
|
| 47 |
+
latitude: number;
|
| 48 |
+
longitude: number;
|
| 49 |
+
accuracy: number;
|
| 50 |
+
heading?: number;
|
| 51 |
+
altitude?: number;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export interface FlowStep {
|
| 55 |
+
name: string;
|
| 56 |
+
status: 'pending' | 'running' | 'done' | 'error';
|
| 57 |
+
decision?: string;
|
| 58 |
+
reasoning?: string;
|
| 59 |
+
timestamp?: string;
|
| 60 |
+
}
|
User/tsconfig.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "expo/tsconfig.base",
|
| 3 |
+
"compilerOptions": {
|
| 4 |
+
"strict": true
|
| 5 |
+
}
|
| 6 |
+
}
|