Compare commits
20 Commits
cursor/int
...
b4079a25bb
| Author | SHA1 | Date | |
|---|---|---|---|
| b4079a25bb | |||
| dbf7be38f7 | |||
| f177f075bc | |||
| ec3c7320d7 | |||
|
|
2e30e0039e | ||
| c0c3fb7b5a | |||
| fd7d3a9635 | |||
| adffc18a2f | |||
| 77df67d8c0 | |||
| a8eae96f7f | |||
| ed63a0cbc8 | |||
| 6924455d35 | |||
| 52ac471d3e | |||
| 7cb7b68446 | |||
| 77d5be8fd1 | |||
| 0e3eea7de9 | |||
| 2a46ecb7d2 | |||
| 11ea469b6d | |||
| 5a311d7ad0 | |||
| 0123257ad1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
478
README.md
@@ -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. There’s 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) won’t 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/)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "FixMate Design Tokens",
|
||||
"name": "CityPulse Design Tokens",
|
||||
"version": "1.0.0",
|
||||
"brand": "Civic Premium – Citizen First"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ Perfect 👍 thanks for clarifying — let’s keep it **venv only**. I’ll 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.
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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
126
backend/app/utils.py
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@ Perfect 👍 Before I drop a full codebase, let’s 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, let’s 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):
|
||||
|
||||
|
||||
@@ -13,4 +13,5 @@ black
|
||||
isort
|
||||
huggingface_hub
|
||||
datasets
|
||||
transformers
|
||||
transformers
|
||||
pydantic[email]
|
||||
33
backend/scripts/add_address_column.py
Normal file
33
backend/scripts/add_address_column.py
Normal 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())
|
||||
BIN
backend/static/uploads/76bc117f-d4ea-417e-8956-209ecce6f234.jpg
Normal file
BIN
backend/static/uploads/76bc117f-d4ea-417e-8956-209ecce6f234.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
backend/static/uploads/c931000e-7c61-427b-b691-585d2b04204b.jpg
Normal file
BIN
backend/static/uploads/c931000e-7c61-427b-b691-585d2b04204b.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
backend/static/uploads/ce032d6b-119b-401e-a4b2-6fa3aee7c1cc.jpg
Normal file
BIN
backend/static/uploads/ce032d6b-119b-401e-a4b2-6fa3aee7c1cc.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
backend/static/uploads/e604f450-53b1-4400-8a3c-a22893208a7b.jpg
Normal file
BIN
backend/static/uploads/e604f450-53b1-4400-8a3c-a22893208a7b.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
backend/static/uploads/e81cc3bb-0e2d-49e0-97f7-958fa6239c6c.jpg
Normal file
BIN
backend/static/uploads/e81cc3bb-0e2d-49e0-97f7-958fa6239c6c.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
4
dashboard/.env
Normal file
4
dashboard/.env
Normal 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
130
dashboard/CHATBOT_README.md
Normal 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
343
dashboard/Chatbot.js
Normal 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
131
dashboard/README.md
Normal 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.
|
||||
351
dashboard/app.js
351
dashboard/app.js
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
16
dashboard/package.json
Normal 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"
|
||||
}
|
||||
48
dashboard/replace-env-vars.js
Normal file
48
dashboard/replace-env-vars.js
Normal 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');
|
||||
3
dashboard/requirements.txt
Normal file
3
dashboard/requirements.txt
Normal 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
47
dashboard/server.py
Normal 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)
|
||||
@@ -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
192
dashboard/test.html
Normal 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
3
devtools_options.yaml
Normal 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:
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
859
lib/screens/report_flow/location_picker_screen.dart
Normal file
859
lib/screens/report_flow/location_picker_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
308
lib/services/geocoding_service.dart
Normal file
308
lib/services/geocoding_service.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
10
pubspec.lock
10
pubspec.lock
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user