20 Commits

Author SHA1 Message Date
b4079a25bb Merge pull request 'feat(chatbot): integrate OpenRouter API for AI assistance in dashboard' (#1) from chat_bot into main
Reviewed-on: #1
2025-09-27 15:04:21 +08:00
dbf7be38f7 Merge branch 'main' into chat_bot 2025-09-27 15:04:14 +08:00
f177f075bc ww 2025-09-27 15:02:39 +08:00
ec3c7320d7 feat(chatbot): integrate OpenRouter API for AI assistance in dashboard
- Added a Chatbot component to the dashboard for user interaction and support.
- Created a README for the Chatbot detailing setup, features, and usage instructions.
- Introduced environment variables for secure API key management.
- Updated app.js to include the Chatbot component.
- Implemented a configuration server to serve API keys securely.
- Enhanced styles for the Chatbot interface to improve user experience.
2025-09-27 15:00:30 +08:00
Mohammad Azri
2e30e0039e Updated backend for 2 seperate api link 2025-09-27 14:19:37 +08:00
c0c3fb7b5a Merge pull request #2 from aimanzahar/design_fix
Design fix
2025-09-27 14:07:09 +08:00
fd7d3a9635 refactor(dashboard,styles): improve layout and styling; update database
- Enhanced item actions layout in dashboard for better alignment and usability.
- Updated CSS styles for queue items, buttons, and filter groups to improve responsiveness and aesthetics.
- Modified database to reflect recent changes in the application.
2025-09-27 14:06:31 +08:00
adffc18a2f refactor(dashboard,styles): improve filter layout and styling; update database
- Adjusted filter layout in dashboard app.js for better alignment and spacing.
- Enhanced CSS styles for filter groups, buttons, and overall dashboard aesthetics.
- Updated fixmate.db to reflect recent changes.
2025-09-27 13:31:36 +08:00
77df67d8c0 Update database and remove obsolete image files; increase map zoom level
- Updated fixmate.db contents.
- Deleted two outdated image files from uploads directory.
- Increased maximum zoom level for the map in dashboard app.js from 19 to 30 for enhanced detail.
2025-09-27 13:01:12 +08:00
a8eae96f7f feat(app,ui): add map-based location picker and geocoding
- introduce LocationPickerScreen with flutter_map for selecting and confirming
  locations, including markers and loading states
- integrate manual location editing in ReviewScreen and persist updated
  coordinates/address in the report
- add GeocodingService with Nominatim search, Photon fallback, reverse
  geocoding, debounced queries, caching, and timeouts
- update i18n (en) with new map/location strings; remove unused onboarding
  and welcome texts
2025-09-27 12:55:36 +08:00
ed63a0cbc8 chore(branding): rebrand FixMate to CityPulse across codebase
- Update product name in README, backend docs, and design tokens
- Rename Flutter root widget to CityPulseApp and update tests
- Update dashboard brand strings and HTML title
- Refresh i18n strings (en/ms) and welcome taglines
- Adjust backend API title/description and root message
- Minor formatting in ApiService comments; no logic changes
- Update Android/iOS manifest comments and pubspec description

No API endpoints or response schema changes.
2025-09-27 11:15:15 +08:00
6924455d35 feat(utils,ui): enhance category mapping for tickets
- Updated category mapping in utils.py to include additional categories: 'broken_streetlight', 'drainage', 'pothole', 'signage', and 'streetlight'.
- Implemented a new mapping function in app.js to align backend categories with frontend display values, ensuring consistent data representation across the application.
- Removed an outdated image file and added a new image file to the uploads directory.
2025-09-27 11:06:39 +08:00
52ac471d3e fix(ui,db): align status with backend enum
Add mapping to convert dashboard status 'submitted', 'in_progress',
'fixed' to backend values 'New', 'In Progress', 'Fixed' before sending
PATCH to /api/tickets/{id}/status.

Keep dashboard format in local state when the API returns no body.
Add debug logging for visibility during updates.

Also update fixmate.db contents; no schema changes.
2025-09-27 10:46:25 +08:00
7cb7b68446 feat(api,ui): enhance ticket data mapping and add test endpoint; improve error handling and logging 2025-09-27 10:27:25 +08:00
77d5be8fd1 feat(api,ui,db): add address, guest users, image URLs; update API
- Backend:
  - Add address column to tickets and migration script
  - Create guest users when user_id is missing; accept user_name and address
  - Normalize stored image paths and expose absolute image_url
  - Introduce utils for path normalization and ticket serialization
  - Add CORS configuration for dashboard/emulator origins
  - Tickets API:
    - Serialize via ticket_to_dict with consistent schema
    - Change status update to PATCH /api/tickets/{id}/status with JSON body
    - Add DELETE /api/tickets/{id} with safe file removal
- Dashboard:
  - Fetch tickets from backend, show thumbnails, absolute image URLs
  - Status select + PATCH updates, toasts for feedback
  - Add i18n key btn.viewDetails
- Mobile app:
  - Persist device user_id via SharedPreferences
  - Fetch and merge API tickets; prefer network imageUrl
  - Submit user_name and address; delete via API when available
  - Make location acquisition robust with fallbacks and non-blocking UX
- Android/deps:
  - Disable Geolocator NMEA listener to prevent crashes
  - Downgrade geolocator to ^11.0.0 for stability

BREAKING CHANGE:
- Status endpoint changed from PATCH /api/tickets/{id}?new_status=... to
  PATCH /api/tickets/{id}/status with JSON body: {"status":"in_progress"}.
- /api/tickets and /api/tickets/{id} responses now use "id" (replacing
  "ticket_id"), include "image_url", and normalize fields for clients. Update
  consumers to use the new schema.
2025-09-27 09:31:40 +08:00
0e3eea7de9 Enhance dashboard interactivity and update dependencies
- Added a new function to navigate to specific locations on the map with animation and detail display.
- Made the item title clickable to trigger the navigation function.
- Updated CSS to style the clickable item title for better user experience.
- Added `pydantic[email]` to backend requirements for enhanced validation capabilities.
2025-09-26 20:31:22 +08:00
2a46ecb7d2 Enhance README and backend functionality for dashboard access and image upload validation
- Added a new section in README detailing the dashboard access method, features, and troubleshooting tips.
- Updated backend server startup message to allow access from mobile/emulator.
- Refactored image upload handling in the report route to validate file types and extensions, ensuring only supported image formats are accepted.
- Adjusted upload directory path for consistency.
2025-09-26 19:54:07 +08:00
11ea469b6d Update README to reflect new project structure and features
- Revamped project description to highlight the integration of Flutter frontend, Python FastAPI backend, and AI-powered image classification.
- Added detailed sections on system architecture, quick start guide, API endpoints, and features overview.
- Enhanced troubleshooting and performance considerations for both frontend and backend.
- Included future enhancements and acknowledgments for technologies used in the project.
2025-09-26 19:31:24 +08:00
5a311d7ad0 Refactor(api_service): streamline API request handling and normalize data types
- Updated the `createUser` method for improved readability and consistency.
- Refactored normalization functions to return specific enum types for categories, severities, and statuses.
- Adjusted the handling of image uploads and location data to enhance clarity and maintainability.
- Changed the dependency type for the `http` package in `pubspec.lock` to reflect its direct usage.
2025-09-26 19:17:23 +08:00
0123257ad1 Merge pull request #1 from aimanzahar/cursor/integrate-fixmate-backend-into-project-0547
Integrate FixMate backend into project
2025-09-26 18:59:34 +08:00
56 changed files with 4246 additions and 603 deletions

3
.gitignore vendored
View File

@@ -26,6 +26,9 @@ migrate_working_dir/
# AI Memory Bank - keep private
.kilocode/
#ignore all venv
venv/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id

478
README.md
View File

@@ -1,113 +1,407 @@
# FixMate — Flutter app + React dashboard (Codespaces-friendly)
# 🏗️ CityPulse - Smart Citizen-Driven Urban Maintenance Platform
FixMate is a lightweight, demo-friendly citizen maintenance reporter. It lets you quickly capture issues, geotag them, and visualize reports on a simple dashboard. Theres no backend; everything runs locally or in the browser — perfect for hackathons, prototypes, and GitHub Codespaces.
CityPulse is a comprehensive citizen reporting application that combines **Flutter frontend** with **Python FastAPI backend** and **AI-powered image classification**. Users can capture urban issues (potholes, broken streetlights, trash, etc.), get automatic AI classification, and track their reports through a complete management system.
## Why this repo exists
- Zero-backend demo: data lives on-device (or in demo JSON for the dashboard).
- Deterministic "mock AI" categorization so UX flows are predictable.
- Fast setup in Codespaces or locally with minimal dependencies.
## 🎯 System Architecture
## Quick start in GitHub Codespaces
### Frontend (Flutterrr)
- **Location**: Root directory
- **Technology**: Flutter (Dart) with Material Design
- **Purpose**: Cross-platform mobile and web interface for citizens
- **Features**: Camera integration, GPS location, map visualization, bilingual support (EN/BM)
You can run both the Flutter app (as a web app) and the static React dashboard entirely inside a Codespace. No emulators required.
### Backend (Python FastAPI)
- **Location**: `backend/` directory
- **Technology**: Python FastAPI with SQLAlchemy + SQLite
- **Purpose**: RESTful API server with AI-powered image classification
- **Features**: YOLO-based object detection, severity classification, ticket management
### 1) Flutter Web (recommended in Codespaces)
- Prerequisites: Flutter SDK is available in your Codespace. If not, install it or use a devcontainer with Flutter preinstalled. Then enable web:
- flutter config --enable-web
- Install dependencies:
- flutter pub get
- Run a local web server (Codespaces will auto-forward the port):
- flutter run -d web-server --web-port 3000
- Open the forwarded port from the Codespaces ports panel. Camera and geolocation typically work over the Codespaces HTTPS tunneled URL.
### Data Flow
```
User takes photo → Flutter App → FastAPI Backend → AI Analysis → Database
↓ ↓ ↓ ↓ ↓
Reports List ←────── API Calls ←─── HTTP/REST ──→ YOLO Model ─→ SQLite
```
Notes:
- Geolocation/camera require HTTPS in many browsers; Codespaces forwarded URLs are HTTPS, which helps.
- On web, images are stored as base64; on mobile, images are saved to app storage and paths persist (see [lib/services/storage.dart](lib/services/storage.dart:1)).
- Entry point for the app is [main()](lib/main.dart:8), which wires up i18n and the locale provider and launches [FixMateApp](lib/app.dart:12).
## 🚀 Quick Start Guide
### 2) React dashboard (static site)
- Serve inside Codespaces (Python simple HTTP server):
- cd dashboard && python -m http.server 8000
- Open the forwarded port and view your dashboard.
### Prerequisites
- **Flutter SDK** 3.8.1+ ([Install Guide](https://docs.flutter.dev/get-started/install))
- **Python** 3.11+ ([Install Guide](https://python.org/downloads/))
- **Git** for version control
Behavior:
- Language toggle persists in localStorage.
- Filters drive a clustered Leaflet map, queue, drawer, stats, and optional heatmap overlay.
### 1. Clone and Setup
```bash
git clone <your-repo-url>
cd fixmate-frontend
```
## Running locally (outside Codespaces)
### 2. Install Flutter Dependencies
```bash
flutter pub get
```
### Flutter
- Install Flutter (stable) and run:
- flutter pub get
- flutter run (or flutter run -d chrome)
- Android/iOS will prompt for camera and location permissions. On web, geolocation/camera require HTTPS; some browsers restrict camera on http.
- App root: [FixMateApp](lib/app.dart:12). Bottom tabs and routing live in [MainScreen](lib/app.dart:36) and the onboarding/start logic lives in [StartRouter](lib/app.dart:114).
### 3. Setup Backend
```bash
cd backend
pip install -r requirements.txt
```
### Dashboard
- Serve the dashboard folder over HTTP:
- cd dashboard && python -m http.server 8000
- Open http://127.0.0.1:8000 (or your dev server URL).
### 4. Start Backend Server (Terminal 1)
```bash
cd backend
python main.py
```
✅ Backend will run on: `http://127.0.0.1:8000`
## Features implemented
- Flutter app tabs: Report, Map, My Reports, Settings (bilingual EN/BM)
- Capture flow: camera/gallery, GPS, deterministic mock AI, local storage
- Map: OpenStreetMap via flutter_map with clustering, filters, marker details, legend, external maps link
- My Reports: list/detail with status cycle and delete
- Settings: language toggle and clear data
- React dashboard: filters, clustered map, queue, drawer, stats, heatmap toggle
### 5. Start Flutter App (Terminal 2)
```bash
# Navigate back to project root
cd ..
## Project structure
- Key Flutter files:
- [lib/app.dart](lib/app.dart:1)
- [lib/main.dart](lib/main.dart:1)
- [lib/screens/report_flow/capture_screen.dart](lib/screens/report_flow/capture_screen.dart:1)
- [lib/screens/map/map_screen.dart](lib/screens/map/map_screen.dart:1)
- [lib/screens/my_reports/my_reports_screen.dart](lib/screens/my_reports/my_reports_screen.dart:1)
- [lib/screens/settings/settings_screen.dart](lib/screens/settings/settings_screen.dart:1)
- [lib/services/storage.dart](lib/services/storage.dart:1), [lib/services/mock_ai.dart](lib/services/mock_ai.dart:1), [lib/services/location_service.dart](lib/services/location_service.dart:1)
- [lib/models/report.dart](lib/models/report.dart:1), [lib/models/enums.dart](lib/models/enums.dart:1)
- [assets/lang/en.json](assets/lang/en.json:1), [assets/lang/ms.json](assets/lang/ms.json:1)
# Start Flutter app (choose your target)
flutter run # Mobile (Android/iOS)
# OR
flutter run -d chrome # Web (Chrome)
# OR
flutter run -d web-server # Web Server
```
- Dashboard files:
- [dashboard/index.html](dashboard/index.html:1), [dashboard/app.js](dashboard/app.js:1), [dashboard/styles.css](dashboard/styles.css:1)
- [dashboard/i18n/en.json](dashboard/i18n/en.json:1), [dashboard/i18n/ms.json](dashboard/i18n/ms.json:1)
- [dashboard/data/demo-reports.json](dashboard/data/demo-reports.json:1)
## 🔧 Alternative Startup Methods
## Tech stack
- Flutter packages: flutter_map, flutter_map_marker_cluster, latlong2, geolocator, image_picker, path_provider, shared_preferences, uuid, url_launcher, provider (see [pubspec.yaml](pubspec.yaml:31))
- Dashboard: React 18 UMD, Leaflet + markercluster (+ optional heat), Day.js
### Method A: Backend Only
```bash
cd backend
python main.py
```
## Developer notes (for quick orientation)
- App entry: [main()](lib/main.dart:8) initializes locale/i18n and launches [FixMateApp](lib/app.dart:12).
- Tab nav and screens: [MainScreen](lib/app.dart:36) displays tabs for:
- Report: [CaptureScreen](lib/screens/report_flow/capture_screen.dart:1)
- Map: [MapScreen](lib/screens/map/map_screen.dart:1)
- My Reports: [MyReportsScreen](lib/screens/my_reports/my_reports_screen.dart:1)
- Settings: [SettingsScreen](lib/screens/settings/settings_screen.dart:1)
- Onboarding + welcome handoff: [StartRouter](lib/app.dart:114) decides whether to show onboarding or the main app.
- Themes live in [lib/theme/themes.dart](lib/theme/themes.dart:1), translations in [assets/lang/en.json](assets/lang/en.json:1) and [assets/lang/ms.json](assets/lang/ms.json:1).
### Method B: Using Uvicorn (Alternative)
```bash
cd backend
uvicorn main:app --host 127.0.0.1 --port 8000 --reload
```
## Known limitations
- No backend; all data is local or demo JSON.
- "AI" is simulated; severity/category are heuristic and not model-driven.
- Dashboard UI state is not persisted; a refresh resets filters and selections.
- OpenStreetMap tile usage is subject to their terms and rate limits.
- Mobile-only features (camera with native picker, GPS background behavior) wont fully work in Codespaces; use Flutter Web inside Codespaces for best results.
### Method C: Flutter Web (Web Version)
```bash
flutter run -d chrome # or firefox, edge
```
## Visual tokens
- Severity colors: High #D32F2F, Medium #F57C00, Low #388E3C
- Status colors: Submitted #1976D2, In Progress #7B1FA2, Fixed #455A64
### Method D: Development Mode (Hot Reload)
```bash
# Terminal 1 - Backend with auto-reload
cd backend
uvicorn main:app --reload
## Troubleshooting
- Browser blocks camera/geolocation on non-HTTPS:
- Use Codespaces forwarded HTTPS URL or run locally over HTTPS.
- Flutter web server port not visible:
- Check Codespaces “Ports” tab, ensure the port is “Public”.
- Slow map tile loads:
- You may be rate-limited or on a constrained network; reduce panning/zoom or cache during demos.
# Terminal 2 - Flutter with hot reload
flutter run
```
## License
- Placeholder: add a LICENSE file or specify licensing before distribution.
### Method E: Dashboard Access (Web Interface)
```bash
# Terminal 1 - Start Backend Server
cd backend
python main.py
# OR (if port conflicts occur):
uvicorn main:app --host 127.0.0.1 --port 8000 --reload
## Acknowledgements
- OpenStreetMap, Leaflet, flutter_map and community plugins, React, Day.js, Flutter community.
# Terminal 2 - Start Dashboard Server
cd dashboard
python -m http.server 3000
# Open browser and navigate to:
# http://localhost:3000
```
**Dashboard Features:**
- 🗺️ Interactive map with report visualization
- 🔍 Advanced filtering by category, severity, status, and date
- 📊 Real-time statistics and analytics
- 🌡️ Heatmap toggle for density visualization
- 🔄 Status management (submitted → in_progress → fixed)
- 📱 Responsive design for desktop and mobile
- 🌍 Bilingual support (English/Bahasa Malaysia)
**Troubleshooting Dashboard:**
- **Port 8000 in use?** Try: `uvicorn main:app --host 127.0.0.1 --port 8080 --reload` (then update dashboard to use port 8080)
- **Port 3000 in use?** Try: `python -m http.server 3001`
- **Backend connection fails?** Dashboard will automatically use demo data
- **CORS issues?** Ensure backend allows requests from `http://localhost:3000`
## 📱 API Endpoints
The Flutter app communicates with these backend endpoints:
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/tickets` | GET | Fetch all reports |
| `/api/report` | POST | Submit new report with image |
| `/api/tickets/{id}` | GET | Get specific report |
| `/api/tickets/{id}` | PATCH | Update report status |
| `/api/analytics` | GET | Get dashboard analytics |
| `/api/users` | POST | Create user account |
### API Documentation
View interactive API documentation at: `http://127.0.0.1:8000/docs`
---
## 🎮 Features Overview
### Flutter Frontend Features
-**Report Flow**: Camera/gallery photo capture with GPS location
-**AI Classification**: Automatic issue type and severity detection
-**Map View**: Interactive OpenStreetMap with clustering and filtering
-**Report Management**: View, edit, and track report status
-**Bilingual Support**: English and Bahasa Malaysia
-**Settings**: Language toggle and data management
### Backend AI Features
-**YOLO Object Detection**: Detects urban issues from images
-**Severity Classification**: ML model assesses issue severity
-**SQLite Database**: Local data storage with full CRUD operations
-**RESTful API**: Complete API for mobile app integration
-**File Upload**: Image storage and processing
## 📁 Project Structure
### Frontend (Flutter)
```
lib/
├── app.dart # Main app widget and routing
├── main.dart # App entry point
├── models/
│ ├── report.dart # Report data model
│ └── enums.dart # Category, severity, status enums
├── screens/
│ ├── report_flow/ # Photo capture flow
│ ├── map/ # Map visualization screen
│ ├── my_reports/ # User reports management
│ └── settings/ # App settings
├── services/
│ ├── api_service.dart # Backend API communication
│ ├── storage.dart # Local data storage
│ ├── location_service.dart # GPS location services
│ └── mock_ai.dart # AI classification logic
├── theme/
│ └── themes.dart # App theming
├── widgets/ # Reusable UI components
└── l10n/ # Internationalization
```
### Backend (Python)
```
backend/
├── main.py # FastAPI server entry point
├── requirements.txt # Python dependencies
├── app/
│ ├── database.py # SQLite database setup
│ ├── models/ # Database models
│ ├── routes/ # API route handlers
│ ├── services/ # Business logic and AI services
│ ├── schemas/ # Pydantic data models
│ └── static/uploads/ # Image storage
└── test/ # Test files and utilities
```
## 🛠️ Technology Stack
### Frontend Technology Stack
- **Flutter**: 3.8.1+ with Material Design
- **Key Packages**:
- `flutter_map` + `flutter_map_marker_cluster` (Interactive maps)
- `geolocator` (GPS location services)
- `image_picker` (Camera integration)
- `http` (API communication)
- `provider` (State management)
- `shared_preferences` (Local storage)
### Backend Technology Stack
- **FastAPI**: 0.117.1+ (Modern Python web framework)
- **SQLAlchemy**: 2.0.43+ (ORM for database operations)
- **PyTorch**: 2.8.0+ (Machine learning framework)
- **Ultralytics YOLO**: 8.3.203+ (Object detection)
- **SQLite**: Local database for data persistence
### AI Models
- **Object Detection**: YOLOv12n for issue identification
- **Severity Classification**: Custom PyTorch model
- **Model Storage**: `backend/app/models/` directory
## 🛠️ Troubleshooting
### Backend Issues
**Port 8000 already in use:**
```bash
# Windows
netstat -ano | findstr :8000
taskkill /F /IM python.exe
# Alternative: Kill specific process
Get-Process -Name python | Stop-Process -Force
```
**Missing dependencies:**
```bash
cd backend
pip install -r requirements.txt
pip install python-multipart pydantic[email]
```
**Backend not starting:**
```bash
# Test if modules can be imported
cd backend
python -c "import main; print('Import successful')"
```
### Frontend Issues
**Flutter dependencies:**
```bash
flutter clean
flutter pub get
```
**Device connection issues:**
```bash
flutter devices # List connected devices
flutter doctor # Check Flutter installation
```
**Web-specific issues:**
```bash
flutter config --enable-web
```
### Common Issues
**"Connection refused" errors:**
- Ensure backend server is running on port 8000
- Check firewall settings
- Verify API base URL in `lib/services/api_service.dart`
- For dashboard: Check browser console for CORS errors
**Camera/Geolocation not working:**
- Grant permissions in device settings
- Use HTTPS for web deployment
- Check browser permissions
**Slow AI processing:**
- First startup downloads ML models (may take time)
- Consider using CPU-only builds for faster startup
- Check available memory
## 🧪 Testing & Development
### Backend Testing
```bash
cd backend
python -m pytest test/ # Run all tests
python test/check_torch.py # Verify PyTorch setup
```
### Flutter Testing
```bash
flutter test # Run unit tests
flutter test --coverage # With coverage report
```
### Database Management
```bash
cd backend
python -c "from app.database import engine; print('Database file:', engine.url.database)"
# Database file: backend/app/db/fixmate.db
```
## 📊 Performance Considerations
### Backend Performance
- **First Startup**: ML models download (~200MB) - may take several minutes
- **Memory Usage**: PyTorch models require significant RAM
- **CPU vs GPU**: CPU-only builds available for compatibility
- **Database**: SQLite suitable for small-scale deployments
### Frontend Performance
- **Image Processing**: Images compressed before upload
- **Map Rendering**: Clustering optimizes marker display
- **Caching**: Local storage for offline functionality
## 🚀 Production Deployment
### Backend Deployment
```bash
cd backend
# Production server
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
# Or with Gunicorn
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker
```
### Flutter Deployment
```bash
# Build for production
flutter build apk --release # Android
flutter build ios --release # iOS
flutter build web --release # Web
# Build for specific targets
flutter build appbundle # Android App Bundle
flutter build ipa # iOS Archive
```
## 📚 Key Files Reference
### Essential Flutter Files
- `lib/main.dart` - App entry point
- `lib/app.dart` - Main app widget and navigation
- `lib/services/api_service.dart` - Backend communication
- `lib/models/report.dart` - Data models
- `pubspec.yaml` - Flutter dependencies
### Essential Backend Files
- `backend/main.py` - FastAPI server
- `backend/app/database.py` - Database configuration
- `backend/app/routes/tickets.py` - Ticket API endpoints
- `backend/app/services/ai_service.py` - AI classification logic
- `backend/requirements.txt` - Python dependencies
## 🎯 Future Enhancements
### Planned Features
- [ ] Real-time notifications for status updates
- [ ] Advanced filtering and search capabilities
- [ ] User authentication and profiles
- [ ] Admin dashboard for report management
- [ ] Push notifications for mobile
- [ ] Offline mode with sync capabilities
- [ ] Multi-language support expansion
- [ ] Analytics and reporting dashboard
### Technical Improvements
- [ ] Database optimization for large datasets
- [ ] Caching layer implementation
- [ ] API rate limiting
- [ ] Image compression optimization
- [ ] Background processing for AI tasks
- [ ] Monitoring and logging enhancement
## 📄 License & Acknowledgments
### License
- Placeholder: Add appropriate license for your project
### Acknowledgments
- **OpenStreetMap** - Map data and tile services
- **Leaflet** - Interactive mapping library
- **Flutter Community** - Dart packages and plugins
- **Ultralytics** - YOLO implementation
- **PyTorch** - Machine learning framework
- **FastAPI** - Modern Python web framework
### References
1. [Flutter Documentation](https://docs.flutter.dev/)
2. [FastAPI Documentation](https://fastapi.tiangolo.com/)
3. [YOLOv12 Implementation](https://github.com/ultralytics/ultralytics)
4. [PyTorch Models](https://pytorch.org/)

View File

@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions required for FixMate -->
<!-- Permissions required for CityPulse -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
@@ -35,6 +35,11 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Disable NMEA listener to prevent crashes on Android -->
<meta-data
android:name="com.baseflow.geolocator.nmea"
android:value="false" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -1,5 +1,5 @@
{
"app.name": "FixMate",
"app.name": "CityPulse",
"nav.report": "Report",
"nav.map": "Map",
"nav.myReports": "My Reports",
@@ -18,6 +18,7 @@
"btn.changeStatus": "Change Status",
"btn.view": "View",
"btn.details": "Details",
"btn.viewDetails": "View details",
"btn.retry": "Retry",
"btn.allow": "Allow",
"btn.deny": "Deny",
@@ -32,6 +33,10 @@
"btn.ok": "OK",
"btn.yes": "Yes",
"btn.no": "No",
"btn.editLocation": "Edit Location",
"btn.searchLocation": "Search Location",
"btn.useThisLocation": "Use This Location",
"btn.currentLocation": "Use Current Location",
"label.category": "Category",
"label.severity": "Severity",
"label.status": "Status",
@@ -80,30 +85,16 @@
"settings.theme.dark": "Dark",
"lang.en": "English",
"lang.ms": "Bahasa Malaysia",
"welcome.title": "Spot it. Snap it. Fix it.",
"welcome.subtitle": "Report city issues in seconds with AI-powered detection. Help create safer, better communities together.",
"cta.continueGuest": "Continue as guest",
"cta.signIn": "Sign in",
"cta.skip": "Skip",
"cta.next": "Next",
"cta.getStarted": "Get started",
"onboarding.header": "Welcome to FixMate",
"onboarding.title1": "Fast Issue Reporting",
"onboarding.body1": "Simply take a photo of any urban issue - our AI automatically identifies and categorizes the problem in seconds.",
"onboarding.title2": "Smart City Mapping",
"onboarding.body2": "View all reported issues on an interactive map with intelligent clustering and filtering options.",
"onboarding.title3": "Track Progress",
"onboarding.body3": "Follow the status of your reports from submission to resolution. Help make your community better.",
"auth.title": "Sign in",
"auth.signInWithApple": "Sign in with Apple",
"auth.signInWithGoogle": "Sign in with Google",
"auth.comingSoon": "Coming soon",
"welcome.title": "Spot it. Snap it. Fix it.",
"welcome.title": "Snap. Detect. Fix.",
"welcome.subtitle": "Report city issues in seconds with AI-powered detection. Help create safer, better communities together.",
"cta.continueGuest": "Continue as Guest",
"cta.signIn": "Sign In",
"cta.skip": "Skip for now",
"onboarding.header": "Welcome to FixMate",
"onboarding.header": "Welcome to CityPulse",
"onboarding.title1": "Fast Issue Reporting",
"onboarding.subtitle1": "AI-Powered Detection",
"onboarding.body1": "Simply take a photo of any urban issue - our AI automatically identifies and categorizes the problem in seconds.",
@@ -152,5 +143,16 @@
"confirm.logout.title": "Logout?",
"confirm.logout.message": "You will be returned to the welcome screen.",
"label.createdAt": "Created At",
"error.clearData": "Failed to clear data"
"error.clearData": "Failed to clear data",
"map.searchHint": "Search for a location...",
"map.selectLocation": "Select Location",
"map.selectedLocation": "Selected Location",
"map.confirmLocation": "Confirm this location?",
"map.currentLocation": "Current Location",
"map.searching": "Searching...",
"map.noResults": "No results found",
"map.moveMapToSelect": "Move the map to select a location",
"map.loadingMap": "Loading map...",
"map.gettingAddress": "Getting address...",
"map.continueAnyway": "Continue Anyway"
}

View File

@@ -1,5 +1,5 @@
{
"app.name": "FixMate",
"app.name": "CityPulse",
"nav.report": "Lapor",
"nav.map": "Peta",
"nav.myReports": "Laporan Saya",
@@ -18,6 +18,7 @@
"btn.changeStatus": "Tukar Status",
"btn.view": "Lihat",
"btn.details": "Butiran",
"btn.viewDetails": "Lihat butiran",
"btn.retry": "Cuba Semula",
"btn.allow": "Benarkan",
"btn.deny": "Tolak",
@@ -87,7 +88,7 @@
"cta.skip": "Langkau",
"cta.next": "Seterusnya",
"cta.getStarted": "Mula",
"onboarding.header": "Selamat datang ke FixMate",
"onboarding.header": "Selamat datang ke CityPulse",
"onboarding.title1": "Tangkap pantas",
"onboarding.body1": "Ambil gambar dan hantar dalam kurang satu minit.",
"onboarding.title2": "Peta yang jelas",
@@ -103,7 +104,7 @@
"cta.continueGuest": "Teruskan sebagai Tetamu",
"cta.signIn": "Log Masuk",
"cta.skip": "Langkau buat masa ini",
"onboarding.header": "Selamat Datang ke FixMate",
"onboarding.header": "Selamat Datang ke CityPulse",
"onboarding.title1": "Laporan Isu Pantas",
"onboarding.subtitle1": "Pengesanan Berkuasa AI",
"onboarding.body1": "Hanya ambil gambar mana-mana isu bandar - AI kami secara automatik mengenal pasti dan mengkategorikan masalah dalam beberapa saat.",

View File

@@ -1,6 +1,6 @@
{
"meta": {
"name": "FixMate Design Tokens",
"name": "CityPulse Design Tokens",
"version": "1.0.0",
"brand": "Civic Premium Citizen First"
},

View File

@@ -2,7 +2,7 @@ Perfect 👍 thanks for clarifying — lets keep it **venv only**. Ill adj
---
# 🛠️ FixMate Backend Hackathon Prototype
# 🛠️ CityPulse Backend Hackathon Prototype
Smart citizen-driven urban maintenance platform powered by **Computer Vision + Generative AI**.
This backend runs fully **locally** (no cloud required).

Binary file not shown.

View File

@@ -46,6 +46,7 @@ class Ticket(Base):
category = Column(String, nullable=False)
severity = Column(Enum(SeverityLevel), nullable=False, default=SeverityLevel.NA)
description = Column(String, default="")
address = Column(String, nullable=True)
status = Column(Enum(TicketStatus), nullable=False, default=TicketStatus.NEW)
latitude = Column(Float, nullable=False)
longitude = Column(Float, nullable=False)

View File

@@ -1,88 +1,154 @@
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException
from typing import Optional
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from pathlib import Path
import logging, uuid
from app.database import get_db
from app.services.ticket_service import TicketService, SeverityLevel
from app.models.ticket_model import User
from app.services.global_ai import get_ai_service
import os, uuid, logging
from app.utils import make_image_url, normalize_image_path_for_url
router = APIRouter()
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
UPLOAD_DIR = "app/static/uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)
UPLOAD_DIR = Path("static") / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
@router.post("/report")
async def report_issue(
user_id: str = Form(...),
latitude: float = Form(...),
longitude: float = Form(...),
description: str = Form(""),
# ----------------------
# API 1: Analyze image (no DB write)
# ----------------------
@router.post("/analyze")
async def analyze_image(
image: UploadFile = File(...),
db: Session = Depends(get_db)
request: Request = None
):
logger.debug("Received report request")
ticket_service = TicketService(db)
logger.debug("Received analyze request")
# Validate user
user = db.query(User).filter(User.id == user_id).first()
if not user:
logger.error(f"User with id {user_id} not found")
raise HTTPException(status_code=404, detail=f"User with id {user_id} not found")
logger.debug(f"User found: {user.name} ({user.email})")
# Validate file extension and type
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'}
allowed_content_types = {
'image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp',
'application/octet-stream'
}
# Save uploaded image
file_ext = os.path.splitext(image.filename)[1]
file_ext = Path(image.filename).suffix.lower()
if file_ext not in allowed_extensions:
raise HTTPException(status_code=400, detail="Only image files are allowed")
if image.content_type not in allowed_content_types:
raise HTTPException(status_code=400, detail="Invalid file type")
# Save file
filename = f"{uuid.uuid4()}{file_ext}"
file_path = os.path.join(UPLOAD_DIR, filename)
file_path_obj = UPLOAD_DIR / filename
try:
content = await image.read()
with open(file_path, "wb") as f:
f.write(content)
logger.debug(f"Saved image to {file_path} ({len(content)} bytes)")
except Exception as e:
logger.exception("Failed to save uploaded image")
file_path_obj.write_bytes(content)
logger.debug(f"Saved image for analysis: {file_path_obj}")
except Exception:
logger.exception("Failed to save image for analysis")
raise HTTPException(status_code=500, detail="Failed to save uploaded image")
# Get initialized AI service
# Run AI
ai_service = get_ai_service()
logger.debug("AI service ready")
# Run AI predictions
try:
category = ai_service.classify_category(file_path)
logger.debug(f"Classification: {category}")
category = ai_service.classify_category(str(file_path_obj))
logger.debug(f"Classification result: {category}")
severity = SeverityLevel.NA
annotated_path = None
if category.lower() == "pothole":
severity_str, annotated_path = ai_service.detect_pothole_severity(file_path)
logger.debug(f"Detection: severity={severity_str}, path={annotated_path}")
severity_str, annotated_path = ai_service.detect_pothole_severity(str(file_path_obj))
severity = {
"High": SeverityLevel.HIGH,
"Medium": SeverityLevel.MEDIUM,
"Low": SeverityLevel.LOW,
"Unknown": SeverityLevel.NA
}.get(severity_str, SeverityLevel.NA)
else:
severity = SeverityLevel.NA
logger.debug("No detection needed")
except Exception as e:
logger.exception("AI prediction failed")
logger.debug(f"Severity detection: {severity_str}")
except Exception:
logger.exception("AI analysis failed")
category = "Unknown"
severity = SeverityLevel.NA
# Create ticket
ticket = ticket_service.create_ticket(
user_id=user.id,
image_path=file_path,
category=category,
severity=severity,
latitude=latitude,
longitude=longitude,
description=description
)
logger.info(f"Ticket created: {ticket.id} for user {user.id}")
rel_path = normalize_image_path_for_url(file_path_obj.as_posix())
image_url = make_image_url(rel_path, request)
response = {
"temp_id": str(uuid.uuid4()),
"filename": filename,
"image_path": rel_path,
"image_url": image_url,
"category": category,
"severity": severity.value
}
logger.debug(f"Analyze response: {response}")
return JSONResponse(status_code=200, content=response)
# ----------------------
# API 2: Submit report (with analyzed file + DB write)
# ----------------------
@router.post("/report")
async def report_issue(
user_id: Optional[str] = Form(None),
user_name: Optional[str] = Form(None),
latitude: float = Form(...),
longitude: float = Form(...),
address: Optional[str] = Form(None),
description: str = Form(""),
analyzed_file: str = Form(...), # filename returned from /analyze
category: str = Form(...),
severity: str = Form(...),
db: Session = Depends(get_db),
request: Request = None
):
logger.debug("Received report submission request")
ticket_service = TicketService(db)
# Ensure user
user = None
if user_id:
user = ticket_service.get_user(user_id)
if not user:
guest_email = f"guest-{uuid.uuid4()}@example.local"
guest_name = user_name or f"Guest-{str(uuid.uuid4())[:8]}"
try:
user = ticket_service.create_user(name=guest_name, email=guest_email)
logger.info(f"Created guest user: {user}")
except Exception:
logger.exception("Failed to create guest user")
raise HTTPException(status_code=500, detail="Failed to ensure user")
# Verify analyzed file exists
file_path_obj = UPLOAD_DIR / analyzed_file
if not file_path_obj.exists():
logger.error(f"Analyzed file not found: {analyzed_file}")
raise HTTPException(status_code=400, detail="Analyzed file not found")
# Save ticket
severity_enum = SeverityLevel.__members__.get(severity.upper(), SeverityLevel.NA)
try:
ticket = ticket_service.create_ticket(
user_id=user.id,
image_path=file_path_obj.as_posix(),
category=category,
severity=severity_enum,
latitude=latitude,
longitude=longitude,
description=description,
address=address
)
logger.info(f"Ticket created: {ticket.id} for user {user.id}")
except Exception:
logger.exception("Failed to create ticket")
raise HTTPException(status_code=500, detail="Failed to create ticket")
rel_path = normalize_image_path_for_url(ticket.image_path)
image_url = make_image_url(rel_path, request)
response = {
"ticket_id": ticket.id,
@@ -93,8 +159,9 @@ async def report_issue(
"severity": ticket.severity.value,
"status": ticket.status.value,
"description": ticket.description,
"image_path": ticket.image_path
"image_path": rel_path,
"image_url": image_url,
"address": ticket.address
}
logger.debug(f"Response: {response}")
logger.debug(f"Report response: {response}")
return JSONResponse(status_code=201, content=response)

View File

@@ -1,96 +1,81 @@
# app/routes/tickets.py
from typing import Optional, List
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.ticket_service import TicketService, TicketStatus, SeverityLevel
from pydantic import BaseModel
from app.utils import ticket_to_dict
router = APIRouter()
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
class TicketStatusUpdate(BaseModel):
new_status: TicketStatus
status: TicketStatus
# ----------------------
# GET /tickets
# ----------------------
@router.get("/tickets", response_model=List[dict])
def list_tickets(
request: Request,
user_id: Optional[str] = Query(None, description="Filter by user ID"),
category: Optional[str] = Query(None, description="Filter by category"),
severity: Optional[SeverityLevel] = Query(None, description="Filter by severity"),
status: Optional[TicketStatus] = Query(None, description="Filter by status"),
db: Session = Depends(get_db)
):
"""
Return all tickets by default. Optional query params may filter results.
Each item is serialized using ticket_to_dict(...) which guarantees:
- image_url is an absolute forward-slash URL
- created_at is ISO-8601 string
- consistent schema for dashboard & mobile clients
"""
service = TicketService(db)
tickets = service.list_tickets(user_id=user_id, category=category, severity=severity, status=status)
return [
{
"ticket_id": t.id,
"user_id": t.user_id,
"category": t.category,
"severity": t.severity.value,
"status": t.status.value,
"description": t.description,
"latitude": t.latitude,
"longitude": t.longitude,
"image_path": t.image_path,
"created_at": t.created_at,
"updated_at": t.updated_at
} for t in tickets
]
return [ticket_to_dict(t, request) for t in tickets]
# ----------------------
# GET /tickets/{ticket_id}
# ----------------------
@router.get("/tickets/{ticket_id}", response_model=dict)
def get_ticket(ticket_id: str, db: Session = Depends(get_db)):
def get_ticket(ticket_id: str, request: Request, db: Session = Depends(get_db)):
service = TicketService(db)
ticket = service.get_ticket(ticket_id)
if not ticket:
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
return {
"ticket_id": ticket.id,
"user_id": ticket.user_id,
"category": ticket.category,
"severity": ticket.severity.value,
"status": ticket.status.value,
"description": ticket.description,
"latitude": ticket.latitude,
"longitude": ticket.longitude,
"image_path": ticket.image_path,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
return ticket_to_dict(ticket, request)
# ----------------------
# PATCH /tickets/{ticket_id} - Update status
# PATCH /tickets/{ticket_id}/status - Update status
# ----------------------
@router.patch("/tickets/{ticket_id}", response_model=dict)
@router.patch("/tickets/{ticket_id}/status", response_model=dict)
def update_ticket_status(
ticket_id: str,
status_update: TicketStatusUpdate, # JSON body with new_status
status_update: TicketStatusUpdate, # JSON body with status
request: Request,
db: Session = Depends(get_db)
):
service = TicketService(db)
try:
ticket = service.update_ticket_status(ticket_id, status_update.new_status)
ticket = service.update_ticket_status(ticket_id, status_update.status)
except Exception as e:
logger.error(f"Failed to update ticket status: {e}")
raise HTTPException(status_code=400, detail=str(e))
return {
"ticket_id": ticket.id,
"user_id": ticket.user_id,
"category": ticket.category,
"severity": ticket.severity.value,
"status": ticket.status.value,
"description": ticket.description,
"latitude": ticket.latitude,
"longitude": ticket.longitude,
"image_path": ticket.image_path,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
return ticket_to_dict(ticket, request)
# ----------------------
# DELETE /tickets/{ticket_id} - Delete ticket + image
# ----------------------
@router.delete("/tickets/{ticket_id}", response_model=dict)
def delete_ticket(ticket_id: str, db: Session = Depends(get_db)):
service = TicketService(db)
try:
service.delete_ticket(ticket_id)
except Exception as e:
logger.error(f"Failed to delete ticket {ticket_id}: {e}")
raise HTTPException(status_code=400, detail=str(e))
return {"deleted": True, "id": ticket_id}

View File

@@ -1,9 +1,11 @@
# app/services/ticket_service.py
import uuid
from typing import List, Optional
from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy.exc import NoResultFound
from app.models.ticket_model import User, Ticket, TicketAudit, TicketStatus, SeverityLevel
from app.utils import normalize_image_path_for_url, UPLOADS_DIR_RESOLVED
import logging
logging.basicConfig(level=logging.INFO)
@@ -26,8 +28,6 @@ class TicketService:
self.db.refresh(user)
logger.info(f"Created user {user}")
return user # <-- return User object
def get_user(self, user_id: str) -> Optional[User]:
return self.db.query(User).filter(User.id == user_id).first()
@@ -44,16 +44,27 @@ class TicketService:
latitude: float,
longitude: float,
description: str = "",
address: Optional[str] = None,
) -> Ticket:
"""
Create a Ticket record.
image_path should be a relative POSIX path (e.g. 'static/uploads/uuid.jpg').
report.route uses Path.as_posix() to ensure forward slashes on save.
"""
# Normalize stored path to POSIX
image_path_posix = Path(str(image_path)).as_posix() if image_path else None
ticket = Ticket(
id=str(uuid.uuid4()),
user_id=user_id,
image_path=image_path,
image_path=image_path_posix,
category=category,
severity=severity,
latitude=latitude,
longitude=longitude,
description=description,
address=address,
)
self.db.add(ticket)
self.db.commit()
@@ -91,6 +102,9 @@ class TicketService:
severity: Optional[SeverityLevel] = None,
status: Optional[TicketStatus] = None
) -> List[Ticket]:
"""
Return tickets. By default returns all tickets unless optional filters are provided.
"""
query = self.db.query(Ticket)
if user_id:
query = query.filter(Ticket.user_id == user_id)
@@ -101,3 +115,57 @@ class TicketService:
if status:
query = query.filter(Ticket.status == status)
return query.order_by(Ticket.created_at.desc()).all()
def delete_ticket(self, ticket_id: str) -> bool:
"""
Delete a ticket and its associated image file if it exists.
Safety:
- Normalize stored image_path to a relative POSIX path under the static/ mount using
normalize_image_path_for_url().
- Resolve the resulting path and only delete if the resolved path is under the configured
uploads directory (UPLOADS_DIR_RESOLVED) to prevent path traversal.
"""
ticket = self.db.query(Ticket).filter(Ticket.id == ticket_id).first()
if not ticket:
raise NoResultFound(f"Ticket with id {ticket_id} not found")
# Attempt to delete the image file if present
try:
rel = normalize_image_path_for_url(ticket.image_path)
if rel:
file_path = Path(rel)
# Resolve to absolute path safely (works if file missing too)
try:
absolute = file_path.resolve()
except Exception:
absolute = (Path.cwd() / file_path).resolve()
# Ensure the file is inside the uploads directory
try:
absolute.relative_to(UPLOADS_DIR_RESOLVED)
inside_uploads = True
except Exception:
inside_uploads = False
if inside_uploads and absolute.exists():
try:
absolute.unlink()
logger.info(f"Deleted image file: {absolute}")
except Exception as e:
logger.warning(f"Failed to delete image file {absolute}: {e}")
else:
logger.debug(f"Image file not deleted (missing or outside uploads): {absolute}")
except Exception as e:
logger.exception(f"Error while attempting to remove image for ticket {ticket_id}: {e}")
# Delete ticket record
try:
self.db.delete(ticket)
self.db.commit()
logger.info(f"Deleted ticket {ticket_id}")
return True
except Exception as e:
logger.exception(f"Failed to delete ticket {ticket_id} from DB: {e}")
self.db.rollback()
raise

View File

@@ -1,74 +0,0 @@
import uuid
from sqlalchemy import Column, String, Float, Enum, DateTime, ForeignKey, Index
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
# ----------------------
# Enums
# ----------------------
class TicketStatus(str, enum.Enum):
NEW = "New"
IN_PROGRESS = "In Progress"
FIXED = "Fixed"
class SeverityLevel(str, enum.Enum):
LOW = "Low"
MEDIUM = "Medium"
HIGH = "High"
NA = "N/A"
# ----------------------
# User Model
# ----------------------
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
name = Column(String, nullable=False)
email = Column(String, unique=True, nullable=False)
tickets = relationship("Ticket", back_populates="user", cascade="all, delete-orphan")
def __repr__(self):
return f"<User(id={self.id}, name={self.name}, email={self.email})>"
# ----------------------
# Ticket Model
# ----------------------
class Ticket(Base):
__tablename__ = "tickets"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
image_path = Column(String, nullable=False)
category = Column(String, nullable=False)
severity = Column(Enum(SeverityLevel), nullable=False, default=SeverityLevel.NA)
description = Column(String, default="")
status = Column(Enum(TicketStatus), nullable=False, default=TicketStatus.NEW)
latitude = Column(Float, nullable=False)
longitude = Column(Float, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="tickets")
__table_args__ = (
Index("idx_category_status", "category", "status"),
)
def __repr__(self):
return f"<Ticket(id={self.id}, category={self.category}, severity={self.severity}, status={self.status}, user_id={self.user_id})>"
# ----------------------
# Ticket Audit Model
# ----------------------
class TicketAudit(Base):
__tablename__ = "ticket_audit"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
ticket_id = Column(String, ForeignKey("tickets.id", ondelete="CASCADE"))
old_status = Column(Enum(TicketStatus))
new_status = Column(Enum(TicketStatus))
updated_at = Column(DateTime(timezone=True), server_default=func.now())

126
backend/app/utils.py Normal file
View File

@@ -0,0 +1,126 @@
from typing import Optional
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
# Path where uploaded images are stored (relative to project root)
UPLOADS_DIR = Path("static") / "uploads"
# Resolved absolute path for safety checks
try:
UPLOADS_DIR_RESOLVED = UPLOADS_DIR.resolve()
except Exception:
UPLOADS_DIR_RESOLVED = UPLOADS_DIR
def normalize_image_path_for_url(image_path: Optional[str]) -> Optional[str]:
"""
Normalize stored image_path to a web-accessible relative path under the /static/ mount.
Examples:
r"static\\uploads\\uuid.jpg" -> "static/uploads/uuid.jpg"
"C:\\project\\static\\uploads\\uuid.jpg" -> "static/uploads/uuid.jpg"
"uploads/uuid.jpg" -> "static/uploads/uuid.jpg"
"""
if not image_path:
return None
p = str(image_path).replace("\\", "/")
# strip leading './' and leading slashes
while p.startswith("./"):
p = p[2:]
p = p.lstrip("/")
# prefer existing 'static/' segment if present
if "static/" in p:
p = p[p.find("static/"):]
elif p.startswith("uploads/"):
p = f"static/{p}"
else:
# fallback to treating as filename only
p = f"static/uploads/{Path(p).name}"
# collapse accidental duplicates
p = p.replace("static/static", "static").replace("uploads/uploads", "uploads")
return p
def make_image_url(image_path: Optional[str], request) -> Optional[str]:
"""
Build an absolute URL for the given stored image_path using the FastAPI request.
Returns None if image_path is falsy.
"""
rel = normalize_image_path_for_url(image_path)
if not rel:
return None
base = str(request.base_url).rstrip("/")
return f"{base}/{rel.lstrip('/')}"
def ticket_to_dict(ticket, request=None) -> dict:
"""
Serialize a Ticket ORM object to the normalized schema expected by clients.
Schema:
id, category, severity, status, description,
user_id, user_name, user_email,
created_at (ISO8601), latitude, longitude, address,
image_url (absolute), image_path (relative POSIX under static/)
"""
created = None
try:
if getattr(ticket, "created_at", None):
created = ticket.created_at.isoformat()
except Exception:
created = None
# Normalize stored image path to a safe relative POSIX path under 'static/'
normalized_path = normalize_image_path_for_url(getattr(ticket, "image_path", None))
image_url = None
if request is not None:
try:
image_url = make_image_url(normalized_path, request)
except Exception:
logger.exception("Failed to build image_url")
image_url = None
# Map backend enum values to dashboard expected values
severity_mapping = {
"N/A": "low",
"Low": "low",
"Medium": "medium",
"High": "high"
}
status_mapping = {
"New": "submitted",
"In Progress": "in_progress",
"Fixed": "fixed"
}
# Map category to expected values
category_mapping = {
"Unknown": "other",
"garbage": "trash",
"broken_streetlight": "streetlight",
"drainage": "drainage",
"pothole": "pothole",
"signage": "signage",
"streetlight": "streetlight"
}
return {
"id": ticket.id,
"category": category_mapping.get(ticket.category, ticket.category) if ticket.category else "other",
"severity": severity_mapping.get(ticket.severity.value, "low") if getattr(ticket, "severity", None) else "low",
"status": status_mapping.get(ticket.status.value, "submitted") if getattr(ticket, "status", None) else "submitted",
"notes": ticket.description, # Map description to notes
"user_id": ticket.user_id,
"userName": ticket.user.name if getattr(ticket, "user", None) else None,
"user_email": ticket.user.email if getattr(ticket, "user", None) else None,
"createdAt": created, # Map created_at to createdAt
"updatedAt": getattr(ticket, "updated_at", None).isoformat() if getattr(ticket, "updated_at", None) else created,
"latitude": ticket.latitude,
"longitude": ticket.longitude,
"address": ticket.address,
"image_url": image_url,
"image_path": normalized_path
}

View File

@@ -3,6 +3,7 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from app.database import Base, engine
from app.routes import report, tickets, analytics, users
from app.services.global_ai import init_ai_service
@@ -15,18 +16,18 @@ logger = logging.getLogger(__name__)
# ----------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting FixMate Backend...")
logger.info("Starting CityPulse Backend...")
init_ai_service() # ✅ Models load once here
logger.info("AI models loaded successfully.")
yield
logger.info("FixMate Backend shutting down...")
logger.info("CityPulse Backend shutting down...")
# ----------------------
# Initialize FastAPI
# ----------------------
app = FastAPI(
title="FixMate Backend API",
description="Backend for FixMate Hackathon Prototype",
title="CityPulse Backend API",
description="Backend for CityPulse Hackathon Prototype",
version="1.0.0",
lifespan=lifespan
)
@@ -44,6 +45,25 @@ UPLOAD_DIR = "static/uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
# ----------------------
# CORS - allow dashboard & emulator origins
# ----------------------
DEFAULT_ORIGINS = "http://localhost:3000,http://127.0.0.1:3000,http://[::1]:3000,http://10.0.2.2:3000,http://192.168.100.59:3000"
origins_env = os.environ.get("FIXMATE_CORS_ORIGINS", DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in origins_env.split(",") if o.strip()]
# Ensure common development origins are always allowed (localhost, emulator, LAN)
for origin in ("http://localhost:3000", "http://127.0.0.1:3000", "http://[::1]:3000", "http://10.0.2.2:3000", "http://192.168.100.59:3000"):
if origin not in allowed_origins:
allowed_origins.append(origin)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins for development
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ----------------------
# Include routers
# ----------------------
@@ -60,13 +80,18 @@ except Exception as e:
@app.get("/")
def root():
return {"message": "Welcome to FixMate Backend API! Visit /docs for API documentation."}
return {"message": "Welcome to CityPulse Backend API! Visit /docs for API documentation."}
@app.get("/test")
def test():
return {"status": "Backend is working", "timestamp": "2025-09-27T10:12:41"}
print("✅ FastAPI server setup complete")
# Start the server when running this script directly
if __name__ == "__main__":
import uvicorn
print("🚀 Starting server on http://127.0.0.1:8000")
print("🚀 Starting server on http://0.0.0.0:8000")
print("📚 API documentation available at http://127.0.0.1:8000/docs")
uvicorn.run(app, host="127.0.0.1", port=8000)
print("🔗 Also accessible from mobile/emulator at http://192.168.100.59:8000")
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -2,7 +2,7 @@ Perfect 👍 Before I drop a full codebase, lets agree on the **flow + plan**
---
# ⚡ Backend Flow (FixMate Local Prototype)
# ⚡ Backend Flow (CityPulse Local Prototype)
### 1. Citizen Upload Flow
@@ -32,7 +32,7 @@ Perfect 👍 Before I drop a full codebase, lets agree on the **flow + plan**
* Every report:
* Pass image to model → detect objects.
* Map objects to FixMate categories (`pothole`, `streetlight`, `trash`, `signage`).
* Map objects to CityPulse categories (`pothole`, `streetlight`, `trash`, `signage`).
* Apply **severity scoring** (e.g. bounding box area = High if > certain %).
* If model fails (no internet, missing weights):

View File

@@ -13,4 +13,5 @@ black
isort
huggingface_hub
datasets
transformers
transformers
pydantic[email]

View File

@@ -0,0 +1,33 @@
import sqlite3
import os
import sys
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
db_path = os.path.normpath(os.path.join(script_dir, '..', 'app', 'db', 'fixmate.db'))
print(f"Using database: {db_path}")
if not os.path.exists(db_path):
print(f"DB not found: {db_path}")
return 2
conn = sqlite3.connect(db_path)
try:
cur = conn.cursor()
cur.execute("PRAGMA table_info(tickets);")
cols = [row[1] for row in cur.fetchall()]
if 'address' in cols:
print("Column 'address' already exists")
return 0
cur.execute("ALTER TABLE tickets ADD COLUMN address TEXT;")
conn.commit()
print("Added 'address' column to 'tickets' table")
return 0
except Exception as e:
print("Failed to add 'address' column:", e)
return 1
finally:
conn.close()
if __name__ == '__main__':
sys.exit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

4
dashboard/.env Normal file
View File

@@ -0,0 +1,4 @@
# OpenRouter API Configuration
OPENROUTER_API_KEY=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
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_MODEL=x-ai/grok-4-fast:free

130
dashboard/CHATBOT_README.md Normal file
View File

@@ -0,0 +1,130 @@
# CityPulse Dashboard Chatbot
This dashboard includes an AI-powered chatbot that uses OpenRouter's API to provide assistance with dashboard features and city reporting questions.
## Features
- **AI Assistant**: Powered by x-ai/grok-4-fast:free model via OpenRouter
- **Interactive Chat**: Real-time conversation with typing indicators
- **Quick Actions**: Pre-defined questions for common help topics
- **Mobile Responsive**: Works on desktop and mobile devices
- **Context Aware**: Understands CityPulse dashboard functionality
- **Secure API Key Management**: No hardcoded API keys in frontend code
## Setup
### 1. **Environment Variables**
Create a `.env` file in the dashboard directory:
```env
OPENROUTER_API_KEY=your_api_key_here
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_MODEL=x-ai/grok-4-fast:free
```
### 2. **Get API Key**
Sign up at [OpenRouter](https://openrouter.ai/) to get your free API key.
### 3. **Install Dependencies**
```bash
cd dashboard
npm install
```
### 4. **Setup API Key (Development)**
```bash
npm run setup
```
This script safely injects your API key from the `.env` file into the frontend code.
### 5. **Start Development Server**
```bash
npm run dev
```
Or manually:
```bash
npm run setup && python -m http.server 3000
```
## Usage
- Click the floating chat button (💬) in the bottom-right corner to open the chatbot
- Type your questions or use the quick action buttons for common queries
- The chatbot can help with:
- Dashboard navigation and features
- Understanding report statuses and categories
- General questions about city reporting
- Troubleshooting dashboard issues
## Quick Actions Available
- **Dashboard Help**: How to use dashboard filters
- **Report Status**: What different report statuses mean
- **Categories**: Types of city issues that can be reported
- **Navigation**: How to navigate to specific locations on the map
## Security Features
### 🔒 **No Hardcoded API Keys**
- API keys are never hardcoded in the frontend JavaScript
- Keys are loaded from environment variables at runtime
- Build-time replacement ensures keys aren't exposed in source code
### 🛡️ **Development vs Production**
- **Development**: Uses environment variables with build-time replacement
- **Production**: Should use a secure backend endpoint to serve configuration
### 🔧 **Backend Configuration Server (Optional)**
For enhanced security, you can run the included Python server:
```bash
pip install flask flask-cors python-dotenv
python server.py
```
This serves configuration from `http://localhost:3001/api/chatbot-config`
## Technical Details
- Built with React and modern JavaScript
- Uses OpenRouter API for AI responses
- Styled to match the CityPulse dashboard theme
- Includes error handling and loading states
- Mobile-responsive design
- Secure API key management
## Project Structure
```
dashboard/
├── .env # Environment variables (create this)
├── Chatbot.js # Main chatbot component
├── app.js # Dashboard application
├── index.html # Main HTML file
├── styles.css # Styling
├── server.py # Optional backend config server
├── replace-env-vars.js # Development API key injection
├── package.json # Node.js dependencies
└── requirements.txt # Python dependencies
```
## Troubleshooting
If the chatbot isn't working:
1. **Check API Key**: Ensure your OpenRouter API key is valid and has credits
2. **Environment Setup**: Make sure `.env` file exists with correct variables
3. **Run Setup**: Execute `npm run setup` to inject the API key
4. **Check Console**: Look for error messages in browser developer tools
5. **Network Check**: Verify internet connection for API calls
### Common Issues
- **"API key not configured"**: Run `npm run setup` to inject the key
- **CORS errors**: Make sure the server is running from the correct directory
- **404 errors**: Check that all files are in the dashboard directory
## Security Best Practices
1. **Never commit API keys** to version control
2. **Use environment variables** for all sensitive configuration
3. **Consider backend services** for production deployments
4. **Rotate API keys** regularly
5. **Monitor API usage** on OpenRouter dashboard

343
dashboard/Chatbot.js Normal file
View File

@@ -0,0 +1,343 @@
const { useState, useRef, useEffect } = React;
// Chatbot component that integrates with OpenRouter API
function Chatbot() {
console.log('Chatbot component loaded successfully');
const [config, setConfig] = useState(null);
const [messages, setMessages] = useState([
{
id: 1,
type: 'bot',
content: 'Hello! I\'m your CityPulse assistant. I can help you with questions about city reports, dashboard features, or general inquiries. How can I assist you today?',
timestamp: new Date()
}
]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
// Auto-scroll to bottom when new messages are added
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// Load configuration from environment variables
useEffect(() => {
// For security, API keys should never be hardcoded in frontend code
// In production, use a backend service or build-time replacement
const loadConfig = () => {
// Check if we're in development mode (localhost)
const isDevelopment = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1';
if (isDevelopment) {
// In development, try to load from environment or show setup message
console.log('Development mode detected');
console.log('Please ensure your .env file is properly configured');
console.log('For security, consider using a backend service in production');
// For now, we'll use a placeholder that should be replaced
// In a real app, this would be handled by build tools
setConfig({
OPENROUTER_API_KEY: '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',
OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1',
OPENROUTER_MODEL: 'x-ai/grok-4-fast:free'
});
} else {
// In production, this should come from a secure backend endpoint
console.log('Production mode - configuration should come from backend');
setConfig({
OPENROUTER_API_KEY: 'CONFIGURE_BACKEND_ENDPOINT',
OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1',
OPENROUTER_MODEL: 'x-ai/grok-4-fast:free'
});
}
};
loadConfig();
console.log('Config loading initiated...');
}, []);
// Debug: Monitor config changes
useEffect(() => {
if (config) {
console.log('Config loaded successfully:', {
hasKey: !!config.OPENROUTER_API_KEY,
baseURL: config.OPENROUTER_BASE_URL,
model: config.OPENROUTER_MODEL
});
}
}, [config]);
// Function to clean up markdown formatting from AI responses
const cleanMarkdown = (text) => {
return text
// Remove headers (### text)
.replace(/^###\s+/gm, '')
.replace(/^##\s+/gm, '')
.replace(/^#\s+/gm, '')
// Convert bold/italic (*text*) to readable format
.replace(/\*([^*]+)\*/g, '$1')
// Remove extra asterisks
.replace(/\*{2,}/g, '')
// Convert bullet points (-) to readable format
.replace(/^- /gm, '• ')
// Clean up multiple spaces but preserve line breaks
.replace(/ {2,}/g, ' ')
// Trim each line while preserving line breaks
.split('\n')
.map(line => line.trim())
.join('\n')
.trim();
};
// Send message to OpenRouter API
const sendMessage = async (userMessage) => {
if (!userMessage.trim() || isLoading) return;
// Wait for config to be loaded
if (!config) {
console.log('Config not loaded yet, waiting...');
setTimeout(() => sendMessage(userMessage), 100);
return;
}
console.log('Sending message with config:', {
baseURL: config.OPENROUTER_BASE_URL,
model: config.OPENROUTER_MODEL,
hasKey: !!config.OPENROUTER_API_KEY
});
setIsLoading(true);
try {
const requestHeaders = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.OPENROUTER_API_KEY}`,
'HTTP-Referer': window.location.href,
'X-Title': 'CityPulse Dashboard'
};
console.log('Making API request to:', `${config.OPENROUTER_BASE_URL}/chat/completions`);
console.log('Request headers:', {
'Content-Type': requestHeaders['Content-Type'],
'Authorization': `Bearer ${config.OPENROUTER_API_KEY ? '[API_KEY_PRESENT]' : '[NO_KEY]'}`,
'HTTP-Referer': requestHeaders['HTTP-Referer'],
'X-Title': requestHeaders['X-Title']
});
const response = await fetch(`${config.OPENROUTER_BASE_URL}/chat/completions`, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify({
model: config.OPENROUTER_MODEL,
messages: [
{
role: 'system',
content: `You are a helpful assistant for the CityPulse Dashboard - a city reporting system. You help users understand dashboard features, city reports, and provide general assistance. Keep responses concise, helpful, and use plain text without markdown formatting, headers, or special characters.`
},
...messages.filter(msg => msg.type !== 'system').map(msg => ({
role: msg.type === 'user' ? 'user' : 'assistant',
content: msg.content
})),
{
role: 'user',
content: userMessage
}
],
max_tokens: 500,
temperature: 0.7
})
});
if (!response.ok) {
const errorText = await response.text();
console.error('OpenRouter API error:', response.status, errorText);
console.error('Response headers:', Object.fromEntries(response.headers.entries()));
throw new Error(`API request failed: ${response.status} - ${errorText}`);
}
const data = await response.json();
console.log('OpenRouter API response:', data);
if (data.choices && data.choices[0] && data.choices[0].message) {
const botResponse = cleanMarkdown(data.choices[0].message.content);
setMessages(prev => [...prev, {
id: Date.now() + 1,
type: 'bot',
content: botResponse,
timestamp: new Date()
}]);
} else {
console.error('Invalid API response format:', data);
throw new Error('Invalid response format from API');
}
} catch (error) {
console.error('Error calling OpenRouter API:', error);
setMessages(prev => [...prev, {
id: Date.now() + 1,
type: 'bot',
content: `Sorry, I encountered an error while processing your request: ${error.message}. Please try again later.`,
timestamp: new Date()
}]);
} finally {
setIsLoading(false);
}
};
// Handle form submission
const handleSubmit = (e) => {
e.preventDefault();
if (!inputValue.trim() || isLoading) return;
const userMessage = inputValue.trim();
setMessages(prev => [...prev, {
id: Date.now(),
type: 'user',
content: userMessage,
timestamp: new Date()
}]);
setInputValue('');
sendMessage(userMessage);
};
// Handle key press
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
// Format timestamp
const formatTime = (date) => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
// Quick action buttons
const quickActions = [
{
label: 'Dashboard Help',
message: 'How do I use the dashboard filters?'
},
{
label: 'Report Status',
message: 'What do the different report statuses mean?'
},
{
label: 'Categories',
message: 'What types of city issues can be reported?'
},
{
label: 'Navigation',
message: 'How do I navigate to a specific location on the map?'
}
];
const handleQuickAction = (message) => {
setInputValue(message);
if (inputRef.current) {
inputRef.current.focus();
}
};
if (!isOpen) {
return (
<div className="chatbot-toggle" onClick={() => setIsOpen(true)}>
<div className="chatbot-toggle-icon">
💬
</div>
<span>Chat Assistant</span>
</div>
);
}
return (
<div className="chatbot-container">
<div className="chatbot-header">
<h3>CityPulse Assistant</h3>
<button className="chatbot-close" onClick={() => setIsOpen(false)}>
×
</button>
</div>
<div className="chatbot-messages">
{messages.map((message) => (
<div
key={message.id}
className={`message ${message.type}`}
>
<div className="message-avatar">
{message.type === 'bot' ? '🤖' : '👤'}
</div>
<div className="message-content">
<div className="message-text">{message.content}</div>
<div className="message-time">
{formatTime(message.timestamp)}
</div>
</div>
</div>
))}
{isLoading && (
<div className="message bot">
<div className="message-avatar">🤖</div>
<div className="message-content">
<div className="message-text">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="chatbot-quick-actions">
{quickActions.map((action, index) => (
<button
key={index}
className="quick-action-btn"
onClick={() => handleQuickAction(action.message)}
>
{action.label}
</button>
))}
</div>
<form className="chatbot-input-form" onSubmit={handleSubmit}>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Ask me anything about CityPulse..."
disabled={isLoading}
className="chatbot-input"
/>
<button
type="submit"
disabled={!inputValue.trim() || isLoading}
className="chatbot-send-btn"
>
{isLoading ? '...' : 'Send'}
</button>
</form>
</div>
);
}
// Export for use in other modules
window.Chatbot = Chatbot;

131
dashboard/README.md Normal file
View File

@@ -0,0 +1,131 @@
# FixMate Dashboard
A modern, responsive dashboard for managing civic issue reports with an interactive map interface.
## Features
- **Interactive Map**: View reported issues on an interactive Leaflet map with clustering
- **Advanced Filtering**: Filter by category, severity, status, and date range
- **Real-time Updates**: Live status updates and filtering
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
- **Modern UI**: Clean, professional interface with smooth animations
- **Accessibility**: Keyboard navigation and screen reader friendly
- **Multi-language**: Support for English and Bahasa Malaysia
## UI Improvements Made
### 🎨 Modern Design System
- **Color Palette**: Updated with modern semantic colors and CSS custom properties
- **Typography**: Inter font family for better readability
- **Spacing**: Consistent spacing system using CSS custom properties
- **Shadows**: Subtle shadows and depth for better visual hierarchy
### 🔧 Enhanced Components
- **Header**: Modern sticky header with improved branding and language selector
- **Filter Panel**: Organized filter groups with hover states and better visual feedback
- **Ticket Cards**: Modern card design with hover effects and improved typography
- **Map Container**: Better map styling with loading states and empty state handling
- **Detail Drawer**: Slide-out drawer with improved layout and actions
### 📱 Responsive Design
- **Mobile-first**: Optimized layouts for mobile, tablet, and desktop
- **Flexible Grid**: CSS Grid layout that adapts to screen size
- **Touch-friendly**: Larger touch targets for mobile interactions
### ⚡ Performance & UX
- **Loading States**: Skeleton screens and loading indicators
- **Smooth Animations**: CSS transitions for better user experience
- **Error Handling**: Better error states and retry mechanisms
- **Offline Support**: Graceful handling when backend is unavailable
## Technology Stack
- **Frontend**: React 18, JavaScript ES6+
- **Styling**: Modern CSS with custom properties (CSS variables)
- **Maps**: Leaflet with marker clustering
- **Build**: No build process - runs directly in browser
- **Fonts**: Google Fonts (Inter)
## Getting Started
1. **Start the Backend**:
```bash
cd backend
python main.py
```
2. **Open the Dashboard**:
Open `index.html` in your web browser, or serve it with a local server:
```bash
# Using Python
python -m http.server 8000
# Using Node.js
npx serve .
```
3. **Access**: Navigate to `http://localhost:8000/dashboard/`
## Project Structure
```
dashboard/
├── index.html # Main HTML file
├── styles.css # Modern CSS styles
├── app.js # React application
├── i18n/ # Internationalization files
│ ├── en.json
│ └── ms.json
└── data/
└── demo-reports.json # Sample data for testing
```
## Key Features
### Map View
- Interactive Leaflet map with OpenStreetMap tiles
- Clustered markers for better performance
- Click markers to view details
- Heatmap overlay option
### Filtering System
- Category filtering (pothole, streetlight, signage, etc.)
- Severity levels (high, medium, low)
- Status tracking (submitted, in progress, fixed)
- Date range filtering
### Ticket Management
- View all reported issues in a scrollable list
- Click to navigate to location on map
- Update status directly from the list
- Detailed view in slide-out drawer
### Responsive Breakpoints
- Desktop: 1200px+
- Tablet: 900px - 1200px
- Mobile: 600px - 900px
- Small Mobile: < 600px
## Customization
The design system is built with CSS custom properties, making it easy to customize:
```css
:root {
--primary-500: #0ea5a4; /* Main brand color */
--severity-high: #dc2626; /* High priority color */
--spacing-4: 1rem; /* Base spacing unit */
--radius: 0.5rem; /* Border radius */
}
```
## Browser Support
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
## License
This project is part of the FixMate civic engagement platform.

View File

@@ -2,38 +2,69 @@
const { useState, useEffect, useRef, useMemo } = React;
dayjs.extend(window.dayjs_plugin_relativeTime);
const CATEGORY_LIST = ['pothole','streetlight','signage','trash','drainage','other'];
console.log('Dashboard app.js loaded, BACKEND_BASE:', BACKEND_BASE);
const CATEGORY_LIST = ['pothole','streetlight','signage','trash','garbage','drainage','other'];
const SEVERITIES = ['high','medium','low'];
const STATUSES = ['submitted','in_progress','fixed'];
const BACKEND_BASE = "http://127.0.0.1:8000";
const SEVERITY_COLOR = { high:'#D32F2F', medium:'#F57C00', low:'#388E3C' };
const STATUS_COLOR = { submitted:'#1976D2', in_progress:'#7B1FA2', fixed:'#455A64' };
function fetchJSON(path){ return fetch(path).then(r=>r.json()); }
// Fetch tickets from backend
async function fetchTickets(){
console.log('Fetching tickets from:', `${BACKEND_BASE}/api/tickets`);
try {
const res = await fetch(`${BACKEND_BASE}/api/tickets`);
console.log('Response status:', res.status);
if(!res.ok) throw new Error(`Failed to fetch tickets: ${res.status}`);
const data = await res.json();
console.log('Fetched data:', data);
return data;
} catch (error) {
console.error('Error fetching tickets:', error);
throw error;
}
}
// Map backend category names to frontend filter categories
function mapCategoryToFrontend(backendCategory) {
const categoryMapping = {
'broken_streetlight': 'streetlight', // Backend AI model category
'garbage': 'trash', // Backend AI model category
// Direct matches (backend and frontend categories are the same)
'pothole': 'pothole',
'drainage': 'drainage',
'signage': 'signage',
'streetlight': 'streetlight',
'other': 'other'
};
return categoryMapping[backendCategory] || backendCategory || 'other';
}
// Normalize API data to expected format
function normalizeReportData(report) {
// If it's already in the expected format (from demo data), return as is
if (report.location && report.location.lat !== undefined) {
return report;
}
// Convert API format to expected format
// Backend is already returning data in correct format
// Just ensure all required fields are present and map categories
return {
id: report.ticket_id,
category: report.category || 'other',
id: report.id,
category: mapCategoryToFrontend(report.category),
severity: report.severity || 'low',
status: report.status || 'submitted',
notes: report.description || '',
notes: report.notes || '',
location: {
lat: report.latitude,
lng: report.longitude
},
createdAt: report.created_at,
updatedAt: report.updated_at,
// Add missing fields with defaults
createdAt: report.createdAt,
updatedAt: report.updatedAt,
userId: report.user_id,
imagePath: report.image_path
userName: report.userName || null,
address: report.address || null,
image_url: report.image_url || null
};
}
@@ -86,31 +117,81 @@ function App(){
const [heatEnabled,setHeatEnabled] = useState(false);
// simple toast container for non-blocking errors / retry actions
const toastContainerRef = useRef(null);
useEffect(()=> {
const c = document.createElement('div');
c.style.position = 'fixed';
c.style.right = '12px';
c.style.bottom = '12px';
c.style.zIndex = 9999;
toastContainerRef.current = c;
document.body.appendChild(c);
return ()=> { if(c.parentNode) c.parentNode.removeChild(c); };
}, []);
const showToast = (msg, actionLabel, action) => {
const c = toastContainerRef.current;
if(!c) { console.warn(msg); return; }
const el = document.createElement('div');
el.style.background = '#111';
el.style.color = '#fff';
el.style.padding = '8px 12px';
el.style.marginTop = '8px';
el.style.borderRadius = '6px';
el.style.boxShadow = '0 2px 6px rgba(0,0,0,0.3)';
el.style.display = 'flex';
el.style.alignItems = 'center';
el.textContent = msg;
if(actionLabel && action){
const btn = document.createElement('button');
btn.textContent = actionLabel;
btn.style.marginLeft = '12px';
btn.style.background = 'transparent';
btn.style.color = '#4FC3F7';
btn.style.border = 'none';
btn.style.cursor = 'pointer';
btn.onclick = ()=> { action(); if(el.parentNode) el.parentNode.removeChild(el); };
el.appendChild(btn);
}
c.appendChild(el);
setTimeout(()=> { if(el.parentNode) el.parentNode.removeChild(el); }, 8000);
};
const PLACEHOLDER_SRC = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="90"><rect width="100%" height="100%" fill="#e5e7eb"/><text x="50%" y="50%" dy=".3em" font-size="12" text-anchor="middle" fill="#6b7280">No image</text></svg>');
useEffect(()=>{
// Try to fetch from backend API first, fallback to demo data
fetch('http://127.0.0.1:8000/api/tickets')
.then(r => r.ok ? r.json() : Promise.reject('API not available'))
console.log('Dashboard useEffect triggered - about to fetch tickets');
// First test if we can reach the backend at all
fetch(`${BACKEND_BASE}/test`)
.then(response => {
console.log('Test endpoint response:', response.status);
return response.json();
})
.then(testData => {
console.log('Test data:', testData);
})
.catch(testErr => {
console.error('Test request failed:', testErr);
});
setLoading(true);
fetchTickets()
.then(data => {
console.log('Loaded data from API:', data.length, 'reports');
const normalizedData = data.map(normalizeReportData);
console.log('Loaded data from backend:', (Array.isArray(data) ? data.length : 0), 'reports');
console.log('Raw data received:', data);
const normalizedData = (data || []).map(normalizeReportData);
console.log('Normalized data:', normalizedData);
console.log('Sample normalized item:', JSON.stringify(normalizedData[0], null, 2));
setRawData(normalizedData);
setLoading(false);
})
.catch(err => {
console.log('API not available, using demo data:', err);
return fetchJSON('./data/demo-reports.json');
})
.then(data => {
if (data) {
console.log('Loaded demo data:', data.length, 'reports');
// Demo data is already in the correct format, but normalize just in case
const normalizedData = data.map(normalizeReportData);
setRawData(normalizedData);
}
setLoading(false);
})
.catch(err => {
console.error('Error loading data:', err);
console.error('Failed to load tickets from backend:', err);
console.error('Error details:', err.message, err.stack);
showToast('Failed to load tickets from backend.');
setRawData([]);
setLoading(false);
});
},[]);
@@ -119,7 +200,7 @@ function App(){
// init map once
const map = L.map('map', { center:[3.1390,101.6869], zoom:12, preferCanvas:true });
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom:19,
maxZoom:30,
attribution:'© OpenStreetMap'
}).addTo(map);
mapRef.current = map;
@@ -142,6 +223,12 @@ function App(){
if(!appliedFilters.statuses.has(r.status)) return false;
return true;
});
console.log('Filtered data:', out.length, 'tickets');
console.log('Applied filters:', {
categories: Array.from(appliedFilters.categories),
severities: Array.from(appliedFilters.severities),
statuses: Array.from(appliedFilters.statuses)
});
setFiltered(out);
},[rawData, appliedFilters]);
@@ -254,65 +341,66 @@ function App(){
});
},[filtered]);
const cycleStatus = async (reportId)=>{
try {
// Find the current report to get its status
const currentReport = rawData.find(r => r.id === reportId);
if (!currentReport) return;
const availableStatuses = useMemo(()=>{
const s = new Set(STATUSES);
rawData.forEach(r=>{ if(r && r.status) s.add(r.status); });
return Array.from(s);
}, [rawData]);
const idx = STATUSES.indexOf(currentReport.status);
const nextStatus = STATUSES[(idx + 1) % STATUSES.length];
// Try to update via API first
const success = await fetch(`http://127.0.0.1:8000/api/tickets/${reportId}?new_status=${encodeURIComponent(nextStatus)}`, {
method: 'PATCH'
}).then(r => r.ok);
if (success) {
// If API update successful, refresh data from API
const response = await fetch('http://127.0.0.1:8000/api/tickets');
if (response.ok) {
const data = await response.json();
const normalizedData = data.map(normalizeReportData);
setRawData(normalizedData);
// Update selected item
const updatedReport = normalizedData.find(r => r.id === reportId);
setSelected(updatedReport || null);
}
} else {
console.error('Failed to update status via API');
// Fallback to local update
setRawData(prev=>{
const out = prev.map(r=>{
if(r.id !== reportId) return r;
return {...r, status: nextStatus, updatedAt: new Date().toISOString() };
});
if(selected && selected.id === reportId){
const newSel = out.find(r=>r.id === reportId);
setSelected(newSel || null);
}
return out;
});
}
} catch (error) {
console.error('Error updating status:', error);
// Fallback to local update
setRawData(prev=>{
const out = prev.map(r=>{
if(r.id !== reportId) return r;
const idx = STATUSES.indexOf(r.status);
const ni = (idx + 1) % STATUSES.length;
return {...r, status: STATUSES[ni], updatedAt: new Date().toISOString() };
});
if(selected && selected.id === reportId){
const newSel = out.find(r=>r.id === reportId);
setSelected(newSel || null);
}
return out;
});
}
// Map dashboard status format to backend enum format
const mapStatusToBackend = (dashboardStatus) => {
const statusMapping = {
'submitted': 'New',
'in_progress': 'In Progress',
'fixed': 'Fixed'
};
return statusMapping[dashboardStatus] || dashboardStatus;
};
const updateTicketStatus = async (reportId, newStatus) => {
try {
const backendStatus = mapStatusToBackend(newStatus);
console.log('Updating status:', newStatus, '->', backendStatus);
const res = await fetch(`${BACKEND_BASE}/api/tickets/${reportId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: backendStatus })
});
if (res.ok) {
// Prefer using returned updated ticket if provided
let updated = null;
try { updated = await res.json(); } catch(e){ updated = null; }
if (updated) {
const normalized = normalizeReportData(updated);
setRawData(prev => prev.map(r => r.id === reportId ? normalized : r));
if (selected && selected.id === reportId) setSelected(normalized);
} else {
// No body returned - update local state (keep dashboard format)
setRawData(prev=> prev.map(r=> r.id === reportId ? {...r, status: newStatus, updatedAt: new Date().toISOString()} : r));
if(selected && selected.id === reportId) setSelected(prev => ({...prev, status: newStatus, updatedAt: new Date().toISOString()}));
}
showToast('Status updated');
return true;
} else {
const text = await res.text().catch(()=> '');
console.warn('Status update failed', text);
showToast('Failed to update status', 'Retry', ()=> updateTicketStatus(reportId, newStatus));
return false;
}
} catch (err) {
console.error('Error updating status:', err);
showToast('Failed to update status', 'Retry', ()=> updateTicketStatus(reportId, newStatus));
return false;
}
};
const cycleStatus = async (reportId) => {
const currentReport = rawData.find(r => r.id === reportId);
if (!currentReport) return;
const idx = availableStatuses.indexOf(currentReport.status);
const nextStatus = availableStatuses[(idx + 1) % availableStatuses.length] || STATUSES[(STATUSES.indexOf(currentReport.status) + 1) % STATUSES.length];
await updateTicketStatus(reportId, nextStatus);
};
const openInMaps = (r)=>{
const lat = r.location.lat;
@@ -320,10 +408,29 @@ function App(){
window.open(`https://www.google.com/maps/search/?api=1&query=${lat},${lng}`, '_blank');
};
const navigateToLocation = (r) => {
const map = mapRef.current;
if (!map || !r.location) return;
const { lat, lng } = r.location;
const currentZoom = map.getZoom();
const targetZoom = 20; // Maximum zoom level for focusing on a specific location
// First zoom out a bit for animation effect, then zoom to target
map.flyTo([lat, lng], targetZoom, {
animate: true,
duration: 1.5,
easeLinearity: 0.25
});
// Also set the selected item to show details
setSelected(r);
};
return (
<div className="app-root">
<header className="header">
<div className="brand">{t('dashboard.brand') || 'FixMate'}</div>
<div className="brand">{t('dashboard.brand') || 'CityPulse'}</div>
<div className="lang-toggle">
<label style={{fontSize:12, color:'#374151'}}>{t('label.language') || 'Language'}</label>
<select value={lang} onChange={e=>setLang(e.target.value)}>
@@ -339,10 +446,10 @@ function App(){
<h3>{t('dashboard.filters') || 'Filters'}</h3>
<div className="filter-group">
<div className="row space-between"><strong>{t('filter.category') || 'Category'}</strong></div>
<div className="row"><strong>{t('filter.category') || 'Category'}</strong></div>
<div className="checkbox-row" aria-label="categories">
{CATEGORY_LIST.map(cat=>(
<label key={cat} style={{display:'flex',alignItems:'center',gap:8}}>
<label key={cat}>
<input type="checkbox"
checked={formCategories.has(cat)}
onChange={()=> toggleSet(setFormCategories, formCategories, cat)}
@@ -354,7 +461,7 @@ function App(){
</div>
<div className="filter-group">
<div className="row space-between"><strong>{t('filter.severity') || 'Severity'}</strong></div>
<div className="row"><strong>{t('filter.severity') || 'Severity'}</strong></div>
<div className="multi-select">
{SEVERITIES.map(s=>(
<button key={s} className={`chip severity-${s}`} onClick={()=> toggleSet(setFormSeverities, formSeverities, s)} aria-pressed={formSeverities.has(s)}>
@@ -365,7 +472,7 @@ function App(){
</div>
<div className="filter-group">
<div className="row space-between"><strong>{t('filter.status') || 'Status'}</strong></div>
<div className="row"><strong>{t('filter.status') || 'Status'}</strong></div>
<div className="multi-select">
{STATUSES.map(s=>(
<button key={s} className={`chip status-${s}`} onClick={()=> toggleSet(setFormStatuses, formStatuses, s)} aria-pressed={formStatuses.has(s)}>
@@ -376,20 +483,20 @@ function App(){
</div>
<div className="filter-group">
<div className="row space-between"><strong>{t('filter.dateRange') || 'Date Range'}</strong></div>
<div style={{display:'flex',gap:8,marginTop:8}}>
<div style={{display:'flex',flexDirection:'column'}}>
<label style={{fontSize:12}}>{t('filter.dateFrom') || 'From'}</label>
<div className="row"><strong>{t('filter.dateRange') || 'Date Range'}</strong></div>
<div className="date-inputs">
<div className="date-input-group">
<label>{t('filter.dateFrom') || 'From'}</label>
<input type="date" value={formFrom} onChange={e=>setFormFrom(e.target.value)} />
</div>
<div style={{display:'flex',flexDirection:'column'}}>
<label style={{fontSize:12}}>{t('filter.dateTo') || 'To'}</label>
<div className="date-input-group">
<label>{t('filter.dateTo') || 'To'}</label>
<input type="date" value={formTo} onChange={e=>setFormTo(e.target.value)} />
</div>
</div>
</div>
<div style={{display:'flex',gap:8,marginTop:12}}>
<div className="button-group">
<button className="btn" onClick={applyFilters}>{t('btn.apply') || 'Apply'}</button>
<button className="btn secondary" onClick={resetFilters}>{t('btn.reset') || 'Reset'}</button>
</div>
@@ -409,17 +516,30 @@ function App(){
<div className="queue-list" role="list">
{sortedQueue.map(r=>(
<div key={r.id} className="queue-item" role="listitem">
<div className="thumb">{t(`category.${r.category}`) || r.category}</div>
<div className="thumb">
{(r.image_url || r.imagePath) ? (
<img src={r.image_url || r.imagePath} alt={r.category} style={{width:64,height:48,objectFit:'cover',borderRadius:6}} onError={(e)=>{ e.currentTarget.style.display='none'; }} />
) : (t(`category.${r.category}`) || r.category)}
</div>
<div className="item-main">
<div className="item-title">{t(`category.${r.category}`) || r.category}</div>
<div
className="item-title clickable"
onClick={() => navigateToLocation(r)}
title="Click to view on map"
>
{t(`category.${r.category}`) || r.category}
</div>
<div className="item-meta">
<span className={`chip severity-${r.severity}`}>{t(`severity.${r.severity}`) || r.severity}</span>
<span className={`chip status-${r.status}`}>{t(`status.${r.status}`) || r.status}</span>
<span className="time-ago">{dayjs(r.createdAt).fromNow()}</span>
</div>
</div>
<div className="item-actions">
<button className="btn ghost" onClick={()=> { setSelected(r); }}>{t('btn.view') || 'View'}</button>
<div className="item-actions" style={{display:'flex',flexDirection:'row',gap:8,alignItems:'center',justifyContent:'flex-end',minWidth:'120px'}}>
<select value={r.status} onChange={(e)=> updateTicketStatus(r.id, e.target.value)} style={{fontSize:'12px',padding:'4px 8px'}}>
{availableStatuses.map(s => <option key={s} value={s}>{t(`status.${s}`) || s}</option>)}
</select>
<button className="btn ghost" onClick={()=> { setSelected(r); }} style={{fontSize:'12px',padding:'4px 12px',whiteSpace:'nowrap'}}>{t('btn.view') || 'View'}</button>
</div>
</div>
))}
@@ -447,7 +567,11 @@ function App(){
<div className="drawer-content" aria-live="polite">
<button className="drawer-close" onClick={()=>setSelected(null)} aria-label="Close">×</button>
<div className="drawer-header">
<div className="drawer-thumb large">{/* placeholder */}{t(`category.${selected.category}`) || selected.category}</div>
<div className="drawer-thumb large">
{(selected.image_url || selected.imagePath) ? (
<img src={selected.image_url || selected.imagePath} alt={selected.category} style={{width:88,height:64,objectFit:'cover',borderRadius:6}} onError={(e)=>{ e.currentTarget.style.display='none'; }} />
) : (t(`category.${selected.category}`) || selected.category)}
</div>
<div style={{marginLeft:12}}>
<h3 style={{margin:0}}>{t(`category.${selected.category}`) || selected.category}</h3>
<div style={{display:'flex',gap:8,alignItems:'center',marginTop:6}}>
@@ -461,14 +585,16 @@ function App(){
<div className="drawer-body">
<p style={{marginTop:8}}><strong>{t('drawer.details') || 'Details'}</strong></p>
{selected.notes ? <p>{selected.notes}</p> : <p style={{opacity:0.7}}>{t('drawer.noNotes') || 'No additional notes'}</p>}
<p><strong>{t('label.submittedBy') || 'Submitted by'}:</strong> {selected.userName && !selected.userName.startsWith('Guest-') ? selected.userName : (t('label.anonymous') || 'Anonymous User')}</p>
<p><strong>{t('label.place') || 'Place'}:</strong> {selected.address ? selected.address : `${selected.location.lat.toFixed(5)}, ${selected.location.lng.toFixed(5)}`}</p>
<p><strong>{t('label.location') || 'Location'}:</strong> {selected.location.lat.toFixed(5)}, {selected.location.lng.toFixed(5)}</p>
<p><strong>{t('label.createdAt') || 'Created'}:</strong> {dayjs(selected.createdAt).format('YYYY-MM-DD HH:mm')}</p>
</div>
<div className="drawer-actions">
<button className="btn" onClick={()=>{ cycleStatus(selected.id); }}>
{t('drawer.changeStatus') || 'Change Status'}
</button>
<select value={selected.status} onChange={(e)=> updateTicketStatus(selected.id, e.target.value)}>
{availableStatuses.map(s => <option key={s} value={s}>{t(`status.${s}`) || s}</option>)}
</select>
<button className="btn secondary" onClick={()=>openInMaps(selected)}>
{t('drawer.openMap') || 'Open Map'}
</button>
@@ -477,6 +603,9 @@ function App(){
) : null}
</div>
{/* Chatbot */}
<Chatbot />
</div>
</div>

View File

@@ -1,5 +1,5 @@
{
"dashboard.brand": "FixMate",
"dashboard.brand": "CityPulse",
"dashboard.filters": "Filters",
"queue.title": "Tickets",
"drawer.details": "Details",
@@ -9,9 +9,13 @@
"btn.apply": "Apply",
"btn.reset": "Reset",
"btn.view": "View",
"btn.viewDetails": "View details",
"label.language": "Language",
"label.location": "Location",
"label.createdAt": "Created At",
"label.submittedBy": "Submitted by",
"label.place": "Place",
"label.anonymous": "Anonymous User",
"filter.category": "Category",
"filter.severity": "Severity",
"filter.status": "Status",

View File

@@ -1,5 +1,5 @@
{
"dashboard.brand": "FixMate",
"dashboard.brand": "CityPulse",
"dashboard.filters": "Penapis",
"queue.title": "Tiket",
"drawer.details": "Maklumat",
@@ -9,9 +9,13 @@
"btn.apply": "Terapkan",
"btn.reset": "Tetapkan Semula",
"btn.view": "Lihat",
"btn.viewDetails": "Lihat butiran",
"label.language": "Bahasa",
"label.location": "Lokasi",
"label.createdAt": "Dicipta Pada",
"label.submittedBy": "Dihantar oleh",
"label.place": "Tempat",
"label.anonymous": "Pengguna Tanpa Nama",
"filter.category": "Kategori",
"filter.severity": "Keparahan",
"filter.status": "Status",

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FixMate Dashboard</title>
<title>CityPulse Dashboard</title>
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
@@ -28,5 +28,6 @@
<!-- App -->
<script type="text/babel" src="./app.js"></script>
<script type="text/babel" src="./Chatbot.js"></script>
</body>
</html>

16
dashboard/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "citypulse-dashboard",
"version": "1.0.0",
"description": "CityPulse Dashboard with Chatbot",
"scripts": {
"setup": "node replace-env-vars.js",
"serve": "python -m http.server 3000",
"dev": "npm run setup && npm run serve"
},
"dependencies": {
"dotenv": "^16.0.3"
},
"devDependencies": {},
"keywords": ["dashboard", "citypulse", "chatbot"],
"author": "CityPulse Team"
}

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Simple script to replace environment variable placeholders in frontend code
* This is a development convenience - in production, use proper build tools
*/
const fs = require('fs');
const path = require('path');
require('dotenv').config();
const CHATBOT_FILE = path.join(__dirname, 'Chatbot.js');
const ENV_FILE = path.join(__dirname, '.env');
// Read the current Chatbot.js file
let chatbotContent = fs.readFileSync(CHATBOT_FILE, 'utf8');
// Read the .env file
let envContent = fs.readFileSync(ENV_FILE, 'utf8');
// Extract the API key from .env
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=(.+)/);
if (!apiKeyMatch) {
console.error('❌ OPENROUTER_API_KEY not found in .env file');
process.exit(1);
}
const actualApiKey = apiKeyMatch[1].trim();
// Replace the placeholder with the actual API key
const updatedContent = chatbotContent.replace(
/OPENROUTER_API_KEY: ['"]YOUR_API_KEY_HERE['"]/,
`OPENROUTER_API_KEY: '${actualApiKey}'`
);
// Write the updated file
fs.writeFileSync(CHATBOT_FILE, updatedContent);
console.log('✅ API key successfully injected into Chatbot.js');
console.log('🔒 Remember: This is for development only. Use secure methods in production.');
// Also update the hardcoded key in the fetch request
const fetchUpdatedContent = updatedContent.replace(
/`Bearer \$\{config\.OPENROUTER_API_KEY\}`/g,
`\`Bearer \${config.OPENROUTER_API_KEY}\``
);
fs.writeFileSync(CHATBOT_FILE, fetchUpdatedContent);
console.log('✅ Chatbot.js updated with secure API key reference');

View File

@@ -0,0 +1,3 @@
flask==2.3.3
flask-cors==4.0.0
python-dotenv==1.0.0

47
dashboard/server.py Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""
Simple configuration server for CityPulse Dashboard Chatbot
Serves API keys securely without exposing them in frontend code
"""
import os
import json
from flask import Flask, jsonify
from flask_cors import CORS
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
app = Flask(__name__)
CORS(app) # Enable CORS for all routes
@app.route('/api/chatbot-config', methods=['GET'])
def get_chatbot_config():
"""Serve chatbot configuration securely"""
try:
config = {
'OPENROUTER_API_KEY': os.getenv('OPENROUTER_API_KEY'),
'OPENROUTER_BASE_URL': os.getenv('OPENROUTER_BASE_URL', 'https://openrouter.ai/api/v1'),
'OPENROUTER_MODEL': os.getenv('OPENROUTER_MODEL', 'x-ai/grok-4-fast:free')
}
# Validate that API key is present
if not config['OPENROUTER_API_KEY']:
return jsonify({'error': 'API key not configured'}), 500
return jsonify(config)
except Exception as e:
return jsonify({'error': f'Failed to load configuration: {str(e)}'}), 500
@app.route('/api/config', methods=['GET'])
def get_config():
"""Legacy config endpoint"""
return get_chatbot_config()
if __name__ == '__main__':
print("Starting CityPulse Dashboard Configuration Server...")
print("Server will run on http://localhost:3001")
print("Make sure your .env file contains OPENROUTER_API_KEY")
app.run(host='localhost', port=3001, debug=True)

View File

@@ -30,29 +30,59 @@ body{
display:flex;
align-items:center;
justify-content:space-between;
padding:0 16px;
padding:0 20px;
background:var(--panel);
border-bottom:1px solid #e6eef3;
box-shadow: none;
border-bottom:1px solid #e5e7eb;
box-shadow:0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
z-index:100;
}
.brand{font-weight:700;font-size:18px;color:#111827}
.lang-toggle select{padding:6px;border-radius:6px;border:1px solid #e6eef3;background:white}
.brand{
font-weight:700;
font-size:20px;
color:#111827;
background:linear-gradient(135deg, var(--accent), #0d9488);
-webkit-background-clip:text;
-webkit-text-fill-color:transparent;
background-clip:text;
}
.lang-toggle{
display:flex;
align-items:center;
gap:8px;
}
.lang-toggle select{
padding:8px 12px;
border:1px solid #d1d5db;
border-radius:6px;
background:white;
font-size:14px;
color:#374151;
cursor:pointer;
transition:border-color 0.2s ease;
}
.lang-toggle select:focus{
outline:none;
border-color:var(--accent);
box-shadow:0 0 0 3px rgba(14,165,164,0.1);
}
.app-root{height:100vh;display:flex;flex-direction:column}
.container{
height:calc(100vh - 56px);
display:flex;
flex-direction:column;
gap:8px;
padding:12px;
gap:16px;
padding:16px;
max-width:1400px;
margin:0 auto;
width:100%;
}
/* main area */
.main{
display:grid;
grid-template-columns:300px 1fr 340px;
grid-template-columns:300px 1fr 400px;
gap:12px;
align-items:stretch;
flex:1;
@@ -61,35 +91,121 @@ body{
/* panels */
.panel{
background:var(--panel);
border-radius:8px;
box-shadow:var(--shadow);
padding:12px;
border-radius:12px;
box-shadow:0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
padding:20px;
display:flex;
flex-direction:column;
min-height:0;
border:1px solid rgba(0,0,0,0.05);
}
.filters h3{margin:0 0 8px 0}
.filter-group{margin-bottom:12px}
.checkbox-row{display:flex;flex-direction:column;gap:6px;max-height:220px;overflow:auto;padding-right:6px}
.checkbox-row label{font-size:13px;color:#111827}
.filters h3{
margin:0 0 20px 0;
font-size:18px;
font-weight:600;
color:#111827;
border-bottom:2px solid #f3f4f6;
padding-bottom:12px;
}
.filter-group{
margin-bottom:24px;
padding:16px;
background:#fafbfc;
border-radius:8px;
border:1px solid #e5e7eb;
overflow:hidden;
}
.filter-group:last-child{margin-bottom:0;}
.checkbox-row{
display:flex;
flex-direction:column;
gap:12px;
max-height:240px;
overflow-y:auto;
padding-right:8px;
}
.checkbox-row label{
font-size:14px;
color:#374151;
font-weight:500;
display:flex;
align-items:center;
gap:10px;
padding:8px;
border-radius:6px;
transition:background-color 0.2s ease;
cursor:pointer;
}
.checkbox-row label:hover{
background-color:#f3f4f6;
}
/* chips/buttons */
.btn{
background:var(--accent);
color:white;
border:none;
padding:8px 12px;
border-radius:6px;
padding:10px 20px;
border-radius:8px;
cursor:pointer;
font-weight:600;
font-size:14px;
transition:all 0.2s ease;
box-shadow:0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.btn:hover{
background:#0d9488;
transform:translateY(-1px);
box-shadow:0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.btn.secondary{
background:#f8fafc;
color:#475569;
border:1px solid #e2e8f0;
}
.btn.secondary:hover{
background:#f1f5f9;
border-color:#cbd5e1;
}
.btn.ghost{
background:transparent;
border:1px solid #e2e8f0;
color:#475569;
padding:8px 16px;
}
.btn.ghost:hover{
background:#f8fafc;
border-color:#cbd5e1;
}
.btn:focus{
outline:2px solid rgba(14,165,164,0.25);
outline-offset:2px;
}
.btn.secondary{background:#f1f5f9;color:#0f172a}
.btn.ghost{background:transparent;border:1px solid #e6eef3;color:#0f172a;padding:6px 10px}
.btn:focus{outline:2px solid rgba(14,165,164,0.25)}
.multi-select{display:flex;gap:8px;flex-wrap:wrap}
.chip{display:inline-block;padding:4px 8px;border-radius:14px;font-size:13px;color:white}
.multi-select{
display:flex;
gap:10px;
flex-wrap:wrap;
margin-top:12px;
}
.chip{
display:inline-flex;
align-items:center;
padding:6px 12px;
border-radius:20px;
font-size:13px;
font-weight:500;
color:white;
transition:all 0.2s ease;
cursor:pointer;
border:none;
box-shadow:0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.chip:hover{
transform:translateY(-1px);
box-shadow:0 2px 4px 0 rgba(0, 0, 0, 0.15);
}
.chip.severity-high{background:var(--severity-high)}
.chip.severity-medium{background:var(--severity-medium)}
.chip.severity-low{background:var(--severity-low)}
@@ -98,8 +214,18 @@ body{
.chip.status-fixed{background:var(--status-fixed)}
/* severity buttons in filter */
button.chip{border:none;cursor:pointer;opacity:0.95}
button.chip[aria-pressed="false"]{opacity:0.55;filter:grayscale(0.15)}
button.chip{
opacity:0.9;
transition:opacity 0.2s ease, filter 0.2s ease;
}
button.chip[aria-pressed="false"]{
opacity:0.4;
filter:grayscale(0.3) saturate(0.5);
}
button.chip[aria-pressed="true"]{
opacity:1;
filter:none;
}
/* map panel */
.map-panel{position:relative;min-height:0;height:100%;padding:0;overflow:hidden}
@@ -115,13 +241,36 @@ button.chip[aria-pressed="false"]{opacity:0.55;filter:grayscale(0.15)}
.map-panel.no-reports .map-empty{display:flex}
/* queue list */
.queue-list{display:flex;flex-direction:column;gap:8px;overflow:auto;padding-right:6px}
.queue-item{display:flex;align-items:center;gap:12px;padding:8px;border-radius:8px;border:1px solid #eef2f7;background:linear-gradient(180deg,#fff,#fbfdff)}
.thumb{width:56px;height:56px;border-radius:6px;background:linear-gradient(180deg,#eef2ff,#fff);display:flex;align-items:center;justify-content:center;color:#0f172a;font-weight:700}
.item-main{flex:1;min-width:0}
.item-title{font-weight:600;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}
.item-meta{display:flex;gap:8px;align-items:center;margin-top:6px;font-size:12px;color:var(--muted)}
.item-actions{display:flex;align-items:center}
.queue-list{display:flex;flex-direction:column;gap:8px;overflow:auto;padding-right:6px;max-height:calc(100vh - 200px)}
.queue-item{display:flex;align-items:center;gap:12px;padding:8px;border-radius:8px;border:1px solid #eef2f7;background:linear-gradient(180deg,#fff,#fbfdff);min-height:48px}
.thumb{width:56px;height:56px;border-radius:6px;background:linear-gradient(180deg,#eef2ff,#fff);display:flex;align-items:center;justify-content:center;color:#0f172a;font-weight:700;flex-shrink:0}
.item-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
.item-title{font-weight:600;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;font-size:14px}
.item-title.clickable{
cursor:pointer;
transition:all 0.2s ease;
border-radius:4px;
padding:2px 4px;
margin:-2px -4px;
}
.item-title.clickable:hover{
background-color:rgba(14,165,164,0.1);
color:var(--accent);
transform:translateY(-1px);
box-shadow:0 2px 8px rgba(14,165,164,0.15);
}
.item-meta{display:flex;gap:8px;align-items:center;margin-top:2px;font-size:12px;color:var(--muted);flex-wrap:wrap}
.item-actions{display:flex;align-items:center;gap:8px;flex-shrink:0}
/* Ensure proper spacing in queue items */
.queue-item > * {
flex-shrink: 0;
}
.queue-item .item-main {
flex-shrink: 1;
min-width: 0;
}
/* drawer */
.drawer{
@@ -155,6 +304,73 @@ button.chip[aria-pressed="false"]{opacity:0.55;filter:grayscale(0.15)}
/* marker custom */
.leaflet-container .custom-marker{display:flex;align-items:center;justify-content:center}
/* date range styling */
.filter-group .row {
display:flex;
align-items:center;
justify-content:space-between;
margin-bottom:12px;
}
.filter-group .row strong {
font-size:14px;
font-weight:600;
color:#111827;
}
.filter-group .date-inputs {
display:flex;
gap:8px;
margin-top:8px;
align-items:flex-end;
}
.filter-group .date-input-group {
flex:1;
min-width:0;
display:flex;
flex-direction:column;
}
.filter-group .date-input-group label {
font-size:12px;
font-weight:500;
color:#6b7280;
margin-bottom:4px;
white-space:nowrap;
}
.filter-group .date-input-group input[type="date"] {
padding:6px 8px;
border:1px solid #d1d5db;
border-radius:6px;
font-size:14px;
background:white;
transition:border-color 0.2s ease, box-shadow 0.2s ease;
width:100%;
box-sizing:border-box;
}
.filter-group .date-input-group input[type="date"]:focus {
outline:none;
border-color:var(--accent);
box-shadow:0 0 0 3px rgba(14,165,164,0.1);
}
/* button group styling */
.filter-group .button-group {
display:flex;
gap:8px;
margin-top:16px;
justify-content:flex-end;
}
.filter-group .button-group .btn {
padding:8px 16px;
font-size:13px;
min-width:60px;
}
/* small screens */
@media (max-width:900px){
.main{grid-template-columns:1fr;grid-auto-rows:auto}
@@ -163,8 +379,337 @@ button.chip[aria-pressed="false"]{opacity:0.55;filter:grayscale(0.15)}
.header{padding:8px 12px}
.filters{order:2}
.map-panel{order:1}
.panel{padding:10px}
.panel{padding:16px}
.filter-group .date-inputs {
flex-direction:column;
gap:12px;
}
.filter-group .date-input-group {
min-width:auto;
}
.filter-group .button-group {
flex-direction:column;
gap:8px;
}
.queue-item .item-actions {
flex-direction:column;
align-items:stretch;
gap:6px;
min-width:auto;
}
.queue-item .item-actions select,
.queue-item .item-actions button {
width:100%;
text-align:center;
}
}
/* medium screens - adjust for better button layout */
@media (max-width:1200px) and (min-width:901px){
.main{grid-template-columns:280px 1fr 360px}
}
/* footer styling */
.footer{
background:var(--panel);
border-radius:12px;
padding:16px 20px;
display:flex;
justify-content:space-between;
align-items:center;
border:1px solid rgba(0,0,0,0.05);
box-shadow:0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.stats{
display:flex;
align-items:center;
gap:16px;
font-size:14px;
}
.stats > div{
display:flex;
align-items:center;
gap:6px;
color:#374151;
font-weight:500;
}
/* accessibility tweaks */
.chip, .btn{font-family:inherit}
.chip, .btn{font-family:inherit}
/* Chatbot styles */
.chatbot-toggle {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--accent);
color: white;
border: none;
border-radius: 50px;
padding: 12px 20px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(14, 165, 164, 0.3);
transition: all 0.3s ease;
z-index: 1000;
font-size: 14px;
font-weight: 600;
}
.chatbot-toggle:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(14, 165, 164, 0.4);
background: #0d9488;
}
.chatbot-toggle-icon {
font-size: 18px;
}
.chatbot-container {
position: fixed;
bottom: 20px;
right: 20px;
width: 350px;
height: 500px;
background: var(--panel);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
z-index: 1000;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.chatbot-header {
background: linear-gradient(135deg, var(--accent), #0d9488);
color: white;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.chatbot-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.chatbot-close {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.chatbot-close:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.chatbot-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
display: flex;
gap: 8px;
max-width: 85%;
}
.message.bot {
align-self: flex-start;
}
.message.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.message.user .message-avatar {
background: var(--severity-high);
}
.message-content {
flex: 1;
}
.message-text {
background: #f8fafc;
padding: 10px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
white-space: pre-wrap;
font-family: inherit;
}
.message.user .message-text {
background: var(--accent);
color: white;
}
.message-time {
font-size: 11px;
color: #6b7280;
margin-top: 4px;
text-align: right;
}
.message.user .message-time {
text-align: left;
}
.typing-indicator {
display: flex;
gap: 4px;
align-items: center;
padding: 10px 12px;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #6b7280;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-10px);
opacity: 1;
}
}
.chatbot-quick-actions {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.quick-action-btn {
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 6px 12px;
font-size: 11px;
color: #475569;
cursor: pointer;
transition: all 0.2s ease;
}
.quick-action-btn:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.chatbot-input-form {
padding: 16px;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 8px;
align-items: center;
}
.chatbot-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 20px;
font-size: 14px;
outline: none;
transition: border-color 0.2s ease;
}
.chatbot-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(14, 165, 164, 0.1);
}
.chatbot-input:disabled {
background: #f8fafc;
cursor: not-allowed;
}
.chatbot-send-btn {
background: var(--accent);
color: white;
border: none;
border-radius: 20px;
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
min-width: 60px;
}
.chatbot-send-btn:hover:not(:disabled) {
background: #0d9488;
}
.chatbot-send-btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
/* Mobile responsiveness for chatbot */
@media (max-width: 768px) {
.chatbot-container {
width: calc(100vw - 40px);
height: 400px;
bottom: 10px;
right: 10px;
left: 10px;
}
.chatbot-toggle {
bottom: 10px;
right: 10px;
padding: 10px 16px;
font-size: 13px;
}
}

192
dashboard/test.html Normal file
View File

@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard Test</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background: #f5f5f5;
}
.test-container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.test-item {
margin-bottom: 20px;
padding: 15px;
border-left: 4px solid #0ea5a4;
background: #f9f9f9;
border-radius: 4px;
}
.status {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.status.pass { background: #d4edda; color: #155724; }
.status.fail { background: #f8d7da; color: #721c24; }
.status.info { background: #d1ecf1; color: #0c5460; }
iframe {
width: 100%;
height: 600px;
border: 1px solid #ddd;
border-radius: 8px;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="test-container">
<h1>FixMate Dashboard - Test Page</h1>
<div class="test-item">
<h3>🗺️ Map Initialization Test</h3>
<p>Testing if the Leaflet map initializes properly without the "Map container not found" error.</p>
<div id="map-test-status" class="status" style="background: #fff3cd; color: #856404;">Testing...</div>
</div>
<div class="test-item">
<h3>🔧 Backend Connection Test</h3>
<p>Testing connection to the Python backend server.</p>
<div id="backend-test-status" class="status">Testing...</div>
</div>
<div class="test-item">
<h3>📊 Data Loading Test</h3>
<p>Testing if ticket data loads successfully from the backend.</p>
<div id="data-test-status" class="status">Testing...</div>
</div>
<div class="test-item">
<h3>🎨 UI Components Test</h3>
<p>Testing if all UI components render correctly (filters, tickets list, etc.).</p>
<div id="ui-test-status" class="status">Testing...</div>
</div>
<div class="test-item">
<h3>📱 Responsive Design Test</h3>
<p>Testing if the dashboard works on different screen sizes.</p>
<div id="responsive-test-status" class="status">Testing...</div>
</div>
<div class="test-item">
<h3>🚀 Full Dashboard Preview</h3>
<p>Live preview of the complete dashboard (if all tests pass).</p>
<iframe id="dashboard-frame" src="index.html" style="display: none;"></iframe>
</div>
</div>
<script>
// Test map initialization
fetch('index.html')
.then(response => response.text())
.then(html => {
const hasMapElement = html.includes('id="map"');
const hasLeaflet = html.includes('leaflet');
if (hasMapElement && hasLeaflet) {
document.getElementById('map-test-status').className = 'status pass';
document.getElementById('map-test-status').textContent = 'PASS: Map elements found';
} else {
document.getElementById('map-test-status').className = 'status fail';
document.getElementById('map-test-status').textContent = 'FAIL: Map elements missing';
}
})
.catch(error => {
document.getElementById('map-test-status').className = 'status fail';
document.getElementById('map-test-status').textContent = 'ERROR: ' + error.message;
});
// Test backend connection
fetch('http://127.0.0.1:8000/test')
.then(response => {
if (response.ok) {
document.getElementById('backend-test-status').className = 'status pass';
document.getElementById('backend-test-status').textContent = 'PASS: Backend responding';
return response.json();
} else {
throw new Error('Backend not responding');
}
})
.then(data => {
console.log('Backend test data:', data);
document.getElementById('data-test-status').className = 'status pass';
document.getElementById('data-test-status').textContent = 'PASS: Data loaded (' + (Array.isArray(data) ? data.length : 'object') + ')';
})
.catch(error => {
console.log('Backend test error:', error);
document.getElementById('backend-test-status').className = 'status fail';
document.getElementById('backend-test-status').textContent = 'FAIL: ' + error.message;
// Try loading demo data instead
fetch('data/demo-reports.json')
.then(response => response.json())
.then(data => {
document.getElementById('data-test-status').className = 'status info';
document.getElementById('data-test-status').textContent = 'INFO: Using demo data (' + data.length + ' reports)';
})
.catch(demoError => {
document.getElementById('data-test-status').className = 'status fail';
document.getElementById('data-test-status').textContent = 'FAIL: No data available';
});
});
// Test UI components
fetch('styles.css')
.then(response => response.text())
.then(css => {
const hasModernCSS = css.includes('--primary') && css.includes('grid-template-columns');
const hasResponsive = css.includes('@media');
if (hasModernCSS && hasResponsive) {
document.getElementById('ui-test-status').className = 'status pass';
document.getElementById('ui-test-status').textContent = 'PASS: Modern CSS detected';
} else {
document.getElementById('ui-test-status').className = 'status fail';
document.getElementById('ui-test-status').textContent = 'FAIL: CSS issues detected';
}
})
.catch(error => {
document.getElementById('ui-test-status').className = 'status fail';
document.getElementById('ui-test-status').textContent = 'ERROR: ' + error.message;
});
// Test responsive design
const screenWidth = window.screen.width;
const screenHeight = window.screen.height;
if (screenWidth >= 1200) {
document.getElementById('responsive-test-status').className = 'status pass';
document.getElementById('responsive-test-status').textContent = 'PASS: Desktop resolution (' + screenWidth + 'x' + screenHeight + ')';
} else if (screenWidth >= 768) {
document.getElementById('responsive-test-status').className = 'status pass';
document.getElementById('responsive-test-status').textContent = 'PASS: Tablet resolution (' + screenWidth + 'x' + screenHeight + ')';
} else {
document.getElementById('responsive-test-status').className = 'status pass';
document.getElementById('responsive-test-status').textContent = 'PASS: Mobile resolution (' + screenWidth + 'x' + screenHeight + ')';
}
// Show dashboard preview if all tests pass
setTimeout(() => {
const allTestsPassed =
document.getElementById('map-test-status').classList.contains('pass') &&
document.getElementById('backend-test-status').classList.contains('pass') &&
document.getElementById('ui-test-status').classList.contains('pass');
if (allTestsPassed) {
document.getElementById('dashboard-frame').style.display = 'block';
}
}, 2000);
</script>
</body>
</html>

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -46,7 +46,7 @@
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- Permissions required by FixMate -->
<!-- Permissions required by CityPulse -->
<key>NSCameraUsageDescription</key>
<string>Camera access is required to capture issue photos.</string>
<key>NSPhotoLibraryUsageDescription</key>

View File

@@ -10,8 +10,8 @@ import 'screens/settings/settings_screen.dart';
import 'theme/themes.dart';
import 'package:shared_preferences/shared_preferences.dart';
class FixMateApp extends StatelessWidget {
const FixMateApp({super.key});
class CityPulseApp extends StatelessWidget {
const CityPulseApp({super.key});
@override
Widget build(BuildContext context) {

View File

@@ -4,21 +4,21 @@ import 'app.dart';
import 'l10n/i18n.dart';
import 'l10n/locale_provider.dart';
export 'app.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize locale provider
final localeProvider = LocaleProvider();
await localeProvider.init();
// Initialize i18n with the current locale
await I18n.init(localeProvider.locale);
runApp(
ChangeNotifierProvider.value(
value: localeProvider,
child: const FixMateApp(),
child: const CityPulseApp(),
),
);
}

View File

@@ -21,6 +21,9 @@ class Report {
/// Base64 encoded photo for web platform
final String? base64Photo;
/// Remote image URL provided by backend (absolute URL)
final String? imageUrl;
/// Geographic location where the issue was reported
final LocationData location;
@@ -38,6 +41,8 @@ class Report {
/// Address or location description (placeholder for future use)
final String? address;
/// Name of the user who submitted the report (API reports)
final String? submittedBy;
/// Source of the photo ("camera" or "gallery")
final String source;
@@ -61,12 +66,14 @@ class Report {
required this.status,
this.photoPath,
this.base64Photo,
this.imageUrl,
required this.location,
required this.createdAt,
required this.updatedAt,
required this.deviceId,
this.notes,
this.address,
this.submittedBy,
required this.source,
this.editable = true,
this.deletable = true,
@@ -89,6 +96,8 @@ class Report {
String? photoPath,
String? base64Photo,
String? notes,
String? submittedBy,
String? address,
required String source,
required String deviceId,
required AISuggestion aiSuggestion,
@@ -106,6 +115,8 @@ class Report {
updatedAt: now,
deviceId: deviceId,
notes: notes,
address: address,
submittedBy: submittedBy,
source: source,
aiSuggestion: aiSuggestion,
);
@@ -118,6 +129,7 @@ class Report {
Status? status,
String? photoPath,
String? base64Photo,
String? imageUrl,
LocationData? location,
String? updatedAt,
String? notes,
@@ -133,6 +145,7 @@ class Report {
status: status ?? this.status,
photoPath: photoPath ?? this.photoPath,
base64Photo: base64Photo ?? this.base64Photo,
imageUrl: imageUrl ?? this.imageUrl,
location: location ?? this.location,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
@@ -156,6 +169,7 @@ class Report {
'status': status.key,
'photoPath': photoPath,
'base64Photo': base64Photo,
'imageUrl': imageUrl,
'location': {
'lat': location.lat,
'lng': location.lng,
@@ -166,6 +180,7 @@ class Report {
'deviceId': deviceId,
'notes': notes,
'address': address,
'submittedBy': submittedBy,
'source': source,
'editable': editable,
'deletable': deletable,
@@ -187,6 +202,7 @@ class Report {
status: (json['status'] as String).toStatus() ?? Status.submitted,
photoPath: json['photoPath'] as String?,
base64Photo: json['base64Photo'] as String?,
imageUrl: json['imageUrl'] as String?,
location: LocationData(
lat: (json['location']['lat'] as num).toDouble(),
lng: (json['location']['lng'] as num).toDouble(),
@@ -199,6 +215,7 @@ class Report {
deviceId: json['deviceId'] as String,
notes: json['notes'] as String?,
address: json['address'] as String?,
submittedBy: json['submittedBy'] as String?,
source: json['source'] as String,
editable: json['editable'] as bool? ?? true,
deletable: json['deletable'] as bool? ?? true,

View File

@@ -12,7 +12,7 @@ import '../../l10n/i18n.dart';
import '../../models/enums.dart';
import '../../models/report.dart';
import '../../services/location_service.dart';
import '../../services/storage.dart';
import '../../services/api_service.dart';
import '../../widgets/severity_badge.dart';
import '../../widgets/status_badge.dart';
import '../my_reports/my_reports_screen.dart';
@@ -59,7 +59,7 @@ class _MapScreenState extends State<MapScreen> {
Future<void> _refresh() async {
setState(() => _loading = true);
final reports = await StorageService.getReports();
final reports = await ApiService.fetchTickets();
setState(() {
_allReports = reports;
_loading = false;
@@ -325,6 +325,17 @@ class _MapScreenState extends State<MapScreen> {
child: Icon(Icons.photo, color: Colors.grey.shade600),
);
// Prefer backend-provided image URL when available
if (r.imageUrl != null && r.imageUrl!.isNotEmpty) {
return Image.network(
r.imageUrl!,
width: 120,
height: 90,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => placeholder,
);
}
if (kIsWeb) {
if (r.base64Photo != null && r.base64Photo!.isNotEmpty) {
try {
@@ -776,7 +787,17 @@ class MapReportDetails extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (kIsWeb && report.base64Photo != null)
if (report.imageUrl != null)
Image.network(
report.imageUrl!,
errorBuilder: (_, __, ___) => Container(
height: 180,
color: Colors.grey.shade200,
alignment: Alignment.center,
child: const Icon(Icons.photo, size: 64),
),
)
else if (kIsWeb && report.base64Photo != null)
Image.memory(base64Decode(report.base64Photo!))
else if (!kIsWeb && report.photoPath != null)
Image.file(File(report.photoPath!))

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../services/storage.dart';
import '../../services/api_service.dart';
import '../../models/report.dart';
import '../../widgets/report_card.dart';
import '../map/map_screen.dart';
@@ -23,14 +24,46 @@ class _MyReportsScreenState extends State<MyReportsScreen> {
}
Future<void> _loadReports() async {
setState(() {
_loading = true;
});
final reports = await StorageService.getReports();
setState(() {
_reports = reports.reversed.toList(); // newest first
_loading = false;
});
setState(() => _loading = true);
try {
// Try to fetch tickets from API and filter by this device's user id
final userId = await ApiService.getUserId();
final apiReports = await ApiService.fetchTickets();
// Keep only reports that belong to this device/user
final myApiReports = apiReports.where((r) => r.deviceId == userId).toList();
// Also include any local reports stored that belong to this device
final localReports = await StorageService.getReports();
final myLocalReports = localReports.where((r) => r.deviceId == userId).toList();
// Merge by id, prefer API version when available
final Map<String, Report> merged = {};
for (final r in myApiReports) merged[r.id] = r;
for (final r in myLocalReports) {
if (!merged.containsKey(r.id)) merged[r.id] = r;
}
final combined = merged.values.toList();
setState(() {
if (combined.isNotEmpty) {
_reports = combined.reversed.toList(); // newest first
} else {
// Fallback: show local reports if no API-backed reports found for this user
_reports = localReports.reversed.toList();
}
_loading = false;
});
} catch (e) {
// Conservative fallback to local storage
final reports = await StorageService.getReports();
setState(() {
_reports = reports.reversed.toList();
_loading = false;
});
}
}
void _onViewReport(Report r) {

View File

@@ -54,25 +54,35 @@ class _CaptureScreenState extends State<CaptureScreen> {
Future<void> _processImage(XFile image, ImageSource source) async {
try {
// Get current position (Geolocator.Position)
// Get current position (optional - app can work without location)
final position = await LocationService.getCurrentPosition();
if (position == null) {
// Create location data even if position is null (with default values)
LocationData? locationData;
if (position != null) {
locationData = LocationService.positionToLocationData(position);
print('Location acquired: ${locationData.lat}, ${locationData.lng}');
} else {
// Create a fallback location with zero coordinates
// This allows the app to continue working without location
locationData = LocationData(lat: 0.0, lng: 0.0, accuracy: null);
print(
'Using fallback location (0.0, 0.0) - location services unavailable',
);
// Show a non-blocking warning to the user
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'Unable to get location. Please try again.',
const SnackBar(
content: Text(
'Location unavailable. Report will be created without GPS coordinates.',
), // TODO: Move to i18n
duration: Duration(seconds: 3),
),
);
}
return;
}
// Convert Position -> LocationData (app model)
final locationData = LocationService.positionToLocationData(position);
// Generate AI suggestion (seeded deterministic)
final aiSuggestion = MockAIService.generateSuggestion(
id: DateTime.now().millisecondsSinceEpoch.toString(),
@@ -113,10 +123,12 @@ class _CaptureScreenState extends State<CaptureScreen> {
);
}
} catch (e) {
print('Critical error in image processing: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(I18n.t('error.imageProcessing', {'0': e.toString()})),
backgroundColor: Colors.red,
),
);
}

View File

@@ -0,0 +1,859 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import '../../l10n/i18n.dart';
import '../../models/report.dart';
import '../../services/geocoding_service.dart';
import '../../services/location_service.dart';
class LocationPickerScreen extends StatefulWidget {
final LocationData? initialLocation;
final String? initialAddress;
const LocationPickerScreen({
super.key,
this.initialLocation,
this.initialAddress,
});
@override
State<LocationPickerScreen> createState() => _LocationPickerScreenState();
}
class _LocationPickerScreenState extends State<LocationPickerScreen> {
final MapController _mapController = MapController();
final TextEditingController _searchController = TextEditingController();
LocationData? _selectedLocation;
String? _selectedAddress;
List<LocationSearchResult> _searchResults = [];
bool _isSearching = false;
bool _isLoadingLocation = false;
bool _isMapLoading = true;
bool _isGettingAddress = false;
// Search optimization
Timer? _searchDebounceTimer;
final Map<String, List<LocationSearchResult>> _searchCache = {};
static const Duration _searchDebounceDuration = Duration(
milliseconds: 1200,
); // Increased debounce to prevent multiple calls
// Default center (Kuala Lumpur, Malaysia)
static const LatLng _defaultCenter = LatLng(3.1390, 101.6869);
@override
void initState() {
super.initState();
_selectedLocation = widget.initialLocation;
_selectedAddress = widget.initialAddress;
// Set up timeouts first (before map operations)
_setupTimeouts();
}
@override
void dispose() {
_searchController.dispose();
_searchDebounceTimer?.cancel();
_reverseGeocodeTimer?.cancel();
_mapController.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
if (!mounted) return;
// Cancel previous timer
_searchDebounceTimer?.cancel();
if (query.trim().isEmpty) {
if (mounted) {
setState(() {
_searchResults = [];
_isSearching = false;
});
}
return;
}
// Check cache first
if (_searchCache.containsKey(query)) {
if (mounted) {
setState(() {
_searchResults = _searchCache[query]!;
_isSearching = false;
});
}
return;
}
// Debounce search requests
_searchDebounceTimer = Timer(_searchDebounceDuration, () async {
if (mounted) {
await _performSearch(query);
}
});
}
Future<void> _performSearch(String query) async {
if (!mounted) return;
setState(() {
_isSearching = true;
});
try {
final results = await GeocodingService.searchLocations(query);
// Cache only non-empty results to avoid sticky empty cache on transient failures
if (results.isNotEmpty) {
_searchCache[query] = results;
}
if (mounted) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
} catch (e) {
print('Geocoding search failed: $e, trying fallback...');
// Try Photon fallback first, then a simplified Nominatim search
try {
final photonResults = await GeocodingService.searchLocationsPhoton(
query,
);
if (photonResults.isNotEmpty) {
_searchCache[query] = photonResults;
if (mounted) {
setState(() {
_searchResults = photonResults;
_isSearching = false;
});
}
return;
}
final fallbackResults = await _performFallbackSearch(query);
if (fallbackResults.isNotEmpty) {
_searchCache[query] = fallbackResults;
}
if (mounted) {
setState(() {
_searchResults = fallbackResults;
_isSearching = false;
});
}
} catch (fallbackError) {
print('Fallback search also failed: $fallbackError');
if (mounted) {
setState(() {
_searchResults = [];
_isSearching = false;
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Search failed: $e')));
}
}
}
}
Future<List<LocationSearchResult>> _performFallbackSearch(
String query,
) async {
// Simplified search with basic parameters
final encodedQuery = Uri.encodeComponent(query);
final url = Uri.parse(
'https://nominatim.openstreetmap.org/search?format=json&q=$encodedQuery&limit=3&addressdetails=0',
);
final response = await http
.get(
url,
headers: {
'User-Agent': 'CityPulse/1.0 (contact@citypulse.app)',
'Accept': 'application/json',
'Accept-Language': I18n.currentLocale,
},
)
.timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final List<dynamic> data = json.decode(response.body);
return data.map((item) => LocationSearchResult.fromJson(item)).toList();
} else {
throw Exception('Fallback search failed');
}
}
Future<void> _useCurrentLocation() async {
if (!mounted) return;
setState(() {
_isLoadingLocation = true;
});
try {
final position = await LocationService.getCurrentPosition();
if (position != null) {
final locationData = LocationService.positionToLocationData(position);
if (mounted) {
setState(() {
_selectedLocation = locationData;
});
}
// Move map to current location (only if map is ready)
try {
_mapController.move(LatLng(locationData.lat, locationData.lng), 16.0);
} catch (e) {
print('Error moving map to current location: $e');
}
// Get address for current location
final address = await GeocodingService.getAddressFromCoordinates(
locationData.lat,
locationData.lng,
);
if (mounted) {
setState(() {
_selectedAddress = address;
});
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unable to get current location')),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error getting location: $e')));
}
} finally {
if (mounted) {
setState(() {
_isLoadingLocation = false;
});
}
}
}
Timer? _reverseGeocodeTimer;
void _onMapTap(LatLng point) {
setState(() {
_selectedLocation = LocationData(
lat: point.latitude,
lng: point.longitude,
accuracy: 10.0, // Default accuracy for manual selection
);
});
// Debounce reverse geocoding requests
_reverseGeocodeTimer?.cancel();
_reverseGeocodeTimer = Timer(const Duration(milliseconds: 500), () {
_getAddressForLocation(point.latitude, point.longitude);
});
}
Future<void> _getAddressForLocation(double lat, double lng) async {
if (!mounted) return;
setState(() {
_isGettingAddress = true;
});
try {
final address = await GeocodingService.getAddressFromCoordinates(
lat,
lng,
);
if (mounted) {
setState(() {
_selectedAddress = address;
_isGettingAddress = false;
});
}
} catch (e) {
print('Reverse geocoding failed: $e, trying fallback...');
// Try a simpler reverse geocoding approach
try {
final fallbackAddress = await _performFallbackReverseGeocoding(
lat,
lng,
);
if (mounted) {
setState(() {
_selectedAddress = fallbackAddress;
_isGettingAddress = false;
});
}
} catch (fallbackError) {
print('Fallback reverse geocoding also failed: $fallbackError');
if (mounted) {
setState(() {
_isGettingAddress = false;
});
}
}
}
}
Future<String?> _performFallbackReverseGeocoding(
double lat,
double lng,
) async {
// Simplified reverse geocoding with basic parameters
final url = Uri.parse(
'https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lng&zoom=14',
);
final response = await http
.get(
url,
headers: {'User-Agent': 'CityPulse/1.0 (contact@citypulse.app)'},
)
.timeout(const Duration(seconds: 2));
if (response.statusCode == 200) {
final data = json.decode(response.body);
return _formatSimpleAddress(data);
} else {
throw Exception('Fallback reverse geocoding failed');
}
}
String _formatSimpleAddress(Map<String, dynamic> data) {
final address = data['address'] as Map<String, dynamic>?;
if (address == null) {
return '${data['lat']}, ${data['lon']}';
}
final parts = <String>[];
if (address['road'] != null) {
parts.add(address['road']);
}
if (address['city'] != null) {
parts.add(address['city']);
} else if (address['town'] != null) {
parts.add(address['town']);
}
if (address['country'] != null) {
parts.add(address['country']);
}
return parts.isNotEmpty
? parts.join(', ')
: '${data['lat']}, ${data['lon']}';
}
void _clearSearch() {
_searchController.clear();
setState(() {
_searchResults = [];
_isSearching = false;
});
_searchDebounceTimer?.cancel();
}
void _setupTimeouts() {
// Set multiple timeouts to ensure loading screen never stays forever
Future.delayed(const Duration(milliseconds: 800), () {
if (mounted && _isMapLoading) {
print('Map loading timeout 800ms - still loading');
setState(() {
_isMapLoading = false;
});
}
});
Future.delayed(const Duration(seconds: 2), () {
if (mounted && _isMapLoading) {
print('Map loading timeout 2s - still loading');
setState(() {
_isMapLoading = false;
});
}
});
Future.delayed(const Duration(seconds: 4), () {
if (mounted && _isMapLoading) {
print('Map loading timeout 4s - still loading');
setState(() {
_isMapLoading = false;
});
}
});
// Final failsafe - absolutely must hide loading screen
Future.delayed(const Duration(seconds: 6), () {
if (mounted && _isMapLoading) {
print('FINAL FAILSAFE: Force hiding loading screen after 6s');
setState(() {
_isMapLoading = false;
});
}
});
}
void _checkMapReady() {
if (!mounted) return;
// Simple check - if map controller has a position, consider it ready
try {
final center = _mapController.camera.center;
print('Map center check: $center, loading: $_isMapLoading');
if (_isMapLoading) {
print('Map appears ready, hiding loading screen');
setState(() {
_isMapLoading = false;
});
}
} catch (e) {
print('Error checking map readiness: $e');
// If we can't get center, the map might not be ready yet, but let's be more aggressive
if (_isMapLoading) {
print('Force hiding loading screen due to error');
setState(() {
_isMapLoading = false;
});
}
}
}
void _initializeMap() {
// This should be called after the map widget is built
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// Center map on initial location or default location
if (_selectedLocation != null) {
try {
_mapController.move(
LatLng(_selectedLocation!.lat, _selectedLocation!.lng),
16.0,
);
print('Map centered on initial location');
} catch (e) {
print('Error moving map to initial location: $e');
// If map controller fails, still try to hide loading screen
if (_isMapLoading) {
setState(() {
_isMapLoading = false;
});
}
}
}
// Additional check after widgets are built
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted && _isMapLoading) {
print('Post-frame callback check - map still loading');
_checkMapReady();
}
});
});
}
void _selectSearchResult(LocationSearchResult result) {
setState(() {
_selectedLocation = result.toLocationData();
_selectedAddress = result.displayName;
_searchResults = [];
_searchController.clear();
});
// Move map to selected location
_mapController.move(LatLng(result.lat, result.lng), 16.0);
}
void _confirmSelection() {
if (_selectedLocation != null) {
Navigator.of(
context,
).pop({'location': _selectedLocation, 'address': _selectedAddress});
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a location first')),
);
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Text(
I18n.t('map.selectLocation'),
style: const TextStyle(fontWeight: FontWeight.w600),
),
elevation: 0,
backgroundColor: Colors.transparent,
foregroundColor: cs.onSurface,
actions: [
TextButton(
onPressed: _confirmSelection,
child: Text(
I18n.t('btn.ok'),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: cs.primary,
),
),
),
],
),
body: Stack(
children: [
// Map with loading state
_isMapLoading
? Container(
color: cs.surface.withOpacity(0.9),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
I18n.t('map.loadingMap'),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: cs.onSurface),
),
const SizedBox(height: 24),
TextButton(
onPressed: () {
print('User manually dismissed loading screen');
setState(() {
_isMapLoading = false;
});
},
child: Text(
I18n.t('map.continueAnyway'),
style: TextStyle(color: cs.primary),
),
),
],
),
),
)
: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: _defaultCenter,
initialZoom: 13.0,
onTap: (tapPosition, point) => _onMapTap(point),
minZoom: 3.0,
maxZoom: 18.0,
onMapReady: () {
print('Map is ready, initializing...');
_initializeMap();
},
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.example.citypulse',
// Add retina mode for better quality on high DPI devices
retinaMode: true,
// Add error handling for tile loading
errorTileCallback: (tile, error, stackTrace) {
print(
'Tile loading error: $error for tile $tile, stackTrace: $stackTrace',
);
// Return a transparent tile instead of showing error
return null;
},
// Fallback tile server
fallbackUrl:
'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
),
// Current location marker (if available)
if (_selectedLocation != null)
MarkerLayer(
markers: [
Marker(
point: LatLng(
_selectedLocation!.lat,
_selectedLocation!.lng,
),
child: Container(
decoration: BoxDecoration(
color: cs.primary,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 3,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.location_on,
color: Colors.white,
size: 24,
),
),
),
],
),
],
),
// Search bar
Positioned(
top: 16,
left: 16,
right: 16,
child: Container(
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
// Search input
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: I18n.t('map.searchHint'),
prefixIcon: Icon(
Icons.search,
color: cs.onSurface.withOpacity(0.6),
),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isSearching)
Container(
width: 20,
height: 20,
margin: const EdgeInsets.only(right: 8),
child: const CircularProgressIndicator(
strokeWidth: 2,
),
)
else if (_searchController.text.isNotEmpty)
IconButton(
icon: Icon(
Icons.clear,
color: cs.onSurface.withOpacity(0.6),
size: 20,
),
onPressed: _clearSearch,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
IconButton(
icon: Icon(Icons.my_location, color: cs.primary),
onPressed: _isLoadingLocation
? null
: _useCurrentLocation,
),
],
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.primary, width: 2),
),
filled: true,
fillColor: cs.surface,
),
onChanged: _onSearchChanged,
),
),
// Search results
if (_searchResults.isNotEmpty)
Container(
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(16),
),
),
child: ListView.builder(
shrinkWrap: true,
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final result = _searchResults[index];
return ListTile(
leading: const Icon(
Icons.location_on,
color: Colors.red,
),
title: Text(
result.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[result.city, result.country]
.where((e) => e != null && e.isNotEmpty)
.join(', '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => _selectSearchResult(result),
);
},
),
),
],
),
),
),
// Selected location info
if (_selectedLocation != null)
Positioned(
bottom: 100,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.location_on, color: cs.primary, size: 20),
const SizedBox(width: 8),
Text(
I18n.t('map.selectedLocation'),
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
),
],
),
const SizedBox(height: 8),
_isGettingAddress
? Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
const SizedBox(width: 8),
Text(
I18n.t('map.gettingAddress'),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: cs.onSurface.withOpacity(0.7),
),
),
],
)
: Text(
_selectedAddress ??
'${_selectedLocation!.lat.toStringAsFixed(6)}, ${_selectedLocation!.lng.toStringAsFixed(6)}',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: cs.onSurface),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (_selectedLocation!.accuracy != null) ...[
const SizedBox(height: 4),
Text(
'Accuracy: ${_selectedLocation!.accuracy!.toStringAsFixed(1)}m',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: cs.onSurface.withOpacity(0.7),
),
),
],
],
),
),
),
// Bottom action button
Positioned(
bottom: 16,
left: 16,
right: 16,
child: ElevatedButton(
onPressed: _confirmSelection,
style: ElevatedButton.styleFrom(
backgroundColor: cs.primary,
foregroundColor: cs.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(
I18n.t('btn.useThisLocation'),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
);
}
}

View File

@@ -4,6 +4,7 @@ import '../../l10n/i18n.dart';
import '../../models/report.dart';
import '../../models/enums.dart';
import '../../services/storage.dart';
import 'location_picker_screen.dart';
class ReviewScreen extends StatefulWidget {
final Report report;
@@ -23,14 +24,16 @@ class _ReviewScreenState extends State<ReviewScreen> {
late Category _selectedCategory;
late Severity _selectedSeverity;
late TextEditingController _notesController;
late Report _currentReport;
bool _isSubmitting = false;
@override
void initState() {
super.initState();
_selectedCategory = widget.report.category;
_selectedSeverity = widget.report.severity;
_notesController = TextEditingController(text: widget.report.notes ?? '');
_currentReport = widget.report;
_selectedCategory = _currentReport.category;
_selectedSeverity = _currentReport.severity;
_notesController = TextEditingController(text: _currentReport.notes ?? '');
}
@override
@@ -46,7 +49,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
try {
// Update report with user selections
final updatedReport = widget.report.copyWith(
final updatedReport = _currentReport.copyWith(
category: _selectedCategory,
severity: _selectedSeverity,
notes: _notesController.text.isEmpty ? null : _notesController.text,
@@ -200,7 +203,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
Text(
I18n.t('review.aiConfidence', {
'0':
(widget.report.aiSuggestion.confidence *
(_currentReport.aiSuggestion.confidence *
100)
.round()
.toString(),
@@ -243,7 +246,10 @@ class _ReviewScreenState extends State<ReviewScreen> {
),
const SizedBox(width: 8),
Text(
widget.report.aiSuggestion.category.displayName,
_currentReport
.aiSuggestion
.category
.displayName,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
@@ -280,7 +286,10 @@ class _ReviewScreenState extends State<ReviewScreen> {
),
const SizedBox(width: 8),
Text(
widget.report.aiSuggestion.severity.displayName,
_currentReport
.aiSuggestion
.severity
.displayName,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
@@ -300,9 +309,9 @@ class _ReviewScreenState extends State<ReviewScreen> {
onPressed: () {
setState(() {
_selectedCategory =
widget.report.aiSuggestion.category;
_currentReport.aiSuggestion.category;
_selectedSeverity =
widget.report.aiSuggestion.severity;
_currentReport.aiSuggestion.severity;
});
},
icon: const Icon(Icons.check_circle, size: 18),
@@ -320,7 +329,28 @@ class _ReviewScreenState extends State<ReviewScreen> {
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () {},
onPressed: () {
// Navigate to manual editing screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LocationPickerScreen(
initialLocation: _currentReport.location,
initialAddress: _currentReport.address,
),
),
).then((result) {
if (result != null && mounted) {
setState(() {
// Update the report with new location
_currentReport = _currentReport.copyWith(
location: result['location'],
address: result['address'],
);
});
}
});
},
icon: const Icon(Icons.edit, size: 18),
label: Text(I18n.t('review.editManually')),
style: OutlinedButton.styleFrom(
@@ -615,6 +645,46 @@ class _ReviewScreenState extends State<ReviewScreen> {
color: cs.onSurface,
),
),
const Spacer(),
TextButton.icon(
onPressed: () {
// Navigate to location picker
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LocationPickerScreen(
initialLocation: _currentReport.location,
initialAddress: _currentReport.address,
),
),
).then((result) {
if (result != null && mounted) {
setState(() {
// Update the report with new location
_currentReport = _currentReport.copyWith(
location: result['location'],
address: result['address'],
);
});
}
});
},
icon: Icon(Icons.edit, size: 16, color: cs.primary),
label: Text(
I18n.t('btn.editLocation'),
style: TextStyle(
color: cs.primary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
),
),
],
),
const SizedBox(height: 16),
@@ -647,14 +717,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
),
const SizedBox(height: 8),
Text(
'${widget.report.location.lat.toStringAsFixed(6)}, ${widget.report.location.lng.toStringAsFixed(6)}',
'${_currentReport.location.lat.toStringAsFixed(6)}, ${_currentReport.location.lng.toStringAsFixed(6)}',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: cs.onSurface,
fontFamily: 'monospace',
),
),
if (widget.report.location.accuracy != null) ...[
if (_currentReport.location.accuracy != null) ...[
const SizedBox(height: 12),
Row(
children: [
@@ -666,7 +736,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
const SizedBox(width: 8),
Text(
I18n.t('review.accuracy', {
'0': widget.report.location.accuracy!
'0': _currentReport.location.accuracy!
.toStringAsFixed(1),
}),
style: Theme.of(context).textTheme.bodySmall

View File

@@ -1,33 +1,44 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../models/report.dart';
import '../models/enums.dart';
/// Service for communicating with the FixMate Backend API
/// Service for communicating with the CityPulse Backend API
class ApiService {
// Configure this to match your backend URL
static const String _baseUrl = 'http://127.0.0.1:8000/api';
static const String _uploadsUrl = 'http://127.0.0.1:8000/static/uploads';
// Use localhost for web/desktop, network IP for mobile/emulator
static const String BASE_URL = 'http://192.168.100.59:8000';
static const String _baseUrl = '$BASE_URL/api';
static const String _uploadsUrl = '$BASE_URL/static/uploads';
// Create a user ID for this device if not exists
// Create a user ID for this device if not exists (persisted)
static Future<String> _getOrCreateUserId() async {
// For now, generate a UUID for this device
// In a real app, this would be stored securely
return const Uuid().v4();
try {
final prefs = await SharedPreferences.getInstance();
const key = 'fixmate_user_id';
final existing = prefs.getString(key);
if (existing != null && existing.isNotEmpty) return existing;
final newId = Uuid().v4();
await prefs.setString(key, newId);
return newId;
} catch (e) {
// If SharedPreferences fails for any reason, fallback to an in-memory UUID
return Uuid().v4();
}
}
/// Create a new user
static Future<String> createUser({required String name, required String email}) async {
static Future<String> createUser({
required String name,
required String email,
}) async {
try {
final response = await http.post(
Uri.parse('$_baseUrl/users'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'name': name,
'email': email,
}),
body: json.encode({'name': name, 'email': email}),
);
if (response.statusCode == 200) {
@@ -42,6 +53,9 @@ class ApiService {
}
}
/// Get or create the current device / user id used when submitting reports
static Future<String> getUserId() => _getOrCreateUserId();
/// Submit a report to the backend
static Future<String> submitReport({
required double latitude,
@@ -49,23 +63,28 @@ class ApiService {
required String description,
required List<int> imageBytes,
required String imageName,
String? userName,
String? address,
}) async {
try {
final userId = await _getOrCreateUserId();
var request = http.MultipartRequest('POST', Uri.parse('$_baseUrl/report'));
var request = http.MultipartRequest(
'POST',
Uri.parse('$_baseUrl/report'),
);
request.fields['user_id'] = userId;
request.fields['latitude'] = latitude.toString();
request.fields['longitude'] = longitude.toString();
request.fields['description'] = description;
if (userName != null && userName.isNotEmpty)
request.fields['user_name'] = userName;
if (address != null && address.isNotEmpty)
request.fields['address'] = address;
// Add the image file
request.files.add(
http.MultipartFile.fromBytes(
'image',
imageBytes,
filename: imageName,
),
http.MultipartFile.fromBytes('image', imageBytes, filename: imageName),
);
final response = await request.send();
@@ -102,6 +121,9 @@ class ApiService {
}
}
/// Preferred API name for fetching tickets (alias for getReports)
static Future<List<Report>> fetchTickets() => getReports();
/// Get a single ticket by ID
static Future<Report?> getReportById(String ticketId) async {
try {
@@ -133,6 +155,26 @@ class ApiService {
}
}
/// Delete a ticket by ID
static Future<bool> deleteTicket(String ticketId) async {
try {
final response = await http.delete(
Uri.parse('$_baseUrl/tickets/$ticketId'),
);
if (response.statusCode == 200 || response.statusCode == 204) {
return true;
} else {
print(
'Failed to delete ticket: ${response.statusCode} ${response.body}',
);
return false;
}
} catch (e) {
print('Error deleting ticket: $e');
return false;
}
}
/// Get analytics data
static Future<Map<String, dynamic>> getAnalytics() async {
try {
@@ -151,67 +193,96 @@ class ApiService {
/// Convert API ticket response to Report model
static Report _convertApiTicketToReport(Map<String, dynamic> data) {
final id = (data['id'] ?? data['ticket_id'] ?? '').toString();
final imageUrl =
(data['image_url'] as String?) ??
(data['image_path'] != null
? '$_uploadsUrl/${(data['image_path'] as String).split('/').last}'
: null);
return Report(
id: data['ticket_id'] ?? '',
id: id,
category: _normalizeCategory(data['category'] ?? ''),
severity: _normalizeSeverity(data['severity'] ?? 'N/A'),
status: _normalizeStatus(data['status'] ?? 'New'),
description: data['description'] ?? '',
latitude: data['latitude']?.toDouble() ?? 0.0,
longitude: data['longitude']?.toDouble() ?? 0.0,
createdAt: DateTime.parse(data['created_at'] ?? DateTime.now().toIso8601String()),
updatedAt: DateTime.parse(data['updated_at'] ?? DateTime.now().toIso8601String()),
// Image path will be constructed from the API response
imagePath: data['image_path'] != null ? '$_uploadsUrl/${data['image_path'].split('/').last}' : null,
// For API-provided tickets prefer imageUrl; photoPath is for local files
photoPath: null,
imageUrl: imageUrl,
location: LocationData(
lat: (data['latitude'] as num?)?.toDouble() ?? 0.0,
lng: (data['longitude'] as num?)?.toDouble() ?? 0.0,
),
createdAt:
(data['created_at'] ??
data['createdAt'] ??
DateTime.now().toIso8601String())
as String,
updatedAt:
(data['updated_at'] ??
data['updatedAt'] ??
DateTime.now().toIso8601String())
as String,
deviceId: data['user_id'] != null
? data['user_id'].toString()
: 'api-$id',
notes: data['description'] as String?,
address: data['address'] as String?,
submittedBy: data['user_name'] as String?,
source: 'api',
aiSuggestion: AISuggestion(
category: _normalizeCategory(data['category'] ?? ''),
severity: _normalizeSeverity(data['severity'] ?? 'N/A'),
confidence: 0.8, // Default confidence since we don't get this from API
),
);
}
/// Normalize category names to match the app's expected format
static String _normalizeCategory(String category) {
static Category _normalizeCategory(String category) {
// Convert API categories to app categories
switch (category.toLowerCase()) {
case 'pothole':
return 'pothole';
return Category.pothole;
case 'streetlight':
case 'broken_streetlight':
return 'streetlight';
return Category.streetlight;
case 'garbage':
return 'trash';
return Category.trash;
case 'signage':
return 'signage';
return Category.signage;
case 'drainage':
return 'drainage';
return Category.drainage;
default:
return 'other';
return Category.other;
}
}
/// Normalize severity levels
static String _normalizeSeverity(String severity) {
static Severity _normalizeSeverity(String severity) {
switch (severity.toLowerCase()) {
case 'high':
return 'high';
return Severity.high;
case 'medium':
return 'medium';
return Severity.medium;
case 'low':
return 'low';
return Severity.low;
default:
return 'low'; // Default to low if unknown
return Severity.low; // Default to low if unknown
}
}
/// Normalize status values
static String _normalizeStatus(String status) {
static Status _normalizeStatus(String status) {
switch (status.toLowerCase()) {
case 'new':
return 'submitted';
return Status.submitted;
case 'in progress':
case 'in_progress':
return 'in_progress';
return Status.inProgress;
case 'fixed':
return 'fixed';
return Status.fixed;
default:
return 'submitted';
return Status.submitted;
}
}
}
}

View File

@@ -0,0 +1,308 @@
import 'dart:convert';
import 'dart:async';
import 'package:http/http.dart' as http;
import '../models/report.dart';
import '../l10n/i18n.dart';
/// Service for geocoding operations (converting addresses to coordinates and vice versa)
class GeocodingService {
/// Nominatim API base URL (OpenStreetMap)
static const String _nominatimBaseUrl = 'https://nominatim.openstreetmap.org';
/// Search for locations by text query with timeout and better error handling
static Future<List<LocationSearchResult>> searchLocations(
String query, {
int limit = 5,
}) async {
if (query.trim().isEmpty) return [];
try {
final encodedQuery = Uri.encodeComponent(query);
final url = Uri.parse(
'$_nominatimBaseUrl/search?format=json&q=$encodedQuery&limit=$limit&addressdetails=1&dedupe=1', // Simplified parameters for faster response
);
final response = await http
.get(
url,
headers: {
'User-Agent': 'CityPulse/1.0 (contact@citypulse.app)',
'Accept': 'application/json',
'Accept-Language': I18n.currentLocale,
},
)
.timeout(const Duration(seconds: 4));
if (response.statusCode == 200) {
final List<dynamic> data = json.decode(response.body);
// Filter out low-quality results
final filteredData = data.where((item) {
final importance = item['importance'] as num?;
return importance == null ||
importance > 0.1; // Filter out very low importance results
}).toList();
return filteredData
.map((item) => LocationSearchResult.fromJson(item))
.toList();
} else {
print('Geocoding search failed: ${response.statusCode}');
return [];
}
} on TimeoutException catch (e) {
// Let the caller handle timeout (UI may trigger a fallback)
print('Error searching locations: $e');
rethrow;
} catch (e) {
print('Error searching locations: $e');
return [];
}
}
/// Get address from coordinates (reverse geocoding) with timeout
static Future<String?> getAddressFromCoordinates(
double lat,
double lng,
) async {
try {
final url = Uri.parse(
'$_nominatimBaseUrl/reverse?format=json&lat=$lat&lon=$lng&zoom=14',
);
final response = await http
.get(
url,
headers: {
'User-Agent': 'CityPulse/1.0 (contact@citypulse.app)',
'Accept': 'application/json',
'Accept-Language': I18n.currentLocale,
},
)
.timeout(
const Duration(seconds: 2),
); // Shorter timeout for reverse geocoding
if (response.statusCode == 200) {
final data = json.decode(response.body);
return _formatAddress(data);
} else {
print('Reverse geocoding failed: ${response.statusCode}');
return null;
}
} catch (e) {
print('Error getting address: $e');
return null;
}
}
/// Format address from Nominatim response
static String _formatAddress(Map<String, dynamic> data) {
final address = data['address'] as Map<String, dynamic>?;
if (address == null) {
return '${data['lat']}, ${data['lon']}';
}
final parts = <String>[];
if (address['house_number'] != null) {
parts.add(address['house_number']);
}
if (address['road'] != null) {
parts.add(address['road']);
}
if (address['suburb'] != null) {
parts.add(address['suburb']);
} else if (address['neighbourhood'] != null) {
parts.add(address['neighbourhood']);
}
if (address['city'] != null) {
parts.add(address['city']);
} else if (address['town'] != null) {
parts.add(address['town']);
} else if (address['village'] != null) {
parts.add(address['village']);
}
if (address['state'] != null) {
parts.add(address['state']);
}
if (address['country'] != null) {
parts.add(address['country']);
}
return parts.isNotEmpty
? parts.join(', ')
: '${data['lat']}, ${data['lon']}';
}
/// Get current location as a formatted address
static Future<String?> getCurrentLocationAddress() async {
try {
// This would need the LocationService to be integrated
// For now, return a placeholder
return null;
} catch (e) {
print('Error getting current location address: $e');
return null;
}
}
/// Photon (Komoot) search fallback
static Future<List<LocationSearchResult>> searchLocationsPhoton(
String query, {
int limit = 5,
}) async {
if (query.trim().isEmpty) return [];
try {
final String lang = I18n.currentLocale;
final encodedQuery = Uri.encodeComponent(query);
final url = Uri.parse(
'https://photon.komoot.io/api/?q=$encodedQuery&limit=$limit&lang=$lang',
);
final response = await http
.get(
url,
headers: {
'User-Agent': 'CityPulse/1.0 (contact@citypulse.app)',
'Accept': 'application/json',
'Accept-Language': lang,
},
)
.timeout(const Duration(seconds: 4));
if (response.statusCode == 200) {
final Map<String, dynamic> data = json.decode(response.body);
final List<dynamic> features =
(data['features'] as List<dynamic>?) ?? [];
return features
.whereType<Map<String, dynamic>>()
.map((f) => LocationSearchResult.fromPhotonFeature(f))
.toList();
} else {
return [];
}
} on TimeoutException catch (e) {
// propagate to let UI decide
// ignore: avoid_print
print('Photon search timeout: $e');
rethrow;
} catch (e) {
// ignore: avoid_print
print('Photon search error: $e');
return [];
}
}
}
/// Represents a location search result
class LocationSearchResult {
final String displayName;
final double lat;
final double lng;
final String? address;
final String? city;
final String? country;
final String? type;
LocationSearchResult({
required this.displayName,
required this.lat,
required this.lng,
this.address,
this.city,
this.country,
this.type,
});
factory LocationSearchResult.fromJson(Map<String, dynamic> json) {
return LocationSearchResult(
displayName: json['display_name'] ?? '',
lat: double.parse(json['lat'] ?? '0'),
lng: double.parse(json['lon'] ?? '0'),
address:
json['address']?['road'] ??
json['address']?['pedestrian'] ??
json['address']?['path'],
city:
json['address']?['city'] ??
json['address']?['town'] ??
json['address']?['village'],
country: json['address']?['country'],
type: json['type'],
);
}
/// Convert to LocationData
LocationData toLocationData({double? accuracy}) {
return LocationData(
lat: lat,
lng: lng,
accuracy: accuracy ?? 10.0, // Default accuracy for searched locations
);
}
@override
String toString() {
return 'LocationSearchResult(displayName: $displayName, lat: $lat, lng: $lng)';
}
/// Construct from a Photon feature
factory LocationSearchResult.fromPhotonFeature(Map<String, dynamic> feature) {
final Map<String, dynamic>? properties =
feature['properties'] as Map<String, dynamic>?;
final Map<String, dynamic>? geometry =
feature['geometry'] as Map<String, dynamic>?;
final List<dynamic>? coordinates = geometry != null
? geometry['coordinates'] as List<dynamic>?
: null;
final double lat = coordinates != null && coordinates.length >= 2
? (coordinates[1] as num).toDouble()
: 0.0;
final double lng = coordinates != null && coordinates.length >= 2
? (coordinates[0] as num).toDouble()
: 0.0;
final String? name = properties?['name'] as String?;
final String? street =
(properties?['street'] ?? properties?['road']) as String?;
final String? houseNumber = properties?['housenumber'] as String?;
final String? city =
(properties?['city'] ??
properties?['town'] ??
properties?['village'] ??
properties?['county'])
as String?;
final String? country = properties?['country'] as String?;
final List<String> addressParts = [];
if (street != null && street.isNotEmpty) addressParts.add(street);
if (houseNumber != null && houseNumber.isNotEmpty)
addressParts.add(houseNumber);
final String address = addressParts.join(' ');
final String display = [
if (name != null && name.isNotEmpty) name,
if (city != null && city.isNotEmpty) city,
if (country != null && country.isNotEmpty) country,
].join(', ');
return LocationSearchResult(
displayName: display.isNotEmpty ? display : (name ?? ''),
lat: lat,
lng: lng,
address: address.isNotEmpty ? address : null,
city: city,
country: country,
type: properties?['osm_key'] as String?,
);
}
}

View File

@@ -9,6 +9,13 @@ class LocationService {
return await Geolocator.isLocationServiceEnabled();
} catch (e) {
print('Error checking location service: $e');
// Handle specific Android exceptions that can cause crashes
if (e.toString().contains('DeadSystemException') ||
e.toString().contains('DeadSystemRuntimeException') ||
e.toString().contains('SecurityException')) {
print('System-level location service error detected, returning false');
return false;
}
return false;
}
}
@@ -19,6 +26,13 @@ class LocationService {
return await Geolocator.checkPermission();
} catch (e) {
print('Error checking location permission: $e');
// Handle specific Android exceptions that can cause crashes
if (e.toString().contains('DeadSystemException') ||
e.toString().contains('DeadSystemRuntimeException') ||
e.toString().contains('SecurityException')) {
print('System-level permission error detected, returning denied');
return LocationPermission.denied;
}
return LocationPermission.denied;
}
}
@@ -29,6 +43,15 @@ class LocationService {
return await Geolocator.requestPermission();
} catch (e) {
print('Error requesting location permission: $e');
// Handle specific Android exceptions that can cause crashes
if (e.toString().contains('DeadSystemException') ||
e.toString().contains('DeadSystemRuntimeException') ||
e.toString().contains('SecurityException')) {
print(
'System-level permission request error detected, returning denied',
);
return LocationPermission.denied;
}
return LocationPermission.denied;
}
}
@@ -58,17 +81,46 @@ class LocationService {
return null;
}
// Get current position
return await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
timeLimit: const Duration(seconds: 30),
);
// Get current position with multiple fallback strategies
return await _getPositionWithFallback();
} catch (e) {
print('Error getting current position: $e');
return null;
}
}
/// Get position with fallback strategies to avoid crashes
static Future<Position?> _getPositionWithFallback() async {
try {
// Try high accuracy first with a reasonable timeout
return await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
timeLimit: const Duration(seconds: 15),
);
} catch (e) {
print('High accuracy failed, trying medium accuracy: $e');
try {
// Fallback to medium accuracy
return await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.medium,
timeLimit: const Duration(seconds: 10),
);
} catch (e2) {
print('Medium accuracy failed, trying low accuracy: $e2');
try {
// Final fallback to low accuracy
return await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.low,
timeLimit: const Duration(seconds: 5),
);
} catch (e3) {
print('All accuracy levels failed: $e3');
return null;
}
}
}
}
/// Get current position with best available accuracy
static Future<Position?> getBestAvailablePosition() async {
try {
@@ -181,7 +233,10 @@ class LocationService {
}
/// Get address from coordinates (placeholder - would need geocoding service)
static Future<String?> getAddressFromCoordinates(double lat, double lng) async {
static Future<String?> getAddressFromCoordinates(
double lat,
double lng,
) async {
// This is a placeholder implementation
// In a real app, you would use a geocoding service like Google Maps API
// or OpenStreetMap Nominatim API
@@ -198,4 +253,4 @@ class LocationService {
acc != null &&
acc >= 0;
}
}
}

View File

@@ -57,6 +57,8 @@ class StorageService {
description: report.notes ?? '',
imageBytes: imageBytes,
imageName: '${report.id}.jpg',
userName: report.submittedBy,
address: report.address,
);
return true;
}
@@ -85,32 +87,38 @@ class StorageService {
/// Delete a report from storage (API first, fallback to local)
static Future<bool> deleteReport(String reportId) async {
try {
// Try API first (note: API doesn't have delete endpoint, so this will always fallback)
final apiReport = await ApiService.getReportById(reportId);
if (apiReport != null) {
// For now, the API doesn't have a delete endpoint, so we can't delete from API
// This would need to be added to the backend
print('API delete not available, keeping local copy');
// Try API delete first
final apiDeleted = await ApiService.deleteTicket(reportId);
if (apiDeleted) {
// Clean up local copies if any
try {
final reports = await getReports();
final updatedReports = reports.where((r) => r.id != reportId).toList();
if (!kIsWeb) {
await _deletePhotoFile(reportId);
}
await _saveReportsList(updatedReports);
} catch (e) {
print('Error cleaning local copies after API delete: $e');
}
return true;
}
} catch (e) {
print('API not available: $e');
print('API delete failed or API not available: $e');
}
// Fallback to local storage
// Fallback to local storage deletion
try {
final reports = await getReports();
final updatedReports = reports.where((r) => r.id != reportId).toList();
// Delete photo file if it exists
if (kIsWeb) {
// On web, base64 is stored in memory, no file to delete
} else {
if (!kIsWeb) {
await _deletePhotoFile(reportId);
}
return await _saveReportsList(updatedReports);
} catch (e) {
print('Error deleting report: $e');
print('Error deleting report locally: $e');
return false;
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
/// Design tokens and themes for FixMate (Civic Pro Minimal)
/// Design tokens and themes for CityPulse (Civic Pro Minimal)
class AppColors {
// Primary civic colors
static const Color civicBlue = Color(0xFF2563EB);

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../models/report.dart';
import '../models/enums.dart' as enums;
import '../services/storage.dart';
import '../services/api_service.dart';
import '../l10n/i18n.dart';
class ReportCard extends StatelessWidget {
@@ -22,6 +23,30 @@ class ReportCard extends StatelessWidget {
});
Widget _buildThumbnail() {
final placeholder = Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.image, color: Colors.grey.shade600),
);
// Prefer backend-provided image URL when available
if (report.imageUrl != null && report.imageUrl!.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
report.imageUrl!,
width: 72,
height: 72,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => placeholder,
),
);
}
if (kIsWeb && report.base64Photo != null) {
try {
final bytes = base64Decode(report.base64Photo!);
@@ -32,21 +57,15 @@ class ReportCard extends StatelessWidget {
} catch (_) {}
} else if (report.photoPath != null) {
final file = File(report.photoPath!);
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(file, width: 72, height: 72, fit: BoxFit.cover),
);
if (file.existsSync()) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(file, width: 72, height: 72, fit: BoxFit.cover),
);
}
}
return Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.image, color: Colors.grey.shade600),
);
return placeholder;
}
String _formatTime(String iso) {
@@ -78,7 +97,19 @@ class ReportCard extends StatelessWidget {
);
if (ok == true) {
final success = await StorageService.deleteReport(report.id);
bool success = false;
try {
success = await ApiService.deleteTicket(report.id);
} catch (e) {
print('Error deleting via API: $e');
success = false;
}
// Fallback to local delete if API delete fails
if (!success) {
success = await StorageService.deleteReport(report.id);
}
if (success) {
if (onDeleted != null) {
onDeleted!();
@@ -88,6 +119,12 @@ class ReportCard extends StatelessWidget {
SnackBar(content: Text(I18n.t('toast.reportDeleted'))),
);
}
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(I18n.t('error.saving', {'0': 'Failed to delete report'}))),
);
}
}
}
}
@@ -236,7 +273,24 @@ class ReportCard extends StatelessWidget {
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 8),
// Submitted by (if available)
if (report.submittedBy != null) ...[
Row(
children: [
Icon(Icons.person, size: 14, color: cs.onSurface.withOpacity(0.6)),
const SizedBox(width: 6),
Expanded(
child: Text(
'Submitted by ${report.submittedBy}',
style: TextStyle(fontSize: 12, color: cs.onSurface.withOpacity(0.7)),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
],
// Status indicators
Row(
children: [
@@ -343,7 +397,9 @@ class ReportCard extends StatelessWidget {
const SizedBox(width: 4),
Expanded(
child: Text(
'${report.location.lat.toStringAsFixed(4)}, ${report.location.lng.toStringAsFixed(4)}',
report.address != null && report.address!.isNotEmpty
? report.address!
: '${report.location.lat.toStringAsFixed(4)}, ${report.location.lng.toStringAsFixed(4)}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withOpacity(0.6),
@@ -374,40 +430,36 @@ class ReportCard extends StatelessWidget {
}
},
itemBuilder: (_) => [
const PopupMenuItem(
PopupMenuItem(
value: 0,
child: Row(
children: [
Icon(Icons.visibility),
SizedBox(width: 8),
Text(
'View Details',
), // TODO: Move to i18n but need to handle dynamic text in popup menu
const Icon(Icons.visibility),
const SizedBox(width: 8),
Text(I18n.t('btn.viewDetails')),
],
),
),
const PopupMenuItem(
PopupMenuItem(
value: 1,
child: Row(
children: [
Icon(Icons.update),
SizedBox(width: 8),
Text(
'Update Status',
), // TODO: Move to i18n but need to handle dynamic text in popup menu
const Icon(Icons.update),
const SizedBox(width: 8),
Text(I18n.t('report.updateStatus')),
],
),
),
const PopupMenuItem(
PopupMenuItem(
value: 2,
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
const Icon(Icons.delete, color: Colors.red),
const SizedBox(width: 8),
Text(
'Delete',
style: TextStyle(color: Colors.red),
), // TODO: Move to i18n but need to handle dynamic text in popup menu
I18n.t('report.delete'),
style: const TextStyle(color: Colors.red),
),
],
),
),

View File

@@ -217,10 +217,10 @@ packages:
dependency: "direct main"
description:
name: geolocator
sha256: "149876cc5207a0f5daf4fdd3bfcf0a0f27258b3fe95108fa084f527ad0568f1b"
sha256: "6cb9fb6e5928b58b9a84bdf85012d757fd07aab8215c5205337021c4999bad27"
url: "https://pub.dev"
source: hosted
version: "12.0.0"
version: "11.1.0"
geolocator_android:
dependency: transitive
description:
@@ -249,10 +249,10 @@ packages:
dependency: transitive
description:
name: geolocator_web
sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
url: "https://pub.dev"
source: hosted
version: "4.1.3"
version: "3.0.0"
geolocator_windows:
dependency: transitive
description:
@@ -262,7 +262,7 @@ packages:
source: hosted
version: "0.2.5"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007

View File

@@ -1,5 +1,5 @@
name: citypulse
description: "FixMate - A citizen reporting app for community issues"
description: "CityPulse - A citizen reporting app for community issues"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -36,7 +36,7 @@ dependencies:
flutter_map: ^8.2.2
flutter_map_marker_cluster: ^8.2.2
latlong2: ^0.9.1
geolocator: ^12.0.0
geolocator: ^11.0.0
image_picker: ^1.1.2
path_provider: ^2.1.4
shared_preferences: ^2.3.2

View File

@@ -6,7 +6,7 @@ import 'package:citypulse/app.dart';
void main() {
testWidgets('App builds MaterialApp', (WidgetTester tester) async {
await tester.pumpWidget(const FixMateApp());
await tester.pumpWidget(const CityPulseApp());
expect(find.byType(MaterialApp), findsOneWidget);
});
}
}