0xarchit commited on
Commit
71638d4
·
1 Parent(s): e40d4c4

User app beta v1 complete

Browse files
.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
+ }