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'}
+
+
+
+
+
+
+
+ {/* 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