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:
289
lib/screens/report_flow/review_screen.dart
Normal file
289
lib/screens/report_flow/review_screen.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user