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

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