From 6518df8ac11b059bb8b4148bd94b82ba0fdd7a49 Mon Sep 17 00:00:00 2001 From: Zahar Date: Thu, 25 Sep 2025 18:38:18 +0800 Subject: [PATCH] feat: introduce FixMate Flutter app and React dashboard - Add Flutter app shell (FixMateApp/MainScreen) with tabs: Report, Map, My Reports, Settings - Implement capture and review flow (image_picker, geolocator, deterministic mock AI), and local storage (SharedPreferences + photo files on mobile) - Build Map screen with flutter_map, marker clustering, filters, legend, marker details, and external maps deeplink - Add My Reports list (view details, cycle status, delete) and Settings (language toggle via Provider, diagnostics, clear all data) - Introduce JSON i18n loader and LocaleProvider; add EN/BM assets - Define models (Report, enums) and UI badges (severity, status) - Add static React dashboard (Leaflet map with clustering, heatmap toggle, filters incl. date range, queue, detail drawer), i18n (EN/BM), and demo data - Update build/config and platform setup: - Extend pubspec with required packages and register i18n assets - Android: add CAMERA and location permissions; pin NDK version - iOS: add usage descriptions for camera, photo library, location - Gradle properties tuned for Windows/UNC stability - Register desktop plugins (Linux/macOS/Windows) - .gitignore: ignore .kilocode - Overhaul README and replace sample widget test --- .gitignore | 3 + README.md | 71 ++- android/app/build.gradle.kts | 2 +- android/app/src/main/AndroidManifest.xml | 7 +- android/gradle.properties | 18 + assets/lang/en.json | 82 +++ assets/lang/ms.json | 82 +++ dashboard/app.js | 397 ++++++++++++ dashboard/data/demo-reports.json | 18 + dashboard/i18n/en.json | 40 ++ dashboard/i18n/ms.json | 40 ++ dashboard/index.html | 32 + dashboard/styles.css | 170 ++++++ ios/Runner/Info.plist | 10 +- lib/app.dart | 111 ++++ lib/l10n/i18n.dart | 79 +++ lib/l10n/locale_provider.dart | 101 +++ lib/main.dart | 142 +---- lib/models/enums.dart | 206 +++++++ lib/models/report.dart | 300 +++++++++ lib/screens/map/map_screen.dart | 543 +++++++++++++++++ lib/screens/my_reports/my_reports_screen.dart | 85 +++ lib/screens/report_flow/capture_screen.dart | 166 +++++ lib/screens/report_flow/review_screen.dart | 289 +++++++++ lib/screens/settings/settings_screen.dart | 134 ++++ lib/services/location_service.dart | 202 ++++++ lib/services/mock_ai.dart | 116 ++++ lib/services/storage.dart | 231 +++++++ lib/widgets/report_card.dart | 136 +++++ lib/widgets/severity_badge.dart | 25 + lib/widgets/status_badge.dart | 45 ++ linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 10 + pubspec.lock | 575 +++++++++++++++++- pubspec.yaml | 19 +- test/widget_test.dart | 30 +- .../flutter/generated_plugin_registrant.cc | 9 + windows/flutter/generated_plugins.cmake | 3 + 39 files changed, 4377 insertions(+), 162 deletions(-) create mode 100644 assets/lang/en.json create mode 100644 assets/lang/ms.json create mode 100644 dashboard/app.js create mode 100644 dashboard/data/demo-reports.json create mode 100644 dashboard/i18n/en.json create mode 100644 dashboard/i18n/ms.json create mode 100644 dashboard/index.html create mode 100644 dashboard/styles.css create mode 100644 lib/app.dart create mode 100644 lib/l10n/i18n.dart create mode 100644 lib/l10n/locale_provider.dart create mode 100644 lib/models/enums.dart create mode 100644 lib/models/report.dart create mode 100644 lib/screens/map/map_screen.dart create mode 100644 lib/screens/my_reports/my_reports_screen.dart create mode 100644 lib/screens/report_flow/capture_screen.dart create mode 100644 lib/screens/report_flow/review_screen.dart create mode 100644 lib/screens/settings/settings_screen.dart create mode 100644 lib/services/location_service.dart create mode 100644 lib/services/mock_ai.dart create mode 100644 lib/services/storage.dart create mode 100644 lib/widgets/report_card.dart create mode 100644 lib/widgets/severity_badge.dart create mode 100644 lib/widgets/status_badge.dart diff --git a/.gitignore b/.gitignore index 79c113f..d1ffd1e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ migrate_working_dir/ # is commented out by default. #.vscode/ +# AI Memory Bank - keep private +.kilocode/ + # Flutter/Dart/Pub related **/doc/api/ **/ios/Flutter/.last_build_id diff --git a/README.md b/README.md index bbe5be8..4f586a3 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,67 @@ -# citypulse +# FixMate — Front-end (Flutter & React Dashboard) -A new Flutter project. +## Overview +- FixMate is a citizen maintenance reporter used to quickly capture and track community issues during a hackathon-style demo. +- This repository contains front-end only implementations: a Flutter mobile/web app and a static React dashboard. +- There is no backend. AI is simulated deterministically. Data is stored locally or loaded from demo JSON. -## Getting Started +## Features implemented +- Flutter app tabs: Report, Map, My Reports, Settings (bilingual EN/BM) +- Capture flow: camera/gallery, GPS, deterministic mock AI, local storage +- Map: OSM 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 -This project is a starting point for a Flutter application. +## Tech stack +- Flutter packages: flutter_map, flutter_map_marker_cluster, latlong2, geolocator, image_picker, path_provider, shared_preferences, uuid, url_launcher, provider +- Dashboard: React 18 UMD, Leaflet + markercluster (+ optional heat), Day.js -A few resources to get you started if this is your first Flutter project: +## Project structure +- Key Flutter files: + - [lib/app.dart](lib/app.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) +- 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) -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +## Running the Flutter app +- Prerequisites: Flutter stable installed and a device/emulator or Chrome for web. +- Commands: + - flutter pub get + - flutter run (or flutter run -d chrome) +- Notes: + - Android/iOS will prompt for camera and location permissions. + - On the web, geolocation and camera require HTTPS; some browsers restrict camera on http. + - Photos are stored as base64 on web; on mobile, images are saved to app storage and paths are persisted (see [lib/services/storage.dart](lib/services/storage.dart:1)). -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +## Running the React dashboard +- Serve the dashboard folder over HTTP (e.g., VSCode Live Server or Python): + - cd dashboard && python -m http.server 8000 +- Open http://127.0.0.1:8000 in a browser (or your Live Server URL). +- Behavior: + - Language toggle persists using localStorage. + - Filters drive the clustered Leaflet map, queue, drawer, stats, and optional heatmap overlay. + +## 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. + +## Visual tokens +- Severity colors: High #D32F2F, Medium #F57C00, Low #388E3C +- Status colors: Submitted #1976D2, In Progress #7B1FA2, Fixed #455A64 + +## License +- Placeholder: add a LICENSE file or specify licensing before distribution. + +## Acknowledgements +- OpenStreetMap, Leaflet, flutter_map and community plugins, React, Day.js, Flutter community. \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5f5a834..5480b81 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "com.example.citypulse" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e9da2be..6611a75 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,9 @@ + + + + + - + \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index f018a61..8182af0 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,21 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true + + +# Disable Gradle VFS watcher to avoid UNC path stat errors +org.gradle.vfs.watch=false + +# Mitigate Windows/UNC mixed root issues and Kotlin incremental cache failures +org.gradle.caching=false +kotlin.incremental=false +kotlin.incremental.useClasspathSnapshot=false +org.gradle.parallel=false + +# Force non-daemon, single worker, and in-process Kotlin compiler to avoid mixed-root cache issues on Windows/UNC +org.gradle.daemon=false +org.gradle.workers.max=1 +kotlin.compiler.execution.strategy=in-process + +# Ensure Kotlin incremental compilation is fully disabled for Android modules on Windows mixed roots +kotlin.incremental.android=false \ No newline at end of file diff --git a/assets/lang/en.json b/assets/lang/en.json new file mode 100644 index 0000000..2c6658f --- /dev/null +++ b/assets/lang/en.json @@ -0,0 +1,82 @@ +{ + "app.name": "FixMate", + "nav.report": "Report", + "nav.map": "Map", + "nav.myReports": "My Reports", + "nav.settings": "Settings", + "btn.capture": "Capture", + "btn.gallery": "Gallery", + "btn.camera": "Camera", + "btn.next": "Next", + "btn.submit": "Submit", + "btn.save": "Save", + "btn.cancel": "Cancel", + "btn.retake": "Retake", + "btn.delete": "Delete", + "btn.clearAll": "Clear All", + "btn.changeStatus": "Change Status", + "btn.view": "View", + "btn.details": "Details", + "btn.retry": "Retry", + "btn.allow": "Allow", + "btn.deny": "Deny", + "btn.confirm": "Confirm", + "btn.close": "Close", + "btn.openMap": "Open Map", + "btn.useSuggestion": "Use Suggestion", + "btn.keepManual": "Keep Manual", + "btn.apply": "Apply", + "btn.reset": "Reset", + "btn.filter": "Filter", + "btn.ok": "OK", + "btn.yes": "Yes", + "btn.no": "No", + "label.category": "Category", + "label.severity": "Severity", + "label.status": "Status", + "label.notes": "Notes", + "label.address": "Address", + "label.location": "Location", + "label.latitude": "Latitude", + "label.longitude": "Longitude", + "label.accuracy": "Accuracy", + "label.timestamp": "Timestamp", + "label.deviceId": "Device ID", + "label.source": "Source", + "label.aiSuggestion": "AI Suggestion", + "status.submitted": "Submitted", + "status.in_progress": "In Progress", + "status.fixed": "Fixed", + "severity.high": "High", + "severity.medium": "Medium", + "severity.low": "Low", + "category.pothole": "Pothole", + "category.streetlight": "Streetlight", + "category.signage": "Signage", + "category.trash": "Trash", + "category.drainage": "Drainage", + "category.other": "Other", + "filter.title": "Filters", + "filter.category": "Category", + "filter.severity": "Severity", + "filter.status": "Status", + "filter.dateRange": "Date Range", + "filter.showOnlyMine": "Show Only Mine", + "map.legend": "Map Legend", + "map.noReports": "No reports found", + "map.clustered": "Clustered", + "toast.reportSaved": "Report saved", + "toast.reportDeleted": "Report deleted", + "toast.storageCleared": "Storage cleared", + "confirm.deleteReport.title": "Delete report?", + "confirm.deleteReport.message": "This action cannot be undone.", + "confirm.clearData.title": "Clear all data?", + "confirm.clearData.message": "This will remove all local reports.", + "settings.language": "Language", + "settings.diagnostics": "Diagnostics", + "settings.theme": "Theme", + "settings.theme.light": "Light", + "settings.theme.dark": "Dark", + "lang.en": "English", + "lang.ms": "Bahasa Malaysia" +} \ No newline at end of file diff --git a/assets/lang/ms.json b/assets/lang/ms.json new file mode 100644 index 0000000..72c1bea --- /dev/null +++ b/assets/lang/ms.json @@ -0,0 +1,82 @@ +{ + "app.name": "FixMate", + "nav.report": "Lapor", + "nav.map": "Peta", + "nav.myReports": "Laporan Saya", + "nav.settings": "Tetapan", + "btn.capture": "Tangkap", + "btn.gallery": "Galeri", + "btn.camera": "Kamera", + "btn.next": "Seterusnya", + "btn.submit": "Hantar", + "btn.save": "Simpan", + "btn.cancel": "Batal", + "btn.retake": "Ambil Semula", + "btn.delete": "Padam", + "btn.clearAll": "Kosongkan Semua", + "btn.changeStatus": "Tukar Status", + "btn.view": "Lihat", + "btn.details": "Butiran", + "btn.retry": "Cuba Semula", + "btn.allow": "Benarkan", + "btn.deny": "Tolak", + "btn.confirm": "Sahkan", + "btn.close": "Tutup", + "btn.openMap": "Buka Peta", + "btn.useSuggestion": "Guna Cadangan", + "btn.keepManual": "Kekal Manual", + "btn.apply": "Guna", + "btn.reset": "Tetap Semula", + "btn.filter": "Tapis", + "btn.ok": "OK", + "btn.yes": "Ya", + "btn.no": "Tidak", + "label.category": "Kategori", + "label.severity": "Tahap", + "label.status": "Status", + "label.notes": "Nota", + "label.address": "Alamat", + "label.location": "Lokasi", + "label.latitude": "Latitud", + "label.longitude": "Longitud", + "label.accuracy": "Ketepatan", + "label.timestamp": "Masa", + "label.deviceId": "ID Peranti", + "label.source": "Sumber", + "label.aiSuggestion": "Cadangan AI", + "status.submitted": "Dihantar", + "status.in_progress": "Dalam Proses", + "status.fixed": "Selesai", + "severity.high": "Tinggi", + "severity.medium": "Sederhana", + "severity.low": "Rendah", + "category.pothole": "Lubang Jalan", + "category.streetlight": "Lampu Jalan", + "category.signage": "Papan Tanda", + "category.trash": "Sampah", + "category.drainage": "Perparitan", + "category.other": "Lain-lain", + "filter.title": "Penapis", + "filter.category": "Kategori", + "filter.severity": "Tahap", + "filter.status": "Status", + "filter.dateRange": "Julat Tarikh", + "filter.showOnlyMine": "Tunjuk Milik Saya", + "map.legend": "Petunjuk Peta", + "map.noReports": "Tiada laporan", + "map.clustered": "Berkelompok", + "toast.reportSaved": "Laporan disimpan", + "toast.reportDeleted": "Laporan dipadam", + "toast.storageCleared": "Storan dikosongkan", + "confirm.deleteReport.title": "Padam laporan?", + "confirm.deleteReport.message": "Tindakan ini tidak boleh dibuat asal.", + "confirm.clearData.title": "Kosongkan semua data?", + "confirm.clearData.message": "Ini akan memadam semua laporan tempatan.", + "settings.language": "Bahasa", + "settings.diagnostics": "Diagnostik", + "settings.theme": "Tema", + "settings.theme.light": "Terang", + "settings.theme.dark": "Gelap", + "lang.en": "English", + "lang.ms": "Bahasa Malaysia" +} \ No newline at end of file diff --git a/dashboard/app.js b/dashboard/app.js new file mode 100644 index 0000000..15cae0f --- /dev/null +++ b/dashboard/app.js @@ -0,0 +1,397 @@ + +const { useState, useEffect, useRef, useMemo } = React; +dayjs.extend(window.dayjs_plugin_relativeTime); + +const CATEGORY_LIST = ['pothole','streetlight','signage','trash','drainage','other']; +const SEVERITIES = ['high','medium','low']; +const STATUSES = ['submitted','in_progress','fixed']; + +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()); } + +function useI18n(initialLang='en'){ + const [lang,setLang] = useState(localStorage.getItem('lang') || initialLang); + const [map,setMap] = useState({en:null,ms:null}); + useEffect(()=>{ + Promise.all([fetchJSON('./i18n/en.json'), fetchJSON('./i18n/ms.json')]) + .then(([en,ms])=> setMap({en,ms})) + .catch(err=> console.error('i18n load',err)); + },[]); + const t = (key)=> (map[lang] && map[lang][key]) || (map['en'] && map['en'][key]) || key; + useEffect(()=> localStorage.setItem('lang',lang),[lang]); + return {lang,setLang,t,i18nMap:map}; +} + +function App(){ + const {lang,setLang,t,i18nMap} = useI18n(); + + const [rawData,setRawData] = useState([]); + const [loading,setLoading] = useState(true); + + const defaultFrom = dayjs().subtract(30,'day').format('YYYY-MM-DD'); + const defaultTo = dayjs().format('YYYY-MM-DD'); + + // form state + const [formCategories,setFormCategories] = useState(new Set(CATEGORY_LIST)); + const [formSeverities,setFormSeverities] = useState(new Set(SEVERITIES)); + const [formStatuses,setFormStatuses] = useState(new Set(STATUSES)); + const [formFrom,setFormFrom] = useState(defaultFrom); + const [formTo,setFormTo] = useState(defaultTo); + + // applied filters + const [appliedFilters,setAppliedFilters] = useState({ + categories:new Set(CATEGORY_LIST), + severities:new Set(SEVERITIES), + statuses:new Set(STATUSES), + from:defaultFrom, + to:defaultTo + }); + + const [filtered,setFiltered] = useState([]); + + const [selected,setSelected] = useState(null); + + const mapRef = useRef(null); + const markersRef = useRef(null); + const heatRef = useRef(null); + const mapContainerRef = useRef(null); + + const [heatEnabled,setHeatEnabled] = useState(false); + + useEffect(()=>{ + fetchJSON('./data/demo-reports.json').then(data=>{ + setRawData(data); + setLoading(false); + }).catch(err=>{ console.error(err); setLoading(false); }); + },[]); + + useEffect(()=>{ + // 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, + attribution:'© OpenStreetMap' + }).addTo(map); + mapRef.current = map; + markersRef.current = L.markerClusterGroup(); + map.addLayer(markersRef.current); + return ()=> { map.remove(); mapRef.current=null; markersRef.current=null; }; + },[]); + + // compute filtered when rawData or appliedFilters change + useEffect(()=>{ + if(!rawData) return; + const from = dayjs(appliedFilters.from).startOf('day'); + const to = dayjs(appliedFilters.to).endOf('day'); + const out = rawData.filter(r=>{ + if(!r || !r.createdAt) return false; + const created = dayjs(r.createdAt); + if(created.isBefore(from) || created.isAfter(to)) return false; + if(!appliedFilters.categories.has(r.category)) return false; + if(!appliedFilters.severities.has(r.severity)) return false; + if(!appliedFilters.statuses.has(r.status)) return false; + return true; + }); + setFiltered(out); + },[rawData, appliedFilters]); + + // update markers and heatmap when filtered changes + useEffect(()=>{ + const map = mapRef.current; + const markersLayer = markersRef.current; + if(!map || !markersLayer) return; + markersLayer.clearLayers(); + if(filtered.length === 0){ + // remove heat if present + if(heatRef.current){ heatRef.current.remove(); heatRef.current = null; } + map.setView([3.1390,101.6869],12); + const container = document.querySelector('.map-panel'); + if(container) container.classList.add('no-reports'); + return; + } else { + const container = document.querySelector('.map-panel'); + if(container) container.classList.remove('no-reports'); + } + + const bounds = []; + filtered.forEach(r=>{ + if(!r.location) return; + const lat = r.location.lat; + const lng = r.location.lng; + bounds.push([lat,lng]); + const color = SEVERITY_COLOR[r.severity] || '#333'; + const icon = L.divIcon({ + className: 'custom-marker', + html: `
`, + iconSize:[22,22], + iconAnchor:[11,11] + }); + const marker = L.marker([lat,lng], { icon }); + marker.on('click', ()=> { + setSelected(r); + }); + marker.bindPopup(`${r.category}
${r.notes || ''}`); + markersLayer.addLayer(marker); + }); + + try{ + const boundsObj = L.latLngBounds(bounds); + if(bounds.length === 1){ + map.setView(bounds[0],14); + } else { + map.fitBounds(boundsObj.pad(0.1)); + } + }catch(e){ + map.setView([3.1390,101.6869],12); + } + + // heatmap + if(heatEnabled){ + const heatPoints = filtered.map(r=> [r.location.lat, r.location.lng, 0.6]); + if(heatRef.current){ + heatRef.current.setLatLngs(heatPoints); + } else { + heatRef.current = L.heatLayer(heatPoints, {radius:25,blur:15,maxZoom:17}).addTo(map); + } + } else { + if(heatRef.current){ heatRef.current.remove(); heatRef.current = null; } + } + },[filtered, heatEnabled]); + + const applyFilters = ()=> { + setAppliedFilters({ + categories: new Set(formCategories), + severities: new Set(formSeverities), + statuses: new Set(formStatuses), + from: formFrom, + to: formTo + }); + }; + + const resetFilters = ()=> { + const cats = new Set(CATEGORY_LIST); + const sevs = new Set(SEVERITIES); + const stats = new Set(STATUSES); + setFormCategories(cats); + setFormSeverities(sevs); + setFormStatuses(stats); + setFormFrom(defaultFrom); + setFormTo(defaultTo); + setAppliedFilters({ + categories: cats, + severities: sevs, + statuses: stats, + from:defaultFrom, + to:defaultTo + }); + }; + + // helper toggle functions + const toggleSet = (setState, currentSet, val) => { + const s = new Set(currentSet); + if(s.has(val)) s.delete(val); else s.add(val); + setState(s); + }; + + // sorted queue + const sortedQueue = useMemo(()=>{ + const order = { high:0, medium:1, low:2 }; + return [...filtered].sort((a,b)=>{ + const sa = order[a.severity] ?? 3; + const sb = order[b.severity] ?? 3; + if(sa !== sb) return sa - sb; + return dayjs(b.createdAt).valueOf() - dayjs(a.createdAt).valueOf(); + }); + },[filtered]); + + const cycleStatus = (reportId)=>{ + 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 the currently selected item was updated, update the selected state too + if(selected && selected.id === reportId){ + const newSel = out.find(r=>r.id === reportId); + setSelected(newSel || null); + } + return out; + }); + }; + + const openInMaps = (r)=>{ + const lat = r.location.lat; + const lng = r.location.lng; + window.open(`https://www.google.com/maps/search/?api=1&query=${lat},${lng}`, '_blank'); + }; + + return ( +
+
+
{t('dashboard.brand') || 'FixMate'}
+
+ + +
+
+ +
+
+ + +
+
+
{t('map.noReports') || 'No reports match filters'}
+
+ + +
+ +
+
+
{t('stats.total') || 'Total'}: {filtered.length}
+
{filtered.filter(x=>x.severity==='high').length} {t('severity.high') || 'High'}
+
{filtered.filter(x=>x.severity==='medium').length} {t('severity.medium') || 'Medium'}
+
{filtered.filter(x=>x.severity==='low').length} {t('severity.low') || 'Low'}
+
+
+ +
+
+ + {/* Detail Drawer */} +
+ {selected ? ( +
+ +
+
{/* placeholder */}{t(`category.${selected.category}`) || selected.category}
+
+

{t(`category.${selected.category}`) || selected.category}

+
+ {t(`severity.${selected.severity}`) || selected.severity} + {t(`status.${selected.status}`) || selected.status} + {dayjs(selected.createdAt).fromNow()} +
+
+
+ +
+

{t('drawer.details') || 'Details'}

+ {selected.notes ?

{selected.notes}

:

{t('drawer.noNotes') || 'No additional notes'}

} +

{t('label.location') || 'Location'}: {selected.location.lat.toFixed(5)}, {selected.location.lng.toFixed(5)}

+

{t('label.createdAt') || 'Created'}: {dayjs(selected.createdAt).format('YYYY-MM-DD HH:mm')}

+
+ +
+ + +
+
+ ) : null} +
+ +
+ +
+ ); +} + +// mount +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(); \ No newline at end of file diff --git a/dashboard/data/demo-reports.json b/dashboard/data/demo-reports.json new file mode 100644 index 0000000..b6058bd --- /dev/null +++ b/dashboard/data/demo-reports.json @@ -0,0 +1,18 @@ +[ +{"id":"r-1","category":"pothole","severity":"high","status":"submitted","location":{"lat":3.1390,"lng":101.6869},"createdAt":"2025-09-20T02:10:00.000Z","updatedAt":"2025-09-20T02:10:00.000Z","notes":"Large pothole near Jalan Ampang intersection"}, +{"id":"r-2","category":"streetlight","severity":"medium","status":"in_progress","location":{"lat":3.1505,"lng":101.6932},"createdAt":"2025-09-22T10:15:00.000Z","updatedAt":"2025-09-23T09:00:00.000Z","notes":"Flickering streetlight on Jalan Sultan Ismail"}, +{"id":"r-3","category":"signage","severity":"low","status":"fixed","location":{"lat":3.1321,"lng":101.6800},"createdAt":"2025-09-10T08:30:00.000Z","updatedAt":"2025-09-12T11:00:00.000Z","notes":"Damaged signpost replaced"}, +{"id":"r-4","category":"trash","severity":"low","status":"submitted","location":{"lat":3.1405,"lng":101.7000},"createdAt":"2025-09-01T07:20:00.000Z","updatedAt":"2025-09-01T07:20:00.000Z","notes":"Illegal dumping behind shoplot"}, +{"id":"r-5","category":"drainage","severity":"medium","status":"in_progress","location":{"lat":3.1250,"lng":101.6950},"createdAt":"2025-08-20T09:45:00.000Z","updatedAt":"2025-09-02T10:00:00.000Z","notes":"Clogged drain after heavy rain"}, +{"id":"r-6","category":"other","severity":"medium","status":"fixed","location":{"lat":3.1600,"lng":101.6820},"createdAt":"2025-09-18T14:05:00.000Z","updatedAt":"2025-09-19T08:00:00.000Z","notes":"Broken bench repaired"}, +{"id":"r-7","category":"pothole","severity":"high","status":"in_progress","location":{"lat":3.1395,"lng":101.6900},"createdAt":"2025-09-24T16:40:00.000Z","updatedAt":"2025-09-24T17:00:00.000Z","notes":"Pothole expanding near bus stop"}, +{"id":"r-8","category":"streetlight","severity":"low","status":"submitted","location":{"lat":3.1450,"lng":101.6785},"createdAt":"2025-09-05T21:10:00.000Z","updatedAt":"2025-09-05T21:10:00.000Z","notes":"Light out since last week"}, +{"id":"r-9","category":"signage","severity":"medium","status":"submitted","location":{"lat":3.1422,"lng":101.6891},"createdAt":"2025-09-12T11:30:00.000Z","updatedAt":"2025-09-12T11:30:00.000Z","notes":"Missing directional sign"}, +{"id":"r-10","category":"trash","severity":"high","status":"fixed","location":{"lat":3.1378,"lng":101.6835},"createdAt":"2025-09-02T05:00:00.000Z","updatedAt":"2025-09-04T09:00:00.000Z","notes":"Overflowing dumpster - cleared"}, +{"id":"r-11","category":"drainage","severity":"low","status":"submitted","location":{"lat":3.1389,"lng":101.6888},"createdAt":"2025-09-21T12:00:00.000Z","updatedAt":"2025-09-21T12:00:00.000Z","notes":"Slow water flow in gutter"}, +{"id":"r-12","category":"other","severity":"high","status":"submitted","location":{"lat":3.1285,"lng":101.6812},"createdAt":"2025-08-28T18:20:00.000Z","updatedAt":"2025-08-28T18:20:00.000Z","notes":"Collapsed temporary structure on sidewalk"}, +{"id":"r-13","category":"pothole","severity":"medium","status":"fixed","location":{"lat":3.1200,"lng":101.6700},"createdAt":"2025-08-10T10:00:00.000Z","updatedAt":"2025-08-15T08:00:00.000Z","notes":"Repaired earlier this month"}, +{"id":"r-14","category":"streetlight","severity":"medium","status":"in_progress","location":{"lat":3.1523,"lng":101.6920},"createdAt":"2025-09-23T19:00:00.000Z","updatedAt":"2025-09-24T09:30:00.000Z","notes":"Pole leaning, inspection scheduled"}, +{"id":"r-15","category":"signage","severity":"low","status":"submitted","location":{"lat":3.1399,"lng":101.6870},"createdAt":"2025-09-25T02:00:00.000Z","updatedAt":"2025-09-25T02:00:00.000Z","notes":"Graffiti on traffic sign"}, +{"id":"r-16","category":"trash","severity":"medium","status":"in_progress","location":{"lat":3.1412,"lng":101.6902},"createdAt":"2025-09-15T13:25:00.000Z","updatedAt":"2025-09-16T08:00:00.000Z","notes":"Community clean-up in progress"} +] \ No newline at end of file diff --git a/dashboard/i18n/en.json b/dashboard/i18n/en.json new file mode 100644 index 0000000..85e7da1 --- /dev/null +++ b/dashboard/i18n/en.json @@ -0,0 +1,40 @@ +{ + "dashboard.brand": "FixMate", + "dashboard.filters": "Filters", + "queue.title": "Tickets", + "drawer.details": "Details", + "drawer.changeStatus": "Change Status", + "drawer.openMap": "Open Map", + "drawer.noNotes": "No additional notes", + "btn.apply": "Apply", + "btn.reset": "Reset", + "btn.view": "View", + "label.language": "Language", + "label.location": "Location", + "label.createdAt": "Created At", + "filter.category": "Category", + "filter.severity": "Severity", + "filter.status": "Status", + "filter.dateRange": "Date Range", + "filter.dateFrom": "From", + "filter.dateTo": "To", + "map.noReports": "No reports match filters", + "stats.total": "Total", + "stats.heatmap": "Heatmap", + "severity.high": "High", + "severity.medium": "Medium", + "severity.low": "Low", + "status.submitted": "Submitted", + "status.in_progress": "In Progress", + "status.fixed": "Fixed", + "category.pothole": "Pothole", + "category.streetlight": "Streetlight", + "category.signage": "Signage", + "category.trash": "Trash", + "category.drainage": "Drainage", + "category.other": "Other", + "nav.map": "Map", + "nav.settings": "Settings", + "label.viewOnMap": "View on Map", + "map.legend": "Legend" +} \ No newline at end of file diff --git a/dashboard/i18n/ms.json b/dashboard/i18n/ms.json new file mode 100644 index 0000000..3e521d8 --- /dev/null +++ b/dashboard/i18n/ms.json @@ -0,0 +1,40 @@ +{ + "dashboard.brand": "FixMate", + "dashboard.filters": "Penapis", + "queue.title": "Tiket", + "drawer.details": "Maklumat", + "drawer.changeStatus": "Tukar Status", + "drawer.openMap": "Buka Peta", + "drawer.noNotes": "Tiada nota tambahan", + "btn.apply": "Terapkan", + "btn.reset": "Tetapkan Semula", + "btn.view": "Lihat", + "label.language": "Bahasa", + "label.location": "Lokasi", + "label.createdAt": "Dicipta Pada", + "filter.category": "Kategori", + "filter.severity": "Keparahan", + "filter.status": "Status", + "filter.dateRange": "Julat Tarikh", + "filter.dateFrom": "Dari", + "filter.dateTo": "Hingga", + "map.noReports": "Tiada laporan sepadan dengan penapis", + "stats.total": "Jumlah", + "stats.heatmap": "Peta Panas", + "severity.high": "Tinggi", + "severity.medium": "Sederhana", + "severity.low": "Rendah", + "status.submitted": "Dihantar", + "status.in_progress": "Sedang Diproses", + "status.fixed": "Dibaiki", + "category.pothole": "Lubang Jalan", + "category.streetlight": "Lampu Jalan", + "category.signage": "Papan Tanda", + "category.trash": "Sampah", + "category.drainage": "Saliran", + "category.other": "Lain-lain", + "nav.map": "Peta", + "nav.settings": "Tetapan", + "label.viewOnMap": "Lihat di Peta", + "map.legend": "Legenda" +} \ No newline at end of file diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..4e26650 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,32 @@ + + + + + + FixMate Dashboard + + + + + + + +
+ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dashboard/styles.css b/dashboard/styles.css new file mode 100644 index 0000000..9b8673b --- /dev/null +++ b/dashboard/styles.css @@ -0,0 +1,170 @@ +:root{ + --bg:#f8fafc; + --panel:#ffffff; + --muted:#6b7280; + --accent:#0ea5a4; + --severity-high:#D32F2F; + --severity-medium:#F57C00; + --severity-low:#388E3C; + --status-submitted:#1976D2; + --status-in_progress:#7B1FA2; + --status-fixed:#455A64; + --shadow: 0 6px 18px rgba(15,23,42,0.08); + --surface-contrast: #111827; +} + +*{box-sizing:border-box} +html,body,#root{height:100%} +body{ + margin:0; + font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; + background:var(--bg); + color:var(--surface-contrast); + -webkit-font-smoothing:antialiased; + -moz-osx-font-smoothing:grayscale; + font-size:14px; +} + +.header{ + height:56px; + display:flex; + align-items:center; + justify-content:space-between; + padding:0 16px; + background:var(--panel); + border-bottom:1px solid #e6eef3; + box-shadow: none; + 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} + +.app-root{height:100vh;display:flex;flex-direction:column} +.container{ + height:calc(100vh - 56px); + display:flex; + flex-direction:column; + gap:8px; + padding:12px; +} + +/* main area */ +.main{ + display:grid; + grid-template-columns:300px 1fr 340px; + gap:12px; + align-items:stretch; + flex:1; +} + +/* panels */ +.panel{ + background:var(--panel); + border-radius:8px; + box-shadow:var(--shadow); + padding:12px; + display:flex; + flex-direction:column; + min-height:0; +} + +.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} + +/* chips/buttons */ +.btn{ + background:var(--accent); + color:white; + border:none; + padding:8px 12px; + border-radius:6px; + cursor:pointer; + font-weight:600; +} +.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} +.chip.severity-high{background:var(--severity-high)} +.chip.severity-medium{background:var(--severity-medium)} +.chip.severity-low{background:var(--severity-low)} +.chip.status-submitted{background:var(--status-submitted)} +.chip.status-in_progress{background:var(--status-in_progress)} +.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)} + +/* map panel */ +.map-panel{position:relative;min-height:0;height:100%;padding:0;overflow:hidden} +#map{width:100%;height:100%} +.map-panel .map-empty{ + display:none; + position:absolute; + left:0;right:0;top:0;bottom:0; + align-items:center;justify-content:center; + font-size:18px;color:#374151;background:rgba(255,255,255,0.85); + z-index:800; +} +.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} + +/* drawer */ +.drawer{ + position:fixed; + top:56px; + right:0; + bottom:0; + width:380px; + transform:translateX(100%); + transition:transform .28s ease; + z-index:1200; + display:flex; + align-items:flex-start; + pointer-events:none; +} +.drawer.open{transform:translateX(0);pointer-events:auto} +.drawer-content{ + width:100%; + height:100%; + background:var(--panel); + box-shadow:-12px 0 30px rgba(2,6,23,0.12); + padding:16px; + overflow:auto; +} +.drawer-close{position:absolute;right:12px;top:8px;background:transparent;border:none;font-size:20px;cursor:pointer} +.drawer-header{display:flex;align-items:center} +.drawer-thumb.large{width:84px;height:84px;border-radius:8px;background:#f3f4f6;display:flex;align-items:center;justify-content:center;font-weight:700} +.drawer-body{margin-top:12px;color:#111827} +.drawer-actions{display:flex;gap:8px;margin-top:16px} + +/* marker custom */ +.leaflet-container .custom-marker{display:flex;align-items:center;justify-content:center} + +/* small screens */ +@media (max-width:900px){ + .main{grid-template-columns:1fr;grid-auto-rows:auto} + .drawer{top:56px;width:100%} + .drawer.open{transform:none} + .header{padding:8px 12px} + .filters{order:2} + .map-panel{order:1} + .panel{padding:10px} +} + +/* accessibility tweaks */ +.chip, .btn{font-family:inherit} \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6a06917..2429f01 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -45,5 +45,13 @@ UIApplicationSupportsIndirectInputEvents + + + NSCameraUsageDescription + Camera access is required to capture issue photos. + NSPhotoLibraryUsageDescription + Photo library access allows selecting existing photos. + NSLocationWhenInUseUsageDescription + Location is used to attach a GPS position to your report. - + \ No newline at end of file diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..29b4fc0 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'l10n/i18n.dart'; +import 'l10n/locale_provider.dart'; +import 'screens/report_flow/capture_screen.dart'; +import 'screens/map/map_screen.dart'; +import 'screens/my_reports/my_reports_screen.dart'; +import 'screens/settings/settings_screen.dart'; + +class FixMateApp extends StatelessWidget { + const FixMateApp({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, localeProvider, child) { + return MaterialApp( + title: I18n.t('app.name'), + theme: ThemeData( + primarySwatch: Colors.blue, + brightness: Brightness.light, + useMaterial3: true, + ), + locale: localeProvider.locale, + supportedLocales: const [ + Locale('en', 'US'), + Locale('ms', 'MY'), + ], + home: const MainScreen(), + ); + }, + ); + } +} + +class MainScreen extends StatefulWidget { + const MainScreen({super.key}); + + @override + State createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + int _selectedIndex = 0; + + final List _screens = [ + const CaptureScreen(), + const MapScreen(), + const MyReportsScreen(), + const SettingsScreen(), + ]; + + final List _navLabels = [ + 'nav.report', + 'nav.map', + 'nav.myReports', + 'nav.settings', + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: _screens[_selectedIndex], + bottomNavigationBar: BottomNavigationBar( + currentIndex: _selectedIndex, + onTap: (index) { + setState(() { + _selectedIndex = index; + }); + }, + type: BottomNavigationBarType.fixed, + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.camera_alt), + label: I18n.t(_navLabels[0]), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.map), + label: I18n.t(_navLabels[1]), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.list), + label: I18n.t(_navLabels[2]), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.settings), + label: I18n.t(_navLabels[3]), + ), + ], + ), + ); + } +} + +class PlaceholderScreen extends StatelessWidget { + final String title; + + const PlaceholderScreen({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Center( + child: Text('$title - Coming Soon!'), + ), + ); + } +} \ No newline at end of file diff --git a/lib/l10n/i18n.dart b/lib/l10n/i18n.dart new file mode 100644 index 0000000..39444a2 --- /dev/null +++ b/lib/l10n/i18n.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; + +/// Simple JSON-based internationalization service +class I18n { + static Map _localizedStrings = {}; + static const String _defaultLocale = 'en'; + static String _loadedLocale = _defaultLocale; + + /// Initialize the i18n system with the given locale + static Future init(Locale locale) async { + _loadedLocale = locale.languageCode; + await _loadLanguage(locale.languageCode); + } + + /// Load language strings from JSON asset + static Future _loadLanguage(String languageCode) async { + try { + final String jsonString = await rootBundle.loadString( + 'assets/lang/$languageCode.json', + ); + final Map jsonMap = json.decode(jsonString); + + _localizedStrings = jsonMap.map((key, value) { + return MapEntry(key, value.toString()); + }); + } catch (e) { + // Fallback to default locale if current locale fails + // ignore: avoid_print + print('Error loading language file for $languageCode: $e'); + if (languageCode != _defaultLocale) { + _loadedLocale = _defaultLocale; + await _loadLanguage(_defaultLocale); + } + } + } + + /// Get translated string for the given key + static String t(String key, [Map? args]) { + String? translation = _localizedStrings[key]; + + if (translation == null) { + // Fallback to key itself if translation not found + // ignore: avoid_print + print('Translation key not found: $key'); + return key; + } + + // Replace placeholders if arguments provided + if (args != null && args.isNotEmpty) { + args.forEach((placeholder, value) { + translation = translation!.replaceAll('{$placeholder}', value); + }); + } + + return translation!; + } + + /// Get the current locale code + static String get currentLocale => _loadedLocale; + + /// Check if a translation key exists + static bool hasKey(String key) { + return _localizedStrings.containsKey(key); + } + + /// Get all available translation keys + static Set get keys => _localizedStrings.keys.toSet(); + + /// Get the number of loaded translations + static int get translationCount => _localizedStrings.length; + + /// Clear loaded translations (useful for testing) + static void clear() { + _localizedStrings.clear(); + _loadedLocale = _defaultLocale; + } +} \ No newline at end of file diff --git a/lib/l10n/locale_provider.dart b/lib/l10n/locale_provider.dart new file mode 100644 index 0000000..09888a9 --- /dev/null +++ b/lib/l10n/locale_provider.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Provider for managing app locale and language switching +class LocaleProvider extends ChangeNotifier { + static const String _languageKey = 'lang'; + static const String _defaultLanguage = 'en'; + + Locale _locale = const Locale('en'); + late SharedPreferences _prefs; + + /// Get the current locale + Locale get locale => _locale; + + /// Get the current language code + String get languageCode => _locale.languageCode; + + /// Initialize the locale provider + Future init() async { + _prefs = await SharedPreferences.getInstance(); + final savedLanguage = _prefs.getString(_languageKey) ?? _defaultLanguage; + _locale = Locale(savedLanguage); + notifyListeners(); + } + + /// Set the locale and persist the change + Future setLocale(Locale locale) async { + if (_locale == locale) return; + + _locale = locale; + await _prefs.setString(_languageKey, locale.languageCode); + notifyListeners(); + } + + /// Toggle between English and Bahasa Malaysia + Future toggleLanguage() async { + final newLanguage = _locale.languageCode == 'en' ? 'ms' : 'en'; + await setLocale(Locale(newLanguage)); + } + + /// Set language to English + Future setEnglish() async { + await setLocale(const Locale('en')); + } + + /// Set language to Bahasa Malaysia + Future setMalay() async { + await setLocale(const Locale('ms')); + } + + /// Check if current language is English + bool get isEnglish => _locale.languageCode == 'en'; + + /// Check if current language is Bahasa Malaysia + bool get isMalay => _locale.languageCode == 'ms'; + + /// Get the display name for the current language + String get currentLanguageDisplayName { + switch (_locale.languageCode) { + case 'en': + return 'English'; + case 'ms': + return 'Bahasa Malaysia'; + default: + return 'English'; + } + } + + /// Get the native display name for the current language + String get currentLanguageNativeName { + switch (_locale.languageCode) { + case 'en': + return 'English'; + case 'ms': + return 'Bahasa Malaysia'; + default: + return 'English'; + } + } + + /// Get available locales + List get availableLocales => const [ + Locale('en'), + Locale('ms'), + ]; + + /// Get available language codes + List get availableLanguageCodes => ['en', 'ms']; + + /// Clear saved language preference + Future clearSavedLanguage() async { + await _prefs.remove(_languageKey); + _locale = const Locale(_defaultLanguage); + notifyListeners(); + } + + /// Reset to default language + Future resetToDefault() async { + await setLocale(const Locale(_defaultLanguage)); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 7b7f5b6..2b74cdb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,122 +1,24 @@ import 'package:flutter/material.dart'; - -void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. - ); - } +import 'package:provider/provider.dart'; +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(), + ), + ); } diff --git a/lib/models/enums.dart b/lib/models/enums.dart new file mode 100644 index 0000000..3052542 --- /dev/null +++ b/lib/models/enums.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; + +/// Categories for different types of issues that can be reported +enum Category { + pothole, + streetlight, + signage, + trash, + drainage, + other; + + /// Get the string key for localization + String get key { + switch (this) { + case Category.pothole: + return 'category.pothole'; + case Category.streetlight: + return 'category.streetlight'; + case Category.signage: + return 'category.signage'; + case Category.trash: + return 'category.trash'; + case Category.drainage: + return 'category.drainage'; + case Category.other: + return 'category.other'; + } + } + + /// Get the display name for this category + String get displayName { + switch (this) { + case Category.pothole: + return 'Pothole'; + case Category.streetlight: + return 'Streetlight'; + case Category.signage: + return 'Signage'; + case Category.trash: + return 'Trash'; + case Category.drainage: + return 'Drainage'; + case Category.other: + return 'Other'; + } + } + + /// Get all categories as a list + static List get all => Category.values; +} + +/// Severity levels for reported issues +enum Severity { + high, + medium, + low; + + /// Get the string key for localization + String get key { + switch (this) { + case Severity.high: + return 'severity.high'; + case Severity.medium: + return 'severity.medium'; + case Severity.low: + return 'severity.low'; + } + } + + /// Get the display name for this severity + String get displayName { + switch (this) { + case Severity.high: + return 'High'; + case Severity.medium: + return 'Medium'; + case Severity.low: + return 'Low'; + } + } + + /// Get the color associated with this severity + Color get color { + switch (this) { + case Severity.high: + return const Color(0xFFD32F2F); // Red 700 + case Severity.medium: + return const Color(0xFFF57C00); // Orange 700 + case Severity.low: + return const Color(0xFF388E3C); // Green 700 + } + } + + /// Get all severities as a list + static List get all => Severity.values; +} + +/// Status of reported issues +enum Status { + submitted, + inProgress, + fixed; + + /// Get the string key for localization + String get key { + switch (this) { + case Status.submitted: + return 'status.submitted'; + case Status.inProgress: + return 'status.in_progress'; + case Status.fixed: + return 'status.fixed'; + } + } + + /// Get the display name for this status + String get displayName { + switch (this) { + case Status.submitted: + return 'Submitted'; + case Status.inProgress: + return 'In Progress'; + case Status.fixed: + return 'Fixed'; + } + } + + /// Get the color associated with this status + Color get color { + switch (this) { + case Status.submitted: + return const Color(0xFF1976D2); // Blue 700 + case Status.inProgress: + return const Color(0xFF7B1FA2); // Purple 700 + case Status.fixed: + return const Color(0xFF455A64); // Blue Grey 700 + } + } + + /// Get the next status in the cycle + Status get next { + switch (this) { + case Status.submitted: + return Status.inProgress; + case Status.inProgress: + return Status.fixed; + case Status.fixed: + return Status.submitted; // Cycle back to submitted + } + } + + /// Get all statuses as a list + static List get all => Status.values; +} + +/// Helper extensions for enum parsing +extension CategoryParsing on String { + Category? toCategory() { + switch (toLowerCase()) { + case 'pothole': + return Category.pothole; + case 'streetlight': + return Category.streetlight; + case 'signage': + return Category.signage; + case 'trash': + return Category.trash; + case 'drainage': + return Category.drainage; + case 'other': + return Category.other; + default: + return null; + } + } +} + +extension SeverityParsing on String { + Severity? toSeverity() { + switch (toLowerCase()) { + case 'high': + return Severity.high; + case 'medium': + return Severity.medium; + case 'low': + return Severity.low; + default: + return null; + } + } +} + +extension StatusParsing on String { + Status? toStatus() { + switch (toLowerCase()) { + case 'submitted': + return Status.submitted; + case 'in_progress': + return Status.inProgress; + case 'fixed': + return Status.fixed; + default: + return null; + } + } +} \ No newline at end of file diff --git a/lib/models/report.dart b/lib/models/report.dart new file mode 100644 index 0000000..923c753 --- /dev/null +++ b/lib/models/report.dart @@ -0,0 +1,300 @@ +import 'dart:math'; +import 'enums.dart'; + +/// Represents a citizen report for community issues +class Report { + /// Unique identifier for the report + final String id; + + /// Category of the issue + final Category category; + + /// Severity level of the issue + final Severity severity; + + /// Current status of the report + final Status status; + + /// File path to the photo on mobile devices + final String? photoPath; + + /// Base64 encoded photo for web platform + final String? base64Photo; + + /// Geographic location where the issue was reported + final LocationData location; + + /// When the report was created (ISO string) + final String createdAt; + + /// When the report was last updated (ISO string) + final String updatedAt; + + /// Unique device identifier + final String deviceId; + + /// Optional notes from the user + final String? notes; + + /// Address or location description (placeholder for future use) + final String? address; + + /// Source of the photo ("camera" or "gallery") + final String source; + + /// Whether the report can be edited + final bool editable; + + /// Whether the report can be deleted + final bool deletable; + + /// AI suggestion for category and severity + final AISuggestion aiSuggestion; + + /// Schema version for data migration + final int schemaVersion; + + const Report({ + required this.id, + required this.category, + required this.severity, + required this.status, + this.photoPath, + this.base64Photo, + required this.location, + required this.createdAt, + required this.updatedAt, + required this.deviceId, + this.notes, + this.address, + required this.source, + this.editable = true, + this.deletable = true, + required this.aiSuggestion, + this.schemaVersion = 1, + }); + + /// Generate a simple unique ID + static String _generateId() { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final random = Random().nextInt(999999); + return '$timestamp$random'; + } + + /// Create a new report with current timestamp and generated ID + factory Report.create({ + required Category category, + required Severity severity, + required LocationData location, + String? photoPath, + String? base64Photo, + String? notes, + required String source, + required String deviceId, + required AISuggestion aiSuggestion, + }) { + final now = DateTime.now().toIso8601String(); + return Report( + id: _generateId(), + category: category, + severity: severity, + status: Status.submitted, + photoPath: photoPath, + base64Photo: base64Photo, + location: location, + createdAt: now, + updatedAt: now, + deviceId: deviceId, + notes: notes, + source: source, + aiSuggestion: aiSuggestion, + ); + } + + /// Create a copy of this report with updated fields + Report copyWith({ + Category? category, + Severity? severity, + Status? status, + String? photoPath, + String? base64Photo, + LocationData? location, + String? updatedAt, + String? notes, + String? address, + bool? editable, + bool? deletable, + AISuggestion? aiSuggestion, + }) { + return Report( + id: id, + category: category ?? this.category, + severity: severity ?? this.severity, + status: status ?? this.status, + photoPath: photoPath ?? this.photoPath, + base64Photo: base64Photo ?? this.base64Photo, + location: location ?? this.location, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deviceId: deviceId, + notes: notes ?? this.notes, + address: address ?? this.address, + source: source, + editable: editable ?? this.editable, + deletable: deletable ?? this.deletable, + aiSuggestion: aiSuggestion ?? this.aiSuggestion, + schemaVersion: schemaVersion, + ); + } + + /// Convert to JSON for storage + Map toJson() { + return { + 'id': id, + 'category': category.name, + 'severity': severity.name, + 'status': status.key, + 'photoPath': photoPath, + 'base64Photo': base64Photo, + 'location': { + 'lat': location.lat, + 'lng': location.lng, + 'accuracy': location.accuracy, + }, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + 'deviceId': deviceId, + 'notes': notes, + 'address': address, + 'source': source, + 'editable': editable, + 'deletable': deletable, + 'aiSuggestion': { + 'category': aiSuggestion.category.name, + 'severity': aiSuggestion.severity.name, + 'confidence': aiSuggestion.confidence, + }, + 'schemaVersion': schemaVersion, + }; + } + + /// Create from JSON for loading from storage + factory Report.fromJson(Map json) { + return Report( + id: json['id'] as String, + category: (json['category'] as String).toCategory() ?? Category.other, + severity: (json['severity'] as String).toSeverity() ?? Severity.medium, + status: (json['status'] as String).toStatus() ?? Status.submitted, + photoPath: json['photoPath'] as String?, + base64Photo: json['base64Photo'] as String?, + location: LocationData( + lat: (json['location']['lat'] as num).toDouble(), + lng: (json['location']['lng'] as num).toDouble(), + accuracy: json['location']['accuracy'] == null + ? null + : (json['location']['accuracy'] as num).toDouble(), + ), + createdAt: json['createdAt'] as String, + updatedAt: json['updatedAt'] as String, + deviceId: json['deviceId'] as String, + notes: json['notes'] as String?, + address: json['address'] as String?, + source: json['source'] as String, + editable: json['editable'] as bool? ?? true, + deletable: json['deletable'] as bool? ?? true, + aiSuggestion: AISuggestion( + category: (json['aiSuggestion']['category'] as String).toCategory() ?? Category.other, + severity: (json['aiSuggestion']['severity'] as String).toSeverity() ?? Severity.medium, + confidence: (json['aiSuggestion']['confidence'] as num).toDouble(), + ), + schemaVersion: json['schemaVersion'] as int? ?? 1, + ); + } + + @override + String toString() { + return 'Report(id: $id, category: ${category.name}, severity: ${severity.name}, status: ${status.name})'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Report && other.id == id; + } + + @override + int get hashCode => id.hashCode; +} + +/// Represents geographic location data +class LocationData { + /// Latitude coordinate + final double lat; + + /// Longitude coordinate + final double lng; + + /// Accuracy of the location in meters (optional) + final double? accuracy; + + const LocationData({ + required this.lat, + required this.lng, + this.accuracy, + }); + + @override + String toString() { + return 'LocationData(lat: $lat, lng: $lng, accuracy: $accuracy)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is LocationData && + other.lat == lat && + other.lng == lng && + other.accuracy == accuracy; + } + + @override + int get hashCode => Object.hash(lat, lng, accuracy); +} + +/// Represents AI suggestion for category and severity +class AISuggestion { + /// Suggested category + final Category category; + + /// Suggested severity + final Severity severity; + + /// Confidence score between 0.0 and 1.0 + final double confidence; + + const AISuggestion({ + required this.category, + required this.severity, + required this.confidence, + }); + + /// Check if confidence is high enough to be considered reliable + bool get isReliable => confidence >= 0.7; + + @override + String toString() { + return 'AISuggestion(category: ${category.name}, severity: ${severity.name}, confidence: ${confidence.toStringAsFixed(2)})'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AISuggestion && + other.category == category && + other.severity == severity && + other.confidence == confidence; + } + + @override + int get hashCode => Object.hash(category, severity, confidence); +} \ No newline at end of file diff --git a/lib/screens/map/map_screen.dart b/lib/screens/map/map_screen.dart new file mode 100644 index 0000000..622873c --- /dev/null +++ b/lib/screens/map/map_screen.dart @@ -0,0 +1,543 @@ +import 'dart:convert'; +import 'dart:io' show File; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_marker_cluster/flutter_map_marker_cluster.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../l10n/i18n.dart'; +import '../../models/enums.dart'; +import '../../models/report.dart'; +import '../../services/location_service.dart'; +import '../../services/storage.dart'; +import '../../widgets/severity_badge.dart'; +import '../../widgets/status_badge.dart'; +import '../my_reports/my_reports_screen.dart'; + +/// MapScreen - displays reports on an interactive OpenStreetMap with clustering +class MapScreen extends StatefulWidget { + const MapScreen({super.key}); + + @override + State createState() => _MapScreenState(); +} + +class _MapScreenState extends State { + final MapController _mapController = MapController(); + + List _allReports = []; + List _filteredReports = []; + bool _loading = true; + + // In-memory filters + Set _filterCategories = Category.all.toSet(); + Set _filterSeverities = Severity.all.toSet(); + Set _filterStatuses = Status.all.toSet(); + DateTimeRange? _filterDateRange; + + // Defaults + static final LatLng _defaultCenter = LatLng(3.1390, 101.6869); // Kuala Lumpur + static const double _defaultZoom = 13.0; + + @override + void initState() { + super.initState(); + _setDefaultDateRange(); + _refresh(); + } + + void _setDefaultDateRange() { + final now = DateTime.now(); + _filterDateRange = DateTimeRange(start: now.subtract(const Duration(days: 30)), end: now); + } + + Future _refresh() async { + setState(() => _loading = true); + final reports = await StorageService.getReports(); + setState(() { + _allReports = reports; + _loading = false; + }); + _applyFilters(); + // If we have filtered reports, fit; otherwise try device location + if (_filteredReports.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) => _fitToBounds()); + } else { + await _centerOnDeviceOrDefault(); + } + } + + Future _centerOnDeviceOrDefault() async { + try { + final pos = await LocationService.getBestAvailablePosition(); + if (pos != null) { + _mapController.move(LatLng(pos.latitude, pos.longitude), _defaultZoom); + return; + } + } catch (_) {} + // fallback + _mapController.move(_defaultCenter, _defaultZoom); + } + + void _applyFilters() { + final range = _filterDateRange; + _filteredReports = _allReports.where((r) { + if (!_filterCategories.contains(r.category)) return false; + if (!_filterSeverities.contains(r.severity)) return false; + if (!_filterStatuses.contains(r.status)) return false; + + if (range != null) { + final created = DateTime.tryParse(r.createdAt); + if (created == null) return false; + // include the end day fully + final endInclusive = DateTime(range.end.year, range.end.month, range.end.day, 23, 59, 59); + if (created.isBefore(range.start) || created.isAfter(endInclusive)) return false; + } + return true; + }).toList(); + + setState(() {}); + + if (_filteredReports.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) => _fitToBounds()); + } + } + + void _fitToBounds() { + if (_filteredReports.isEmpty) return; + final points = _filteredReports.map((r) => LatLng(r.location.lat, r.location.lng)).toList(); + if (points.isEmpty) return; + final bounds = LatLngBounds.fromPoints(points); + try { + _mapController.fitCamera( + CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(60)), + ); + } catch (_) { + // ignore + } + } + + List _buildMarkers() { + return _filteredReports.map((r) { + final latlng = LatLng(r.location.lat, r.location.lng); + return Marker( + point: latlng, + width: 40, + height: 40, + child: GestureDetector( + onTap: () => _onMarkerTap(r), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.location_on, color: r.severity.color, size: 36), + ], + ), + ), + ); + }).toList(); + } + + void _onMarkerTap(Report r) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Wrap( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildThumbnail(r), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(I18n.t(r.category.key), style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Row(children: [ + SeverityBadge(severity: r.severity, small: true), + const SizedBox(width: 8), + StatusBadge(status: r.status, small: true), + ]), + const SizedBox(height: 8), + Text('${I18n.t('label.location')}: ${r.location.lat.toStringAsFixed(6)}, ${r.location.lng.toStringAsFixed(6)}'), + const SizedBox(height: 4), + Text('${I18n.t('label.createdAt')}: ${r.createdAt.split('T').first}'), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); // close sheet + // Navigate to My Reports tab/screen (simplest) + Navigator.push(context, MaterialPageRoute(builder: (_) => const MyReportsScreen())); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(I18n.t('map.openedInMyReports') ?? I18n.t('nav.myReports'))), + ); + }, + child: Text(I18n.t('btn.viewDetails')), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () => _openExternalMap(r.location.lat, r.location.lng), + child: Text(I18n.t('btn.openMap')), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildThumbnail(Report r) { + final placeholder = Container( + width: 120, + height: 90, + color: Colors.grey.shade200, + alignment: Alignment.center, + child: Icon(Icons.photo, color: Colors.grey.shade600), + ); + + if (kIsWeb) { + if (r.base64Photo != null && r.base64Photo!.isNotEmpty) { + try { + final bytes = base64Decode(r.base64Photo!); + return Image.memory(bytes, width: 120, height: 90, fit: BoxFit.cover); + } catch (_) { + return placeholder; + } + } + return placeholder; + } else { + if (r.photoPath != null && r.photoPath!.isNotEmpty) { + final file = File(r.photoPath!); + if (file.existsSync()) { + return Image.file(file, width: 120, height: 90, fit: BoxFit.cover); + } + } + return placeholder; + } + } + + Future _openExternalMap(double lat, double lng) async { + final uri = Uri.parse('https://www.google.com/maps/search/?api=1&query=$lat,$lng'); + try { + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('error.openMap')))); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('error.openMap')))); + } + } + + Future _openFilterModal() async { + // Use current filters as initial values + final now = DateTime.now(); + Set selCategories = Set.from(_filterCategories); + Set selSeverities = Set.from(_filterSeverities); + Set selStatuses = Set.from(_filterStatuses); + DateTimeRange? selRange = _filterDateRange; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) { + return StatefulBuilder(builder: (context, setModalState) { + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(I18n.t('btn.filter'), style: Theme.of(context).textTheme.titleMedium), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(ctx), + ) + ], + ), + const SizedBox(height: 8), + Align(alignment: Alignment.centerLeft, child: Text(I18n.t('filter.category'))), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: Category.all.map((c) { + final selected = selCategories.contains(c); + return FilterChip( + label: Text(I18n.t(c.key)), + selected: selected, + onSelected: (v) { + setModalState(() { + if (v) { + selCategories.add(c); + } else { + selCategories.remove(c); + } + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 12), + Align(alignment: Alignment.centerLeft, child: Text(I18n.t('filter.severity'))), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: Severity.all.map((s) { + final selected = selSeverities.contains(s); + return FilterChip( + label: Text(I18n.t(s.key)), + selected: selected, + onSelected: (v) { + setModalState(() { + if (v) { + selSeverities.add(s); + } else { + selSeverities.remove(s); + } + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 12), + Align(alignment: Alignment.centerLeft, child: Text(I18n.t('filter.status'))), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: Status.all.map((st) { + final selected = selStatuses.contains(st); + return FilterChip( + label: Text(I18n.t(st.key)), + selected: selected, + onSelected: (v) { + setModalState(() { + if (v) { + selStatuses.add(st); + } else { + selStatuses.remove(st); + } + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 12), + Align(alignment: Alignment.centerLeft, child: Text(I18n.t('filter.dateRange'))), + const SizedBox(height: 8), + Row(children: [ + Expanded( + child: OutlinedButton( + onPressed: () async { + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2000), + lastDate: now, + initialDateRange: selRange ?? DateTimeRange(start: now.subtract(const Duration(days: 30)), end: now), + ); + if (picked != null) { + setModalState(() => selRange = picked); + } + }, + child: Text(selRange == null ? I18n.t('filter.dateRange') : '${selRange!.start.toLocal().toIso8601String().split('T').first} - ${selRange!.end.toLocal().toIso8601String().split('T').first}'), + ), + ), + ]), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () { + setModalState(() { + selCategories = Category.all.toSet(); + selSeverities = Severity.all.toSet(); + selStatuses = Status.all.toSet(); + selRange = DateTimeRange(start: now.subtract(const Duration(days: 30)), end: now); + }); + }, + child: Text(I18n.t('btn.reset')), + ), + ElevatedButton( + onPressed: () { + // Apply + setState(() { + _filterCategories = selCategories; + _filterSeverities = selSeverities; + _filterStatuses = selStatuses; + _filterDateRange = selRange; + }); + _applyFilters(); + Navigator.pop(ctx); + }, + child: Text(I18n.t('btn.apply')), + ), + ], + ), + ]), + ), + ), + ); + }); + }, + ); + } + + @override + Widget build(BuildContext context) { + final markers = _buildMarkers(); + + return Scaffold( + appBar: AppBar( + title: Text(I18n.t('nav.map')), + actions: [ + IconButton(icon: const Icon(Icons.filter_list), onPressed: _openFilterModal), + IconButton(icon: const Icon(Icons.refresh), onPressed: _refresh), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _filteredReports.isEmpty + ? Center(child: Text(I18n.t('map.noReports'))) + : Stack( + children: [ + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: _filteredReports.isNotEmpty + ? LatLng(_filteredReports.first.location.lat, _filteredReports.first.location.lng) + : _defaultCenter, + initialZoom: _defaultZoom, + minZoom: 3.0, + maxZoom: 18.0, + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.example.citypulse', + ), + if (markers.isNotEmpty) + MarkerClusterLayerWidget( + options: MarkerClusterLayerOptions( + maxClusterRadius: 60, + size: const Size(40, 40), + markers: markers, + spiderfyCircleRadius: 80, + showPolygon: false, + disableClusteringAtZoom: 16, + builder: (context, markers) { + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: Text( + markers.length.toString(), + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ); + }, + onClusterTap: (cluster) { + try { + final pts = cluster.markers.map((m) => m.point).toList(); + final bounds = LatLngBounds.fromPoints(pts); + _mapController.fitCamera( + CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(60)), + ); + } catch (_) {} + }, + ), + ), + ], + ), + // Legend overlay + Positioned( + top: 16, + right: 16, + child: Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _legendItem(Severity.high, I18n.t('severity.high')), + const SizedBox(width: 8), + _legendItem(Severity.medium, I18n.t('severity.medium')), + const SizedBox(width: 8), + _legendItem(Severity.low, I18n.t('severity.low')), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _legendItem(Severity s, String label) { + return Row( + children: [ + Container(width: 12, height: 12, decoration: BoxDecoration(color: s.color, shape: BoxShape.circle)), + const SizedBox(width: 6), + Text(label, style: const TextStyle(fontSize: 12)), + ], + ); + } +} + +/// Full screen report details used elsewhere in the app. +class MapReportDetails extends StatelessWidget { + final Report report; + const MapReportDetails({super.key, required this.report}); + + @override + Widget build(BuildContext context) { + final created = DateTime.tryParse(report.createdAt); + return Scaffold( + appBar: AppBar(title: Text(I18n.t('btn.details'))), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (kIsWeb && report.base64Photo != null) + Image.memory(base64Decode(report.base64Photo!)) + else if (!kIsWeb && report.photoPath != null) + Image.file(File(report.photoPath!)) + else + Container(height: 180, color: Colors.grey.shade200, alignment: Alignment.center, child: const Icon(Icons.photo, size: 64)), + const SizedBox(height: 12), + Text(I18n.t(report.category.key), style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Row(children: [SeverityBadge(severity: report.severity), const SizedBox(width: 8), StatusBadge(status: report.status)]), + const SizedBox(height: 12), + Text('${I18n.t('label.location')}: ${report.location.lat.toStringAsFixed(6)}, ${report.location.lng.toStringAsFixed(6)}'), + const SizedBox(height: 8), + Text('${I18n.t('label.createdAt')}: ${created != null ? created.toLocal().toString() : report.createdAt}'), + const SizedBox(height: 8), + if (report.notes != null) Text('${I18n.t('label.notes')}: ${report.notes}'), + ]), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/my_reports/my_reports_screen.dart b/lib/screens/my_reports/my_reports_screen.dart new file mode 100644 index 0000000..c458c71 --- /dev/null +++ b/lib/screens/my_reports/my_reports_screen.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import '../../services/storage.dart'; +import '../../models/report.dart'; +import '../../widgets/report_card.dart'; +import '../map/map_screen.dart'; +import '../../l10n/i18n.dart'; + +class MyReportsScreen extends StatefulWidget { + const MyReportsScreen({super.key}); + + @override + State createState() => _MyReportsScreenState(); +} + +class _MyReportsScreenState extends State { + List _reports = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadReports(); + } + + Future _loadReports() async { + setState(() { + _loading = true; + }); + final reports = await StorageService.getReports(); + setState(() { + _reports = reports.reversed.toList(); // newest first + _loading = false; + }); + } + + void _onViewReport(Report r) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => MapReportDetails(report: r)), + ); + } + + void _onDeleted() async { + await _loadReports(); + } + + void _onUpdated(Report updated) async { + await _loadReports(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(I18n.t('nav.myReports')), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadReports, + ) + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _reports.isEmpty + ? Center(child: Text(I18n.t('map.noReports'))) + : RefreshIndicator( + onRefresh: _loadReports, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _reports.length, + itemBuilder: (context, index) { + final r = _reports[index]; + return ReportCard( + report: r, + onView: () => _onViewReport(r), + onDeleted: _onDeleted, + onUpdated: _onUpdated, + ); + }, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/report_flow/capture_screen.dart b/lib/screens/report_flow/capture_screen.dart new file mode 100644 index 0000000..d3d2714 --- /dev/null +++ b/lib/screens/report_flow/capture_screen.dart @@ -0,0 +1,166 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../l10n/i18n.dart'; +import '../../services/location_service.dart'; +import '../../services/mock_ai.dart'; +import '../../models/report.dart'; +import '../../models/enums.dart'; +import 'review_screen.dart'; + +class CaptureScreen extends StatefulWidget { + const CaptureScreen({super.key}); + + @override + State createState() => _CaptureScreenState(); +} + +class _CaptureScreenState extends State { + final ImagePicker _picker = ImagePicker(); + bool _isLoading = false; + + Future _pickImage(ImageSource source) async { + setState(() { + _isLoading = true; + }); + + try { + final XFile? image = await _picker.pickImage( + source: source, + maxWidth: 1920, + maxHeight: 1080, + imageQuality: 85, + ); + + if (image != null) { + await _processImage(image, source); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error picking image: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _processImage(XFile image, ImageSource source) async { + try { + // Get current position (Geolocator.Position) + final position = await LocationService.getCurrentPosition(); + + if (position == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Unable to get location. Please try again.')), + ); + } + 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(), + createdAt: DateTime.now().toIso8601String(), + lat: locationData.lat, + lng: locationData.lng, + photoSizeBytes: await image.length(), + ); + + // Create report with AI suggestion + final report = Report( + id: DateTime.now().millisecondsSinceEpoch.toString(), + category: aiSuggestion.category, + severity: aiSuggestion.severity, + status: Status.submitted, + photoPath: image.path, + base64Photo: null, // Will be set on Web + location: locationData, + createdAt: DateTime.now().toIso8601String(), + updatedAt: DateTime.now().toIso8601String(), + deviceId: 'device_${DateTime.now().millisecondsSinceEpoch}', + notes: null, + address: null, + source: source == ImageSource.camera ? 'camera' : 'gallery', + editable: true, + deletable: true, + aiSuggestion: aiSuggestion, + schemaVersion: 1, + ); + + if (mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ReviewScreen(report: report, imageFile: File(image.path)), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error processing image: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(I18n.t('nav.report')), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Take a photo of the issue', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + if (_isLoading) + const CircularProgressIndicator() + else ...[ + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _pickImage(ImageSource.camera), + icon: const Icon(Icons.camera_alt), + label: Text(I18n.t('btn.camera')), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _pickImage(ImageSource.gallery), + icon: const Icon(Icons.photo_library), + label: Text(I18n.t('btn.gallery')), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + ], + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/report_flow/review_screen.dart b/lib/screens/report_flow/review_screen.dart new file mode 100644 index 0000000..62a479c --- /dev/null +++ b/lib/screens/report_flow/review_screen.dart @@ -0,0 +1,289 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../../l10n/i18n.dart'; +import '../../models/report.dart'; +import '../../models/enums.dart'; +import '../../services/storage.dart'; + +class ReviewScreen extends StatefulWidget { + final Report report; + final File imageFile; + + const ReviewScreen({ + super.key, + required this.report, + required this.imageFile, + }); + + @override + State createState() => _ReviewScreenState(); +} + +class _ReviewScreenState extends State { + late Category _selectedCategory; + late Severity _selectedSeverity; + late TextEditingController _notesController; + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + _selectedCategory = widget.report.category; + _selectedSeverity = widget.report.severity; + _notesController = TextEditingController(text: widget.report.notes ?? ''); + } + + @override + void dispose() { + _notesController.dispose(); + super.dispose(); + } + + Future _submitReport() async { + setState(() { + _isSubmitting = true; + }); + + try { + // Update report with user selections + final updatedReport = widget.report.copyWith( + category: _selectedCategory, + severity: _selectedSeverity, + notes: _notesController.text.isEmpty ? null : _notesController.text, + updatedAt: DateTime.now().toIso8601String(), + ); + + // Save to storage + await StorageService.saveReport(updatedReport); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(I18n.t('toast.reportSaved'))), + ); + + // Navigate back to main screen + Navigator.of(context).popUntil((route) => route.isFirst); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error saving report: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(I18n.t('btn.submit')), + actions: [ + TextButton( + onPressed: _isSubmitting ? null : _submitReport, + child: _isSubmitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(I18n.t('btn.submit')), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image preview + Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: Image.file( + widget.imageFile, + fit: BoxFit.cover, + ), + ), + const SizedBox(height: 24), + + // AI Suggestion Card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.lightbulb, + color: Theme.of(context).primaryColor, + ), + const SizedBox(width: 8), + Text( + I18n.t('label.aiSuggestion'), + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _buildSuggestionChip( + widget.report.aiSuggestion.category.displayName, + Colors.blue.shade100, + ), + const SizedBox(width: 8), + _buildSuggestionChip( + widget.report.aiSuggestion.severity.displayName, + _getSeverityColor(widget.report.aiSuggestion.severity), + ), + const SizedBox(width: 8), + _buildSuggestionChip( + '${(widget.report.aiSuggestion.confidence * 100).round()}%', + Colors.grey.shade100, + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () { + setState(() { + _selectedCategory = widget.report.aiSuggestion.category; + _selectedSeverity = widget.report.aiSuggestion.severity; + }); + }, + child: Text(I18n.t('btn.useSuggestion')), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: () { + // Keep manual selections + }, + child: Text(I18n.t('btn.keepManual')), + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Category Selection + Text( + I18n.t('label.category'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: Category.values.map((category) { + return ChoiceChip( + label: Text(category.displayName), + selected: _selectedCategory == category, + onSelected: (selected) { + if (selected) { + setState(() { + _selectedCategory = category; + }); + } + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + + // Severity Selection + Text( + I18n.t('label.severity'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: Severity.values.map((severity) { + return ChoiceChip( + label: Text(severity.displayName), + selected: _selectedSeverity == severity, + onSelected: (selected) { + if (selected) { + setState(() { + _selectedSeverity = severity; + }); + } + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + + // Notes + Text( + I18n.t('label.notes'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + TextField( + controller: _notesController, + decoration: InputDecoration( + hintText: 'Add any additional notes...', + border: const OutlineInputBorder(), + ), + maxLines: 3, + ), + const SizedBox(height: 24), + + // Location info + Text( + I18n.t('label.location'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Lat: ${widget.report.location.lat.toStringAsFixed(6)}, ' + 'Lng: ${widget.report.location.lng.toStringAsFixed(6)}', + ), + if (widget.report.location.accuracy != null) + Text('Accuracy: ${widget.report.location.accuracy!.toStringAsFixed(1)}m'), + ], + ), + ), + ); + } + + Widget _buildSuggestionChip(String label, Color color) { + return Chip( + label: Text(label), + backgroundColor: color, + ); + } + + Color _getSeverityColor(Severity severity) { + switch (severity) { + case Severity.high: + return Colors.red.shade100; + case Severity.medium: + return Colors.orange.shade100; + case Severity.low: + return Colors.green.shade100; + } + } +} \ No newline at end of file diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart new file mode 100644 index 0000000..73510da --- /dev/null +++ b/lib/screens/settings/settings_screen.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../l10n/i18n.dart'; +import '../../l10n/locale_provider.dart'; +import '../../services/storage.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + StorageStats? _stats; + bool _loadingStats = true; + bool _clearing = false; + + @override + void initState() { + super.initState(); + _loadStats(); + } + + Future _loadStats() async { + setState(() { + _loadingStats = true; + }); + final stats = await StorageService.getStorageStats(); + if (mounted) { + setState(() { + _stats = stats; + _loadingStats = false; + }); + } + } + + Future _confirmAndClearAll(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(I18n.t('confirm.clearData.title')), + content: Text(I18n.t('confirm.clearData.message')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(I18n.t('btn.no'))), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text(I18n.t('btn.yes'))), + ], + ), + ); + + if (confirmed == true) { + setState(() { + _clearing = true; + }); + final ok = await StorageService.clearAllReports(); + setState(() { + _clearing = false; + }); + if (ok) { + await _loadStats(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('toast.storageCleared')))); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to clear data'))); + } + } + } + } + + @override + Widget build(BuildContext context) { + final localeProvider = Provider.of(context); + final isEnglish = localeProvider.isEnglish; + + return Scaffold( + appBar: AppBar( + title: Text(I18n.t('nav.settings')), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + ListTile( + title: Text(I18n.t('settings.language')), + subtitle: Text(isEnglish ? I18n.t('lang.en') : I18n.t('lang.ms')), + trailing: Switch( + value: isEnglish, + onChanged: (v) async { + // toggle language and reload translations + if (v) { + await localeProvider.setEnglish(); + } else { + await localeProvider.setMalay(); + } + await I18n.init(localeProvider.locale); + // Force rebuild + setState(() {}); + }, + ), + ), + const Divider(), + ListTile( + title: Text(I18n.t('settings.theme')), + subtitle: Text(I18n.t('settings.theme.light')), + trailing: const Icon(Icons.light_mode), + enabled: false, + ), + const Divider(), + ListTile( + title: Text(I18n.t('settings.diagnostics')), + subtitle: _loadingStats + ? const Text('Loading...') + : Text('${I18n.t('toast.reportSaved')}: ${_stats?.reportCount ?? 0} • ${_stats?.formattedPhotoSize ?? "0 B"}'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: _clearing ? null : () => _confirmAndClearAll(context), + icon: const Icon(Icons.delete_forever), + label: _clearing ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : Text(I18n.t('btn.clearAll')), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + ), + const SizedBox(height: 16), + Text('App', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ListTile( + title: Text(I18n.t('app.name')), + subtitle: Text('v1.0.0'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart new file mode 100644 index 0000000..95ef236 --- /dev/null +++ b/lib/services/location_service.dart @@ -0,0 +1,202 @@ +import 'package:flutter/foundation.dart'; +import 'package:geolocator/geolocator.dart'; +import '../models/report.dart'; + +/// Service for handling location operations and permissions +class LocationService { + /// Check if location services are enabled + static Future isLocationServiceEnabled() async { + try { + return await Geolocator.isLocationServiceEnabled(); + } catch (e) { + print('Error checking location service: $e'); + return false; + } + } + + /// Check location permissions + static Future checkPermission() async { + try { + return await Geolocator.checkPermission(); + } catch (e) { + print('Error checking location permission: $e'); + return LocationPermission.denied; + } + } + + /// Request location permissions + static Future requestPermission() async { + try { + return await Geolocator.requestPermission(); + } catch (e) { + print('Error requesting location permission: $e'); + return LocationPermission.denied; + } + } + + /// Get current position with high accuracy + static Future getCurrentPosition() async { + try { + // Check if location services are enabled + final serviceEnabled = await isLocationServiceEnabled(); + if (!serviceEnabled) { + print('Location services are disabled'); + return null; + } + + // Check permissions + var permission = await checkPermission(); + if (permission == LocationPermission.denied) { + permission = await requestPermission(); + if (permission == LocationPermission.denied) { + print('Location permission denied'); + return null; + } + } + + if (permission == LocationPermission.deniedForever) { + print('Location permission permanently denied'); + return null; + } + + // Get current position + return await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: const Duration(seconds: 30), + ); + } catch (e) { + print('Error getting current position: $e'); + return null; + } + } + + /// Get current position with best available accuracy + static Future getBestAvailablePosition() async { + try { + // Check if location services are enabled + final serviceEnabled = await isLocationServiceEnabled(); + if (!serviceEnabled) { + print('Location services are disabled'); + return null; + } + + // Check permissions + var permission = await checkPermission(); + if (permission == LocationPermission.denied) { + permission = await requestPermission(); + if (permission == LocationPermission.denied) { + print('Location permission denied'); + return null; + } + } + + if (permission == LocationPermission.deniedForever) { + print('Location permission permanently denied'); + return null; + } + + // Try high accuracy first, fallback to medium if it takes too long + try { + return await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: const Duration(seconds: 15), + ); + } catch (e) { + print('High accuracy failed, trying medium accuracy: $e'); + return await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.medium, + timeLimit: const Duration(seconds: 10), + ); + } + } catch (e) { + print('Error getting best available position: $e'); + return null; + } + } + + /// Convert Position to LocationData + static LocationData positionToLocationData(Position position) { + return LocationData( + lat: position.latitude, + lng: position.longitude, + accuracy: position.accuracy, + ); + } + + /// Get location accuracy description + static String getAccuracyDescription(double? accuracy) { + if (accuracy == null) return 'Unknown'; + + if (accuracy <= 3) { + return 'Very High'; + } else if (accuracy <= 10) { + return 'High'; + } else if (accuracy <= 50) { + return 'Medium'; + } else if (accuracy <= 100) { + return 'Low'; + } else { + return 'Very Low'; + } + } + + /// Check if location accuracy is good enough for reporting + static bool isAccuracyGoodForReporting(double? accuracy) { + if (accuracy == null) return false; + return accuracy <= 50; // Within 50 meters is acceptable + } + + /// Get user-friendly location permission status message + static String getPermissionStatusMessage(LocationPermission permission) { + switch (permission) { + case LocationPermission.denied: + return 'Location permission denied. Please enable location access to attach GPS coordinates to your reports.'; + case LocationPermission.deniedForever: + return 'Location permission permanently denied. Please enable location access in your device settings to use this feature.'; + case LocationPermission.whileInUse: + case LocationPermission.always: + return 'Location permission granted.'; + case LocationPermission.unableToDetermine: + return 'Unable to determine location permission status.'; + } + } + + /// Open device location settings + static Future openLocationSettings() async { + try { + return await Geolocator.openLocationSettings(); + } catch (e) { + print('Error opening location settings: $e'); + return false; + } + } + + /// Calculate distance between two points in meters + static double calculateDistance(LocationData point1, LocationData point2) { + return Geolocator.distanceBetween( + point1.lat, + point1.lng, + point2.lat, + point2.lng, + ); + } + + /// Get address from coordinates (placeholder - would need geocoding service) + static Future 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 + return null; + } + + /// Validate location data + static bool isValidLocation(LocationData location) { + final acc = location.accuracy; + return location.lat >= -90 && + location.lat <= 90 && + location.lng >= -180 && + location.lng <= 180 && + acc != null && + acc >= 0; + } +} \ No newline at end of file diff --git a/lib/services/mock_ai.dart b/lib/services/mock_ai.dart new file mode 100644 index 0000000..0db0a09 --- /dev/null +++ b/lib/services/mock_ai.dart @@ -0,0 +1,116 @@ +import 'dart:math'; +import 'package:flutter/foundation.dart' hide Category; +import '../models/enums.dart'; +import '../models/report.dart'; + +/// Service for generating deterministic AI suggestions for reports +class MockAIService { + /// Generate a deterministic seed based on report parameters + static int _generateSeed(String id, String createdAt, double lat, double lng, int? photoSizeBytes) { + final combined = '$id$createdAt$lat$lng${photoSizeBytes ?? 0}'; + var hash = 0; + for (var i = 0; i < combined.length; i++) { + hash = ((hash << 5) - hash) + combined.codeUnitAt(i); + hash = hash & hash; // Convert to 32-bit integer + } + return hash.abs(); + } + + /// Generate AI suggestion for a report + static AISuggestion generateSuggestion({ + required String id, + required String createdAt, + required double lat, + required double lng, + int? photoSizeBytes, + }) { + final seed = _generateSeed(id, createdAt, lat, lng, photoSizeBytes); + final random = Random(seed); + + // Category selection with weighted probabilities + final categoryWeights = { + Category.pothole: 0.35, + Category.trash: 0.25, + Category.streetlight: 0.15, + Category.signage: 0.10, + Category.drainage: 0.10, + Category.other: 0.05, + }; + + // Apply heuristics based on image dimensions (if available) + final aspectRatio = photoSizeBytes != null ? (random.nextDouble() * 2) : 1.0; + if (aspectRatio > 1.2) { + // Wide image - likely signage + categoryWeights[Category.signage] = categoryWeights[Category.signage]! * 2; + categoryWeights[Category.pothole] = categoryWeights[Category.pothole]! * 0.5; + } + + // Select category based on weights + final categoryRand = random.nextDouble(); + double cumulative = 0.0; + Category selectedCategory = Category.pothole; + + for (final entry in categoryWeights.entries) { + cumulative += entry.value; + if (categoryRand <= cumulative) { + selectedCategory = entry.key; + break; + } + } + + // Severity selection with weighted probabilities + final severityWeights = { + Severity.medium: 0.45, + Severity.high: 0.30, + Severity.low: 0.25, + }; + + // Apply location accuracy heuristic + final accuracy = random.nextDouble() * 50; // Simulate accuracy 0-50m + final isNight = random.nextBool(); // Simulate night time + + if (accuracy <= 10 && isNight) { + // High accuracy at night - bump high severity + severityWeights[Severity.high] = severityWeights[Severity.high]! * 1.5; + severityWeights[Severity.medium] = severityWeights[Severity.medium]! * 0.8; + } + + // Select severity based on weights + final severityRand = random.nextDouble(); + cumulative = 0.0; + Severity selectedSeverity = Severity.medium; + + for (final entry in severityWeights.entries) { + cumulative += entry.value; + if (severityRand <= cumulative) { + selectedSeverity = entry.key; + break; + } + } + + // Generate confidence score (0.6 - 0.9) + final confidence = 0.6 + (random.nextDouble() * 0.3); + + return AISuggestion( + category: selectedCategory, + severity: selectedSeverity, + confidence: confidence, + ); + } + + /// Check if the AI suggestion is reliable enough to use + static bool isSuggestionReliable(AISuggestion suggestion) { + return suggestion.confidence >= 0.7; + } + + /// Get confidence level description + static String getConfidenceDescription(double confidence) { + if (confidence >= 0.8) { + return 'High confidence'; + } else if (confidence >= 0.7) { + return 'Medium confidence'; + } else { + return 'Low confidence'; + } + } +} \ No newline at end of file diff --git a/lib/services/storage.dart b/lib/services/storage.dart new file mode 100644 index 0000000..8049d7f --- /dev/null +++ b/lib/services/storage.dart @@ -0,0 +1,231 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:path_provider/path_provider.dart'; +import '../models/report.dart'; + +/// Service for persisting reports and managing local storage +class StorageService { + static const String _reportsKey = 'reports_v1'; + + /// Get all reports from storage + static Future> getReports() async { + try { + final prefs = await SharedPreferences.getInstance(); + final reportsJson = prefs.getString(_reportsKey); + + if (reportsJson == null || reportsJson.isEmpty) { + return []; + } + + final List reportsList = json.decode(reportsJson); + return reportsList.map((json) => Report.fromJson(json)).toList(); + } catch (e) { + print('Error loading reports: $e'); + return []; + } + } + + /// Save a single report to storage + static Future saveReport(Report report) async { + try { + final reports = await getReports(); + final existingIndex = reports.indexWhere((r) => r.id == report.id); + + if (existingIndex >= 0) { + reports[existingIndex] = report; + } else { + reports.add(report); + } + + return await _saveReportsList(reports); + } catch (e) { + print('Error saving report: $e'); + return false; + } + } + + /// Delete a report from storage + static Future deleteReport(String reportId) async { + 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 { + await _deletePhotoFile(reportId); + } + + return await _saveReportsList(updatedReports); + } catch (e) { + print('Error deleting report: $e'); + return false; + } + } + + /// Clear all reports from storage + static Future clearAllReports() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_reportsKey); + + // Delete all photo files + if (!kIsWeb) { + await _deleteAllPhotoFiles(); + } + + return true; + } catch (e) { + print('Error clearing reports: $e'); + return false; + } + } + + /// Save reports list to SharedPreferences + static Future _saveReportsList(List reports) async { + try { + final prefs = await SharedPreferences.getInstance(); + + // Convert reports to JSON, excluding photo data for mobile + final reportsForStorage = reports.map((report) { + final json = report.toJson(); + if (!kIsWeb) { + // On mobile, remove base64Photo to save space + json.remove('base64Photo'); + } + return json; + }).toList(); + + final reportsJson = json.encode(reportsForStorage); + return await prefs.setString(_reportsKey, reportsJson); + } catch (e) { + print('Error saving reports list: $e'); + return false; + } + } + + /// Get the photo file for a report + static Future getPhotoFile(String reportId) async { + if (kIsWeb) return null; + + try { + final appDir = await getApplicationDocumentsDirectory(); + final photoFile = File('${appDir.path}/$reportId.jpg'); + + if (await photoFile.exists()) { + return photoFile; + } + return null; + } catch (e) { + print('Error getting photo file: $e'); + return null; + } + } + + /// Save photo file for a report + static Future savePhotoFile(String reportId, List photoBytes) async { + if (kIsWeb) return false; + + try { + final appDir = await getApplicationDocumentsDirectory(); + final photoFile = File('${appDir.path}/$reportId.jpg'); + await photoFile.writeAsBytes(photoBytes); + return true; + } catch (e) { + print('Error saving photo file: $e'); + return false; + } + } + + /// Delete photo file for a report + static Future _deletePhotoFile(String reportId) async { + if (kIsWeb) return true; + + try { + final appDir = await getApplicationDocumentsDirectory(); + final photoFile = File('${appDir.path}/$reportId.jpg'); + + if (await photoFile.exists()) { + await photoFile.delete(); + } + return true; + } catch (e) { + print('Error deleting photo file: $e'); + return false; + } + } + + /// Delete all photo files + static Future _deleteAllPhotoFiles() async { + if (kIsWeb) return; + + try { + final appDir = await getApplicationDocumentsDirectory(); + final reports = await getReports(); + + for (final report in reports) { + final photoFile = File('${appDir.path}/${report.id}.jpg'); + if (await photoFile.exists()) { + await photoFile.delete(); + } + } + } catch (e) { + print('Error deleting all photo files: $e'); + } + } + + /// Get storage statistics + static Future getStorageStats() async { + try { + final reports = await getReports(); + int photoFilesSize = 0; + + if (!kIsWeb) { + final appDir = await getApplicationDocumentsDirectory(); + final reportsDir = Directory(appDir.path); + + if (await reportsDir.exists()) { + final files = reportsDir.listSync().whereType(); + photoFilesSize = files + .where((file) => file.path.endsWith('.jpg')) + .fold(0, (sum, file) => sum + file.lengthSync()); + } + } + + return StorageStats( + reportCount: reports.length, + photoFilesSize: photoFilesSize, + ); + } catch (e) { + print('Error getting storage stats: $e'); + return StorageStats(reportCount: 0, photoFilesSize: 0); + } + } +} + +/// Storage statistics model +class StorageStats { + final int reportCount; + final int photoFilesSize; // in bytes + + const StorageStats({ + required this.reportCount, + required this.photoFilesSize, + }); + + String get formattedPhotoSize { + const units = ['B', 'KB', 'MB', 'GB']; + double size = photoFilesSize.toDouble(); + int unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return '${size.toStringAsFixed(1)} ${units[unitIndex]}'; + } +} \ No newline at end of file diff --git a/lib/widgets/report_card.dart b/lib/widgets/report_card.dart new file mode 100644 index 0000000..659a501 --- /dev/null +++ b/lib/widgets/report_card.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../models/report.dart'; +import '../services/storage.dart'; +import 'severity_badge.dart'; +import 'status_badge.dart'; +import '../l10n/i18n.dart'; + +class ReportCard extends StatelessWidget { + final Report report; + final VoidCallback? onView; + final VoidCallback? onDeleted; + final ValueChanged? onUpdated; + + const ReportCard({ + super.key, + required this.report, + this.onView, + this.onDeleted, + this.onUpdated, + }); + + Widget _buildThumbnail() { + if (kIsWeb && report.base64Photo != null) { + try { + final bytes = base64Decode(report.base64Photo!); + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory(bytes, width: 72, height: 72, fit: BoxFit.cover), + ); + } 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), + ); + } + + return Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.image, color: Colors.grey.shade600), + ); + } + + String _formatTime(String iso) { + try { + final dt = DateTime.parse(iso).toLocal(); + return '${dt.year}-${dt.month.toString().padLeft(2,'0')}-${dt.day.toString().padLeft(2,'0')} ${dt.hour.toString().padLeft(2,'0')}:${dt.minute.toString().padLeft(2,'0')}'; + } catch (_) { + return iso; + } + } + + Future _confirmAndDelete(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(I18n.t('confirm.deleteReport.title')), + content: Text(I18n.t('confirm.deleteReport.message')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(I18n.t('btn.no'))), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text(I18n.t('btn.yes'))), + ], + ), + ); + + if (ok == true) { + final success = await StorageService.deleteReport(report.id); + if (success) { + if (onDeleted != null) onDeleted!(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('toast.reportDeleted')))); + } + } + } + + Future _cycleStatus(BuildContext context) async { + final next = report.status.next; + final updated = report.copyWith(status: next, updatedAt: DateTime.now().toIso8601String()); + final ok = await StorageService.saveReport(updated); + if (ok) { + if (onUpdated != null) onUpdated!(updated); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('btn.changeStatus')))); + } + } + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: ListTile( + leading: _buildThumbnail(), + title: Text(report.category.displayName), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 6), + Row( + children: [ + SeverityBadge(severity: report.severity, small: true), + const SizedBox(width: 8), + StatusBadge(status: report.status), + const SizedBox(width: 8), + Text(_formatTime(report.createdAt), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), + ], + ), + ], + ), + isThreeLine: true, + trailing: PopupMenuButton( + onSelected: (v) async { + if (v == 0) { + if (onView != null) onView!(); + } else if (v == 1) { + await _cycleStatus(context); + } else if (v == 2) { + await _confirmAndDelete(context); + } + }, + itemBuilder: (_) => [ + PopupMenuItem(value: 0, child: Text(I18n.t('btn.view'))), + PopupMenuItem(value: 1, child: Text(I18n.t('btn.changeStatus'))), + PopupMenuItem(value: 2, child: Text(I18n.t('btn.delete'))), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/severity_badge.dart b/lib/widgets/severity_badge.dart new file mode 100644 index 0000000..2850606 --- /dev/null +++ b/lib/widgets/severity_badge.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import '../models/enums.dart'; + +class SeverityBadge extends StatelessWidget { + final Severity severity; + final bool small; + + const SeverityBadge({super.key, required this.severity, this.small = false}); + + @override + Widget build(BuildContext context) { + final color = severity.color; + return Container( + padding: EdgeInsets.symmetric(horizontal: small ? 8 : 12, vertical: small ? 4 : 6), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + severity.displayName, + style: TextStyle(color: color, fontSize: small ? 12 : 14), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/status_badge.dart b/lib/widgets/status_badge.dart new file mode 100644 index 0000000..c5b165d --- /dev/null +++ b/lib/widgets/status_badge.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import '../models/enums.dart'; + +class StatusBadge extends StatelessWidget { + final Status status; + final bool small; + + const StatusBadge({Key? key, required this.status, this.small = false}) : super(key: key); + + @override + Widget build(BuildContext context) { + final color = status.color; + return Chip( + backgroundColor: color.withOpacity(0.12), + avatar: CircleAvatar( + backgroundColor: color, + radius: small ? 10 : 12, + child: Icon( + _iconForStatus(status), + color: Colors.white, + size: small ? 12 : 14, + ), + ), + label: Text( + status.displayName, + style: TextStyle( + color: color, + fontSize: small ? 12 : 14, + ), + ), + padding: EdgeInsets.symmetric(horizontal: small ? 8 : 12, vertical: 0), + ); + } + + IconData _iconForStatus(Status s) { + switch (s) { + case Status.submitted: + return Icons.send; + case Status.inProgress: + return Icons.autorenew; + case Status.fixed: + return Icons.check; + } + } +} \ No newline at end of file diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..7299b5c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,14 @@ #include "generated_plugin_registrant.h" +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..786ff5c 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..32ef7bb 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,16 @@ import FlutterMacOS import Foundation +import file_selector_macos +import geolocator_apple +import path_provider_foundation +import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index eaa659f..6d15889 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + animated_stack_widget: + dependency: transitive + description: + name: animated_stack_widget + sha256: ce4788dd158768c9d4388354b6fb72600b78e041a37afc4c279c63ecafcb9408 + url: "https://pub.dev" + source: hosted + version: "0.0.4" async: dependency: transitive description: @@ -41,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -49,6 +73,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" fake_async: dependency: transitive description: @@ -57,6 +97,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" + url: "https://pub.dev" + source: hosted + version: "0.9.4+4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -70,11 +166,192 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" + url: "https://pub.dev" + source: hosted + version: "8.2.2" + flutter_map_marker_cluster: + dependency: "direct main" + description: + name: flutter_map_marker_cluster + sha256: "04a20d9b1c3a18b67cc97c1240f75361ab98449b735ab06f2534ece0d0794733" + url: "https://pub.dev" + source: hosted + version: "8.2.2" + flutter_map_marker_popup: + dependency: transitive + description: + name: flutter_map_marker_popup + sha256: "982b38455e739fe04abf05066340e0ce5883c40fb08b121cc8c60f5ee2c664a3" + url: "https://pub.dev" + source: hosted + version: "8.1.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31 + url: "https://pub.dev" + source: hosted + version: "2.0.30" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "149876cc5207a0f5daf4fdd3bfcf0a0f27258b3fe95108fa084f527ad0568f1b" + url: "https://pub.dev" + source: hosted + version: "12.0.0" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + http: + dependency: transitive + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e" + url: "https://pub.dev" + source: hosted + version: "0.8.13+1" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e + url: "https://pub.dev" + source: hosted + version: "0.8.13" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -107,6 +384,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c" + url: "https://pub.dev" + source: hosted + version: "2.6.1" matcher: dependency: transitive description: @@ -131,6 +424,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" path: dependency: transitive description: @@ -139,6 +456,142 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + url: "https://pub.dev" + source: hosted + version: "2.2.18" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -152,6 +605,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -192,6 +653,94 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" + url: "https://pub.dev" + source: hosted + version: "6.3.20" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + url: "https://pub.dev" + source: hosted + version: "6.3.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: dependency: transitive description: @@ -208,6 +757,30 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: dart: ">=3.8.1 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index 391d298..f5815b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: citypulse -description: "A new Flutter project." +description: "FixMate - 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 @@ -30,10 +30,17 @@ environment: dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + flutter_map: ^8.2.2 + flutter_map_marker_cluster: ^8.2.2 + latlong2: ^0.9.1 + geolocator: ^12.0.0 + image_picker: ^1.1.2 + path_provider: ^2.1.4 + shared_preferences: ^2.3.2 + uuid: ^4.5.1 + provider: ^6.1.1 + url_launcher: ^6.3.0 dev_dependencies: flutter_test: @@ -57,6 +64,10 @@ flutter: # the material Icons class. uses-material-design: true + assets: + - assets/lang/en.json + - assets/lang/ms.json + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/test/widget_test.dart b/test/widget_test.dart index 690f67f..0f249c8 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,12 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - +// Basic widget test to ensure the app builds import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:citypulse/main.dart'; +import 'package:citypulse/app.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + testWidgets('App builds MaterialApp', (WidgetTester tester) async { + await tester.pumpWidget(const FixMateApp()); + expect(find.byType(MaterialApp), findsOneWidget); }); -} +} \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..6514907 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,15 @@ #include "generated_plugin_registrant.h" +#include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..c193e6e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + geolocator_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST