feat: introduce FixMate Flutter app and React dashboard
- Add Flutter app shell (FixMateApp/MainScreen) with tabs: Report, Map, My Reports, Settings - Implement capture and review flow (image_picker, geolocator, deterministic mock AI), and local storage (SharedPreferences + photo files on mobile) - Build Map screen with flutter_map, marker clustering, filters, legend, marker details, and external maps deeplink - Add My Reports list (view details, cycle status, delete) and Settings (language toggle via Provider, diagnostics, clear all data) - Introduce JSON i18n loader and LocaleProvider; add EN/BM assets - Define models (Report, enums) and UI badges (severity, status) - Add static React dashboard (Leaflet map with clustering, heatmap toggle, filters incl. date range, queue, detail drawer), i18n (EN/BM), and demo data - Update build/config and platform setup: - Extend pubspec with required packages and register i18n assets - Android: add CAMERA and location permissions; pin NDK version - iOS: add usage descriptions for camera, photo library, location - Gradle properties tuned for Windows/UNC stability - Register desktop plugins (Linux/macOS/Windows) - .gitignore: ignore .kilocode - Overhaul README and replace sample widget test
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
71
README.md
71
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.
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Permissions required for FixMate -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:label="citypulse"
|
||||
android:name="${applicationName}"
|
||||
@@ -42,4 +47,4 @@
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
</manifest>
|
||||
@@ -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
|
||||
82
assets/lang/en.json
Normal file
82
assets/lang/en.json
Normal file
@@ -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"
|
||||
}
|
||||
82
assets/lang/ms.json
Normal file
82
assets/lang/ms.json
Normal file
@@ -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"
|
||||
}
|
||||
397
dashboard/app.js
Normal file
397
dashboard/app.js
Normal file
@@ -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: `<div style="width:18px;height:18px;border-radius:50%;background:${color};border:2px solid #fff;"></div>`,
|
||||
iconSize:[22,22],
|
||||
iconAnchor:[11,11]
|
||||
});
|
||||
const marker = L.marker([lat,lng], { icon });
|
||||
marker.on('click', ()=> {
|
||||
setSelected(r);
|
||||
});
|
||||
marker.bindPopup(`<strong>${r.category}</strong><br/>${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 (
|
||||
<div className="app-root">
|
||||
<header className="header">
|
||||
<div className="brand">{t('dashboard.brand') || 'FixMate'}</div>
|
||||
<div className="lang-toggle">
|
||||
<label style={{fontSize:12, color:'#374151'}}>{t('label.language') || 'Language'}</label>
|
||||
<select value={lang} onChange={e=>setLang(e.target.value)}>
|
||||
<option value="en">EN</option>
|
||||
<option value="ms">BM</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container">
|
||||
<div className="main">
|
||||
<aside className="panel filters">
|
||||
<h3>{t('dashboard.filters') || 'Filters'}</h3>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="row space-between"><strong>{t('filter.category') || 'Category'}</strong></div>
|
||||
<div className="checkbox-row" aria-label="categories">
|
||||
{CATEGORY_LIST.map(cat=>(
|
||||
<label key={cat} style={{display:'flex',alignItems:'center',gap:8}}>
|
||||
<input type="checkbox"
|
||||
checked={formCategories.has(cat)}
|
||||
onChange={()=> toggleSet(setFormCategories, formCategories, cat)}
|
||||
/>
|
||||
<span style={{textTransform:'capitalize'}}>{t(`category.${cat}`) || cat}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="row space-between"><strong>{t('filter.severity') || 'Severity'}</strong></div>
|
||||
<div className="multi-select">
|
||||
{SEVERITIES.map(s=>(
|
||||
<button key={s} className={`chip severity-${s}`} onClick={()=> toggleSet(setFormSeverities, formSeverities, s)} aria-pressed={formSeverities.has(s)}>
|
||||
{t(`severity.${s}`) || s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="row space-between"><strong>{t('filter.status') || 'Status'}</strong></div>
|
||||
<div className="multi-select">
|
||||
{STATUSES.map(s=>(
|
||||
<button key={s} className={`chip status-${s}`} onClick={()=> toggleSet(setFormStatuses, formStatuses, s)} aria-pressed={formStatuses.has(s)}>
|
||||
{t(`status.${s}`) || s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="row space-between"><strong>{t('filter.dateRange') || 'Date Range'}</strong></div>
|
||||
<div style={{display:'flex',gap:8,marginTop:8}}>
|
||||
<div style={{display:'flex',flexDirection:'column'}}>
|
||||
<label style={{fontSize:12}}>{t('filter.dateFrom') || 'From'}</label>
|
||||
<input type="date" value={formFrom} onChange={e=>setFormFrom(e.target.value)} />
|
||||
</div>
|
||||
<div style={{display:'flex',flexDirection:'column'}}>
|
||||
<label style={{fontSize:12}}>{t('filter.dateTo') || 'To'}</label>
|
||||
<input type="date" value={formTo} onChange={e=>setFormTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display:'flex',gap:8,marginTop:12}}>
|
||||
<button className="btn" onClick={applyFilters}>{t('btn.apply') || 'Apply'}</button>
|
||||
<button className="btn secondary" onClick={resetFilters}>{t('btn.reset') || 'Reset'}</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<section className="panel map-panel" ref={mapContainerRef}>
|
||||
<div id="map"></div>
|
||||
<div className="map-empty">{t('map.noReports') || 'No reports match filters'}</div>
|
||||
</section>
|
||||
|
||||
<aside className="panel">
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
|
||||
<h3>{t('queue.title') || 'Tickets'}</h3>
|
||||
</div>
|
||||
|
||||
<div className="queue-list" role="list">
|
||||
{sortedQueue.map(r=>(
|
||||
<div key={r.id} className="queue-item" role="listitem">
|
||||
<div className="thumb">{t(`category.${r.category}`) || r.category}</div>
|
||||
<div className="item-main">
|
||||
<div className="item-title">{t(`category.${r.category}`) || r.category}</div>
|
||||
<div className="item-meta">
|
||||
<span className={`chip severity-${r.severity}`}>{t(`severity.${r.severity}`) || r.severity}</span>
|
||||
<span className={`chip status-${r.status}`}>{t(`status.${r.status}`) || r.status}</span>
|
||||
<span className="time-ago">{dayjs(r.createdAt).fromNow()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<button className="btn ghost" onClick={()=> { setSelected(r); }}>{t('btn.view') || 'View'}</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer className="footer">
|
||||
<div className="stats">
|
||||
<div><strong>{t('stats.total') || 'Total'}: </strong> {filtered.length}</div>
|
||||
<div className="chip severity-high">{filtered.filter(x=>x.severity==='high').length} {t('severity.high') || 'High'}</div>
|
||||
<div className="chip severity-medium">{filtered.filter(x=>x.severity==='medium').length} {t('severity.medium') || 'Medium'}</div>
|
||||
<div className="chip severity-low">{filtered.filter(x=>x.severity==='low').length} {t('severity.low') || 'Low'}</div>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:12,alignItems:'center'}}>
|
||||
<label style={{display:'flex',alignItems:'center',gap:8}}>
|
||||
<input type="checkbox" checked={heatEnabled} onChange={e=>setHeatEnabled(e.target.checked)} /> {t('stats.heatmap') || 'Heatmap'}
|
||||
</label>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Detail Drawer */}
|
||||
<div className={`drawer ${selected ? 'open' : ''}`} role="dialog" aria-hidden={!selected}>
|
||||
{selected ? (
|
||||
<div className="drawer-content" aria-live="polite">
|
||||
<button className="drawer-close" onClick={()=>setSelected(null)} aria-label="Close">×</button>
|
||||
<div className="drawer-header">
|
||||
<div className="drawer-thumb large">{/* placeholder */}{t(`category.${selected.category}`) || selected.category}</div>
|
||||
<div style={{marginLeft:12}}>
|
||||
<h3 style={{margin:0}}>{t(`category.${selected.category}`) || selected.category}</h3>
|
||||
<div style={{display:'flex',gap:8,alignItems:'center',marginTop:6}}>
|
||||
<span className={`chip severity-${selected.severity}`}>{t(`severity.${selected.severity}`) || selected.severity}</span>
|
||||
<span className={`chip status-${selected.status}`}>{t(`status.${selected.status}`) || selected.status}</span>
|
||||
<span style={{color:'#6b7280',fontSize:12}}>{dayjs(selected.createdAt).fromNow()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-body">
|
||||
<p style={{marginTop:8}}><strong>{t('drawer.details') || 'Details'}</strong></p>
|
||||
{selected.notes ? <p>{selected.notes}</p> : <p style={{opacity:0.7}}>{t('drawer.noNotes') || 'No additional notes'}</p>}
|
||||
<p><strong>{t('label.location') || 'Location'}:</strong> {selected.location.lat.toFixed(5)}, {selected.location.lng.toFixed(5)}</p>
|
||||
<p><strong>{t('label.createdAt') || 'Created'}:</strong> {dayjs(selected.createdAt).format('YYYY-MM-DD HH:mm')}</p>
|
||||
</div>
|
||||
|
||||
<div className="drawer-actions">
|
||||
<button className="btn" onClick={()=>{ cycleStatus(selected.id); }}>
|
||||
{t('drawer.changeStatus') || 'Change Status'}
|
||||
</button>
|
||||
<button className="btn secondary" onClick={()=>openInMaps(selected)}>
|
||||
{t('drawer.openMap') || 'Open Map'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// mount
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
18
dashboard/data/demo-reports.json
Normal file
18
dashboard/data/demo-reports.json
Normal file
@@ -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"}
|
||||
]
|
||||
40
dashboard/i18n/en.json
Normal file
40
dashboard/i18n/en.json
Normal file
@@ -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"
|
||||
}
|
||||
40
dashboard/i18n/ms.json
Normal file
40
dashboard/i18n/ms.json
Normal file
@@ -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"
|
||||
}
|
||||
32
dashboard/index.html
Normal file
32
dashboard/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FixMate Dashboard</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
|
||||
<style>
|
||||
/* small inline to ensure map icon z-index etc maybe not needed */
|
||||
body { margin:0; font-family: Arial, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- Libraries -->
|
||||
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
||||
<script src="https://unpkg.com/dayjs/dayjs.min.js"></script>
|
||||
<script src="https://unpkg.com/dayjs/plugin/relativeTime.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.heat/dist/leaflet-heat.js"></script>
|
||||
|
||||
<!-- App -->
|
||||
<script type="text/babel" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
170
dashboard/styles.css
Normal file
170
dashboard/styles.css
Normal file
@@ -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}
|
||||
@@ -45,5 +45,13 @@
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
|
||||
<!-- Permissions required by FixMate -->
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Camera access is required to capture issue photos.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Photo library access allows selecting existing photos.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Location is used to attach a GPS position to your report.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
111
lib/app.dart
Normal file
111
lib/app.dart
Normal file
@@ -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<LocaleProvider>(
|
||||
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<MainScreen> createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends State<MainScreen> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
final List<Widget> _screens = [
|
||||
const CaptureScreen(),
|
||||
const MapScreen(),
|
||||
const MyReportsScreen(),
|
||||
const SettingsScreen(),
|
||||
];
|
||||
|
||||
final List<String> _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!'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
79
lib/l10n/i18n.dart
Normal file
79
lib/l10n/i18n.dart
Normal file
@@ -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<String, String> _localizedStrings = {};
|
||||
static const String _defaultLocale = 'en';
|
||||
static String _loadedLocale = _defaultLocale;
|
||||
|
||||
/// Initialize the i18n system with the given locale
|
||||
static Future<void> init(Locale locale) async {
|
||||
_loadedLocale = locale.languageCode;
|
||||
await _loadLanguage(locale.languageCode);
|
||||
}
|
||||
|
||||
/// Load language strings from JSON asset
|
||||
static Future<void> _loadLanguage(String languageCode) async {
|
||||
try {
|
||||
final String jsonString = await rootBundle.loadString(
|
||||
'assets/lang/$languageCode.json',
|
||||
);
|
||||
final Map<String, dynamic> 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<String, String>? 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<String> 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;
|
||||
}
|
||||
}
|
||||
101
lib/l10n/locale_provider.dart
Normal file
101
lib/l10n/locale_provider.dart
Normal file
@@ -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<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
final savedLanguage = _prefs.getString(_languageKey) ?? _defaultLanguage;
|
||||
_locale = Locale(savedLanguage);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set the locale and persist the change
|
||||
Future<void> setLocale(Locale locale) async {
|
||||
if (_locale == locale) return;
|
||||
|
||||
_locale = locale;
|
||||
await _prefs.setString(_languageKey, locale.languageCode);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Toggle between English and Bahasa Malaysia
|
||||
Future<void> toggleLanguage() async {
|
||||
final newLanguage = _locale.languageCode == 'en' ? 'ms' : 'en';
|
||||
await setLocale(Locale(newLanguage));
|
||||
}
|
||||
|
||||
/// Set language to English
|
||||
Future<void> setEnglish() async {
|
||||
await setLocale(const Locale('en'));
|
||||
}
|
||||
|
||||
/// Set language to Bahasa Malaysia
|
||||
Future<void> 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<Locale> get availableLocales => const [
|
||||
Locale('en'),
|
||||
Locale('ms'),
|
||||
];
|
||||
|
||||
/// Get available language codes
|
||||
List<String> get availableLanguageCodes => ['en', 'ms'];
|
||||
|
||||
/// Clear saved language preference
|
||||
Future<void> clearSavedLanguage() async {
|
||||
await _prefs.remove(_languageKey);
|
||||
_locale = const Locale(_defaultLanguage);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Reset to default language
|
||||
Future<void> resetToDefault() async {
|
||||
await setLocale(const Locale(_defaultLanguage));
|
||||
}
|
||||
}
|
||||
142
lib/main.dart
142
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<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
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: <Widget>[
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
206
lib/models/enums.dart
Normal file
206
lib/models/enums.dart
Normal file
@@ -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<Category> 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<Severity> 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<Status> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
300
lib/models/report.dart
Normal file
300
lib/models/report.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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);
|
||||
}
|
||||
543
lib/screens/map/map_screen.dart
Normal file
543
lib/screens/map/map_screen.dart
Normal file
@@ -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<MapScreen> createState() => _MapScreenState();
|
||||
}
|
||||
|
||||
class _MapScreenState extends State<MapScreen> {
|
||||
final MapController _mapController = MapController();
|
||||
|
||||
List<Report> _allReports = [];
|
||||
List<Report> _filteredReports = [];
|
||||
bool _loading = true;
|
||||
|
||||
// In-memory filters
|
||||
Set<Category> _filterCategories = Category.all.toSet();
|
||||
Set<Severity> _filterSeverities = Severity.all.toSet();
|
||||
Set<Status> _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<void> _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<void> _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<Marker> _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<void> _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<void> _openFilterModal() async {
|
||||
// Use current filters as initial values
|
||||
final now = DateTime.now();
|
||||
Set<Category> selCategories = Set.from(_filterCategories);
|
||||
Set<Severity> selSeverities = Set.from(_filterSeverities);
|
||||
Set<Status> 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}'),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/screens/my_reports/my_reports_screen.dart
Normal file
85
lib/screens/my_reports/my_reports_screen.dart
Normal file
@@ -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<MyReportsScreen> createState() => _MyReportsScreenState();
|
||||
}
|
||||
|
||||
class _MyReportsScreenState extends State<MyReportsScreen> {
|
||||
List<Report> _reports = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadReports();
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
166
lib/screens/report_flow/capture_screen.dart
Normal file
166
lib/screens/report_flow/capture_screen.dart
Normal file
@@ -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<CaptureScreen> createState() => _CaptureScreenState();
|
||||
}
|
||||
|
||||
class _CaptureScreenState extends State<CaptureScreen> {
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
bool _isLoading = false;
|
||||
|
||||
Future<void> _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<void> _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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
289
lib/screens/report_flow/review_screen.dart
Normal file
289
lib/screens/report_flow/review_screen.dart
Normal file
@@ -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<ReviewScreen> createState() => _ReviewScreenState();
|
||||
}
|
||||
|
||||
class _ReviewScreenState extends State<ReviewScreen> {
|
||||
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<void> _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;
|
||||
}
|
||||
}
|
||||
}
|
||||
134
lib/screens/settings/settings_screen.dart
Normal file
134
lib/screens/settings/settings_screen.dart
Normal file
@@ -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<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
StorageStats? _stats;
|
||||
bool _loadingStats = true;
|
||||
bool _clearing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStats();
|
||||
}
|
||||
|
||||
Future<void> _loadStats() async {
|
||||
setState(() {
|
||||
_loadingStats = true;
|
||||
});
|
||||
final stats = await StorageService.getStorageStats();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_stats = stats;
|
||||
_loadingStats = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmAndClearAll(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<LocaleProvider>(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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
202
lib/services/location_service.dart
Normal file
202
lib/services/location_service.dart
Normal file
@@ -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<bool> isLocationServiceEnabled() async {
|
||||
try {
|
||||
return await Geolocator.isLocationServiceEnabled();
|
||||
} catch (e) {
|
||||
print('Error checking location service: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check location permissions
|
||||
static Future<LocationPermission> checkPermission() async {
|
||||
try {
|
||||
return await Geolocator.checkPermission();
|
||||
} catch (e) {
|
||||
print('Error checking location permission: $e');
|
||||
return LocationPermission.denied;
|
||||
}
|
||||
}
|
||||
|
||||
/// Request location permissions
|
||||
static Future<LocationPermission> 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<Position?> 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<Position?> 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<bool> 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<String?> getAddressFromCoordinates(double lat, double lng) async {
|
||||
// This is a placeholder implementation
|
||||
// In a real app, you would use a geocoding service like Google Maps API
|
||||
// or OpenStreetMap Nominatim API
|
||||
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;
|
||||
}
|
||||
}
|
||||
116
lib/services/mock_ai.dart
Normal file
116
lib/services/mock_ai.dart
Normal file
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
231
lib/services/storage.dart
Normal file
231
lib/services/storage.dart
Normal file
@@ -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<List<Report>> getReports() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final reportsJson = prefs.getString(_reportsKey);
|
||||
|
||||
if (reportsJson == null || reportsJson.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<dynamic> 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<bool> 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<bool> 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<bool> 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<bool> _saveReportsList(List<Report> 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<File?> 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<bool> savePhotoFile(String reportId, List<int> 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<bool> _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<void> _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<StorageStats> 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<File>();
|
||||
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]}';
|
||||
}
|
||||
}
|
||||
136
lib/widgets/report_card.dart
Normal file
136
lib/widgets/report_card.dart
Normal file
@@ -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<Report>? 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<void> _confirmAndDelete(BuildContext context) async {
|
||||
final ok = await showDialog<bool>(
|
||||
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<void> _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<int>(
|
||||
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'))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/widgets/severity_badge.dart
Normal file
25
lib/widgets/severity_badge.dart
Normal file
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/widgets/status_badge.dart
Normal file
45
lib/widgets/status_badge.dart
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,14 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
575
pubspec.lock
575
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"
|
||||
|
||||
19
pubspec.yaml
19
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,15 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <geolocator_windows/geolocator_windows.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
GeolocatorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
geolocator_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Reference in New Issue
Block a user