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:
206
lib/models/enums.dart
Normal file
206
lib/models/enums.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Categories for different types of issues that can be reported
|
||||
enum Category {
|
||||
pothole,
|
||||
streetlight,
|
||||
signage,
|
||||
trash,
|
||||
drainage,
|
||||
other;
|
||||
|
||||
/// Get the string key for localization
|
||||
String get key {
|
||||
switch (this) {
|
||||
case Category.pothole:
|
||||
return 'category.pothole';
|
||||
case Category.streetlight:
|
||||
return 'category.streetlight';
|
||||
case Category.signage:
|
||||
return 'category.signage';
|
||||
case Category.trash:
|
||||
return 'category.trash';
|
||||
case Category.drainage:
|
||||
return 'category.drainage';
|
||||
case Category.other:
|
||||
return 'category.other';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the display name for this category
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case Category.pothole:
|
||||
return 'Pothole';
|
||||
case Category.streetlight:
|
||||
return 'Streetlight';
|
||||
case Category.signage:
|
||||
return 'Signage';
|
||||
case Category.trash:
|
||||
return 'Trash';
|
||||
case Category.drainage:
|
||||
return 'Drainage';
|
||||
case Category.other:
|
||||
return 'Other';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all categories as a list
|
||||
static List<Category> get all => Category.values;
|
||||
}
|
||||
|
||||
/// Severity levels for reported issues
|
||||
enum Severity {
|
||||
high,
|
||||
medium,
|
||||
low;
|
||||
|
||||
/// Get the string key for localization
|
||||
String get key {
|
||||
switch (this) {
|
||||
case Severity.high:
|
||||
return 'severity.high';
|
||||
case Severity.medium:
|
||||
return 'severity.medium';
|
||||
case Severity.low:
|
||||
return 'severity.low';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the display name for this severity
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case Severity.high:
|
||||
return 'High';
|
||||
case Severity.medium:
|
||||
return 'Medium';
|
||||
case Severity.low:
|
||||
return 'Low';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the color associated with this severity
|
||||
Color get color {
|
||||
switch (this) {
|
||||
case Severity.high:
|
||||
return const Color(0xFFD32F2F); // Red 700
|
||||
case Severity.medium:
|
||||
return const Color(0xFFF57C00); // Orange 700
|
||||
case Severity.low:
|
||||
return const Color(0xFF388E3C); // Green 700
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all severities as a list
|
||||
static List<Severity> get all => Severity.values;
|
||||
}
|
||||
|
||||
/// Status of reported issues
|
||||
enum Status {
|
||||
submitted,
|
||||
inProgress,
|
||||
fixed;
|
||||
|
||||
/// Get the string key for localization
|
||||
String get key {
|
||||
switch (this) {
|
||||
case Status.submitted:
|
||||
return 'status.submitted';
|
||||
case Status.inProgress:
|
||||
return 'status.in_progress';
|
||||
case Status.fixed:
|
||||
return 'status.fixed';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the display name for this status
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case Status.submitted:
|
||||
return 'Submitted';
|
||||
case Status.inProgress:
|
||||
return 'In Progress';
|
||||
case Status.fixed:
|
||||
return 'Fixed';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the color associated with this status
|
||||
Color get color {
|
||||
switch (this) {
|
||||
case Status.submitted:
|
||||
return const Color(0xFF1976D2); // Blue 700
|
||||
case Status.inProgress:
|
||||
return const Color(0xFF7B1FA2); // Purple 700
|
||||
case Status.fixed:
|
||||
return const Color(0xFF455A64); // Blue Grey 700
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next status in the cycle
|
||||
Status get next {
|
||||
switch (this) {
|
||||
case Status.submitted:
|
||||
return Status.inProgress;
|
||||
case Status.inProgress:
|
||||
return Status.fixed;
|
||||
case Status.fixed:
|
||||
return Status.submitted; // Cycle back to submitted
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all statuses as a list
|
||||
static List<Status> get all => Status.values;
|
||||
}
|
||||
|
||||
/// Helper extensions for enum parsing
|
||||
extension CategoryParsing on String {
|
||||
Category? toCategory() {
|
||||
switch (toLowerCase()) {
|
||||
case 'pothole':
|
||||
return Category.pothole;
|
||||
case 'streetlight':
|
||||
return Category.streetlight;
|
||||
case 'signage':
|
||||
return Category.signage;
|
||||
case 'trash':
|
||||
return Category.trash;
|
||||
case 'drainage':
|
||||
return Category.drainage;
|
||||
case 'other':
|
||||
return Category.other;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SeverityParsing on String {
|
||||
Severity? toSeverity() {
|
||||
switch (toLowerCase()) {
|
||||
case 'high':
|
||||
return Severity.high;
|
||||
case 'medium':
|
||||
return Severity.medium;
|
||||
case 'low':
|
||||
return Severity.low;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusParsing on String {
|
||||
Status? toStatus() {
|
||||
switch (toLowerCase()) {
|
||||
case 'submitted':
|
||||
return Status.submitted;
|
||||
case 'in_progress':
|
||||
return Status.inProgress;
|
||||
case 'fixed':
|
||||
return Status.fixed;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
300
lib/models/report.dart
Normal file
300
lib/models/report.dart
Normal file
@@ -0,0 +1,300 @@
|
||||
import 'dart:math';
|
||||
import 'enums.dart';
|
||||
|
||||
/// Represents a citizen report for community issues
|
||||
class Report {
|
||||
/// Unique identifier for the report
|
||||
final String id;
|
||||
|
||||
/// Category of the issue
|
||||
final Category category;
|
||||
|
||||
/// Severity level of the issue
|
||||
final Severity severity;
|
||||
|
||||
/// Current status of the report
|
||||
final Status status;
|
||||
|
||||
/// File path to the photo on mobile devices
|
||||
final String? photoPath;
|
||||
|
||||
/// Base64 encoded photo for web platform
|
||||
final String? base64Photo;
|
||||
|
||||
/// Geographic location where the issue was reported
|
||||
final LocationData location;
|
||||
|
||||
/// When the report was created (ISO string)
|
||||
final String createdAt;
|
||||
|
||||
/// When the report was last updated (ISO string)
|
||||
final String updatedAt;
|
||||
|
||||
/// Unique device identifier
|
||||
final String deviceId;
|
||||
|
||||
/// Optional notes from the user
|
||||
final String? notes;
|
||||
|
||||
/// Address or location description (placeholder for future use)
|
||||
final String? address;
|
||||
|
||||
/// Source of the photo ("camera" or "gallery")
|
||||
final String source;
|
||||
|
||||
/// Whether the report can be edited
|
||||
final bool editable;
|
||||
|
||||
/// Whether the report can be deleted
|
||||
final bool deletable;
|
||||
|
||||
/// AI suggestion for category and severity
|
||||
final AISuggestion aiSuggestion;
|
||||
|
||||
/// Schema version for data migration
|
||||
final int schemaVersion;
|
||||
|
||||
const Report({
|
||||
required this.id,
|
||||
required this.category,
|
||||
required this.severity,
|
||||
required this.status,
|
||||
this.photoPath,
|
||||
this.base64Photo,
|
||||
required this.location,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.deviceId,
|
||||
this.notes,
|
||||
this.address,
|
||||
required this.source,
|
||||
this.editable = true,
|
||||
this.deletable = true,
|
||||
required this.aiSuggestion,
|
||||
this.schemaVersion = 1,
|
||||
});
|
||||
|
||||
/// Generate a simple unique ID
|
||||
static String _generateId() {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final random = Random().nextInt(999999);
|
||||
return '$timestamp$random';
|
||||
}
|
||||
|
||||
/// Create a new report with current timestamp and generated ID
|
||||
factory Report.create({
|
||||
required Category category,
|
||||
required Severity severity,
|
||||
required LocationData location,
|
||||
String? photoPath,
|
||||
String? base64Photo,
|
||||
String? notes,
|
||||
required String source,
|
||||
required String deviceId,
|
||||
required AISuggestion aiSuggestion,
|
||||
}) {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
return Report(
|
||||
id: _generateId(),
|
||||
category: category,
|
||||
severity: severity,
|
||||
status: Status.submitted,
|
||||
photoPath: photoPath,
|
||||
base64Photo: base64Photo,
|
||||
location: location,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deviceId: deviceId,
|
||||
notes: notes,
|
||||
source: source,
|
||||
aiSuggestion: aiSuggestion,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of this report with updated fields
|
||||
Report copyWith({
|
||||
Category? category,
|
||||
Severity? severity,
|
||||
Status? status,
|
||||
String? photoPath,
|
||||
String? base64Photo,
|
||||
LocationData? location,
|
||||
String? updatedAt,
|
||||
String? notes,
|
||||
String? address,
|
||||
bool? editable,
|
||||
bool? deletable,
|
||||
AISuggestion? aiSuggestion,
|
||||
}) {
|
||||
return Report(
|
||||
id: id,
|
||||
category: category ?? this.category,
|
||||
severity: severity ?? this.severity,
|
||||
status: status ?? this.status,
|
||||
photoPath: photoPath ?? this.photoPath,
|
||||
base64Photo: base64Photo ?? this.base64Photo,
|
||||
location: location ?? this.location,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
deviceId: deviceId,
|
||||
notes: notes ?? this.notes,
|
||||
address: address ?? this.address,
|
||||
source: source,
|
||||
editable: editable ?? this.editable,
|
||||
deletable: deletable ?? this.deletable,
|
||||
aiSuggestion: aiSuggestion ?? this.aiSuggestion,
|
||||
schemaVersion: schemaVersion,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON for storage
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'category': category.name,
|
||||
'severity': severity.name,
|
||||
'status': status.key,
|
||||
'photoPath': photoPath,
|
||||
'base64Photo': base64Photo,
|
||||
'location': {
|
||||
'lat': location.lat,
|
||||
'lng': location.lng,
|
||||
'accuracy': location.accuracy,
|
||||
},
|
||||
'createdAt': createdAt,
|
||||
'updatedAt': updatedAt,
|
||||
'deviceId': deviceId,
|
||||
'notes': notes,
|
||||
'address': address,
|
||||
'source': source,
|
||||
'editable': editable,
|
||||
'deletable': deletable,
|
||||
'aiSuggestion': {
|
||||
'category': aiSuggestion.category.name,
|
||||
'severity': aiSuggestion.severity.name,
|
||||
'confidence': aiSuggestion.confidence,
|
||||
},
|
||||
'schemaVersion': schemaVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create from JSON for loading from storage
|
||||
factory Report.fromJson(Map<String, dynamic> json) {
|
||||
return Report(
|
||||
id: json['id'] as String,
|
||||
category: (json['category'] as String).toCategory() ?? Category.other,
|
||||
severity: (json['severity'] as String).toSeverity() ?? Severity.medium,
|
||||
status: (json['status'] as String).toStatus() ?? Status.submitted,
|
||||
photoPath: json['photoPath'] as String?,
|
||||
base64Photo: json['base64Photo'] as String?,
|
||||
location: LocationData(
|
||||
lat: (json['location']['lat'] as num).toDouble(),
|
||||
lng: (json['location']['lng'] as num).toDouble(),
|
||||
accuracy: json['location']['accuracy'] == null
|
||||
? null
|
||||
: (json['location']['accuracy'] as num).toDouble(),
|
||||
),
|
||||
createdAt: json['createdAt'] as String,
|
||||
updatedAt: json['updatedAt'] as String,
|
||||
deviceId: json['deviceId'] as String,
|
||||
notes: json['notes'] as String?,
|
||||
address: json['address'] as String?,
|
||||
source: json['source'] as String,
|
||||
editable: json['editable'] as bool? ?? true,
|
||||
deletable: json['deletable'] as bool? ?? true,
|
||||
aiSuggestion: AISuggestion(
|
||||
category: (json['aiSuggestion']['category'] as String).toCategory() ?? Category.other,
|
||||
severity: (json['aiSuggestion']['severity'] as String).toSeverity() ?? Severity.medium,
|
||||
confidence: (json['aiSuggestion']['confidence'] as num).toDouble(),
|
||||
),
|
||||
schemaVersion: json['schemaVersion'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Report(id: $id, category: ${category.name}, severity: ${severity.name}, status: ${status.name})';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Report && other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// Represents geographic location data
|
||||
class LocationData {
|
||||
/// Latitude coordinate
|
||||
final double lat;
|
||||
|
||||
/// Longitude coordinate
|
||||
final double lng;
|
||||
|
||||
/// Accuracy of the location in meters (optional)
|
||||
final double? accuracy;
|
||||
|
||||
const LocationData({
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
this.accuracy,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LocationData(lat: $lat, lng: $lng, accuracy: $accuracy)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is LocationData &&
|
||||
other.lat == lat &&
|
||||
other.lng == lng &&
|
||||
other.accuracy == accuracy;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(lat, lng, accuracy);
|
||||
}
|
||||
|
||||
/// Represents AI suggestion for category and severity
|
||||
class AISuggestion {
|
||||
/// Suggested category
|
||||
final Category category;
|
||||
|
||||
/// Suggested severity
|
||||
final Severity severity;
|
||||
|
||||
/// Confidence score between 0.0 and 1.0
|
||||
final double confidence;
|
||||
|
||||
const AISuggestion({
|
||||
required this.category,
|
||||
required this.severity,
|
||||
required this.confidence,
|
||||
});
|
||||
|
||||
/// Check if confidence is high enough to be considered reliable
|
||||
bool get isReliable => confidence >= 0.7;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AISuggestion(category: ${category.name}, severity: ${severity.name}, confidence: ${confidence.toStringAsFixed(2)})';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is AISuggestion &&
|
||||
other.category == category &&
|
||||
other.severity == severity &&
|
||||
other.confidence == confidence;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(category, severity, confidence);
|
||||
}
|
||||
Reference in New Issue
Block a user