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:
2025-09-25 18:38:18 +08:00
parent d16e56bdcf
commit 6518df8ac1
39 changed files with 4377 additions and 162 deletions

3
.gitignore vendored
View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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>

View File

@@ -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
View 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
View 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
View 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 />);

View 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
View 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
View 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
View 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
View 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}

View File

@@ -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
View 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
View 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;
}
}

View 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));
}
}

View File

@@ -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
View 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
View 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);
}

View 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}'),
]),
),
);
}
}

View 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,
);
},
),
),
);
}
}

View 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),
),
),
),
],
],
),
),
);
}
}

View 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;
}
}
}

View 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'),
),
],
),
);
}
}

View 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
View 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
View 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]}';
}
}

View 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'))),
],
),
),
);
}
}

View 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),
),
);
}
}

View 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;
}
}
}

View File

@@ -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);
}

View File

@@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -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"))
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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);
});
}
}

View File

@@ -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"));
}

View File

@@ -3,6 +3,9 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
geolocator_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST