Files
citypulse/dashboard/app.js
Zahar 6518df8ac1 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
2025-09-25 18:38:18 +08:00

397 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 />);