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

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