feat(app,ui): add map-based location picker and geocoding

- introduce LocationPickerScreen with flutter_map for selecting and confirming
  locations, including markers and loading states
- integrate manual location editing in ReviewScreen and persist updated
  coordinates/address in the report
- add GeocodingService with Nominatim search, Photon fallback, reverse
  geocoding, debounced queries, caching, and timeouts
- update i18n (en) with new map/location strings; remove unused onboarding
  and welcome texts
This commit is contained in:
2025-09-27 12:55:36 +08:00
parent ed63a0cbc8
commit a8eae96f7f
9 changed files with 1266 additions and 28 deletions

View File

@@ -4,6 +4,7 @@ import '../../l10n/i18n.dart';
import '../../models/report.dart';
import '../../models/enums.dart';
import '../../services/storage.dart';
import 'location_picker_screen.dart';
class ReviewScreen extends StatefulWidget {
final Report report;
@@ -23,14 +24,16 @@ class _ReviewScreenState extends State<ReviewScreen> {
late Category _selectedCategory;
late Severity _selectedSeverity;
late TextEditingController _notesController;
late Report _currentReport;
bool _isSubmitting = false;
@override
void initState() {
super.initState();
_selectedCategory = widget.report.category;
_selectedSeverity = widget.report.severity;
_notesController = TextEditingController(text: widget.report.notes ?? '');
_currentReport = widget.report;
_selectedCategory = _currentReport.category;
_selectedSeverity = _currentReport.severity;
_notesController = TextEditingController(text: _currentReport.notes ?? '');
}
@override
@@ -46,7 +49,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
try {
// Update report with user selections
final updatedReport = widget.report.copyWith(
final updatedReport = _currentReport.copyWith(
category: _selectedCategory,
severity: _selectedSeverity,
notes: _notesController.text.isEmpty ? null : _notesController.text,
@@ -200,7 +203,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
Text(
I18n.t('review.aiConfidence', {
'0':
(widget.report.aiSuggestion.confidence *
(_currentReport.aiSuggestion.confidence *
100)
.round()
.toString(),
@@ -243,7 +246,10 @@ class _ReviewScreenState extends State<ReviewScreen> {
),
const SizedBox(width: 8),
Text(
widget.report.aiSuggestion.category.displayName,
_currentReport
.aiSuggestion
.category
.displayName,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
@@ -280,7 +286,10 @@ class _ReviewScreenState extends State<ReviewScreen> {
),
const SizedBox(width: 8),
Text(
widget.report.aiSuggestion.severity.displayName,
_currentReport
.aiSuggestion
.severity
.displayName,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
@@ -300,9 +309,9 @@ class _ReviewScreenState extends State<ReviewScreen> {
onPressed: () {
setState(() {
_selectedCategory =
widget.report.aiSuggestion.category;
_currentReport.aiSuggestion.category;
_selectedSeverity =
widget.report.aiSuggestion.severity;
_currentReport.aiSuggestion.severity;
});
},
icon: const Icon(Icons.check_circle, size: 18),
@@ -320,7 +329,28 @@ class _ReviewScreenState extends State<ReviewScreen> {
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () {},
onPressed: () {
// Navigate to manual editing screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LocationPickerScreen(
initialLocation: _currentReport.location,
initialAddress: _currentReport.address,
),
),
).then((result) {
if (result != null && mounted) {
setState(() {
// Update the report with new location
_currentReport = _currentReport.copyWith(
location: result['location'],
address: result['address'],
);
});
}
});
},
icon: const Icon(Icons.edit, size: 18),
label: Text(I18n.t('review.editManually')),
style: OutlinedButton.styleFrom(
@@ -615,6 +645,46 @@ class _ReviewScreenState extends State<ReviewScreen> {
color: cs.onSurface,
),
),
const Spacer(),
TextButton.icon(
onPressed: () {
// Navigate to location picker
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LocationPickerScreen(
initialLocation: _currentReport.location,
initialAddress: _currentReport.address,
),
),
).then((result) {
if (result != null && mounted) {
setState(() {
// Update the report with new location
_currentReport = _currentReport.copyWith(
location: result['location'],
address: result['address'],
);
});
}
});
},
icon: Icon(Icons.edit, size: 16, color: cs.primary),
label: Text(
I18n.t('btn.editLocation'),
style: TextStyle(
color: cs.primary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
),
),
],
),
const SizedBox(height: 16),
@@ -647,14 +717,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
),
const SizedBox(height: 8),
Text(
'${widget.report.location.lat.toStringAsFixed(6)}, ${widget.report.location.lng.toStringAsFixed(6)}',
'${_currentReport.location.lat.toStringAsFixed(6)}, ${_currentReport.location.lng.toStringAsFixed(6)}',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: cs.onSurface,
fontFamily: 'monospace',
),
),
if (widget.report.location.accuracy != null) ...[
if (_currentReport.location.accuracy != null) ...[
const SizedBox(height: 12),
Row(
children: [
@@ -666,7 +736,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
const SizedBox(width: 8),
Text(
I18n.t('review.accuracy', {
'0': widget.report.location.accuracy!
'0': _currentReport.location.accuracy!
.toStringAsFixed(1),
}),
style: Theme.of(context).textTheme.bodySmall