import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/region/geo.dart'; import '../../l10n/app_localizations.dart'; import 'location_flow.dart'; import 'saved_place.dart'; import 'saved_places.dart'; /// The saved places list. /// /// Returns the chosen [SavedPlace] to the caller when one is tapped, so the map /// can centre on it without this screen knowing anything about the map. class PlacesScreen extends ConsumerWidget { const PlacesScreen({required this.mapCentre, super.key}); /// Where the map is looking right now, offered as a one-tap thing to save. final GeoPoint mapCentre; @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final places = ref.watch(savedPlacesProvider); return Scaffold( appBar: AppBar(title: Text(l10n.placesTitle)), body: switch (places) { AsyncData(:final value) => _PlacesList( places: value, mapCentre: mapCentre, ), AsyncError(:final error) => Center( child: Padding( padding: const EdgeInsets.all(24), child: Text('${l10n.dataUnavailable}\n$error'), ), ), _ => Center(child: Text(l10n.loading)), }, ); } } class _PlacesList extends ConsumerWidget { const _PlacesList({required this.places, required this.mapCentre}); final List places; final GeoPoint mapCentre; @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final isFull = places.length >= SavedPlaces.maxPlaces; return Column( children: [ Expanded( child: places.isEmpty ? _EmptyState() : ListView.separated( itemCount: places.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) => _PlaceTile(place: places[index]), ), ), if (isFull) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( l10n.placesFull(SavedPlaces.maxPlaces), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ), SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( children: [ FilledButton.icon( onPressed: isFull ? null : () => _addFromDevicePosition(context, ref), icon: const Icon(Icons.my_location), label: Text(l10n.placeAddCurrent), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: isFull ? null : () => _promptAndAdd(context, ref, mapCentre), icon: const Icon(Icons.center_focus_strong), label: Text(l10n.placeAddMapCentre), ), ], ), ), ), ], ); } Future _addFromDevicePosition( BuildContext context, WidgetRef ref, ) async { final point = await requestPosition(context, ref); if (point == null || !context.mounted) return; await _promptAndAdd(context, ref, point); } } class _EmptyState extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.place_outlined, size: 48, color: theme.colorScheme.outline, ), const SizedBox(height: 16), Text(l10n.placesEmpty, style: theme.textTheme.titleMedium), const SizedBox(height: 8), Text( l10n.placesEmptyHint, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.outline, ), ), ], ), ), ); } } class _PlaceTile extends ConsumerWidget { const _PlaceTile({required this.place}); final SavedPlace place; @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); return ListTile( leading: const Icon(Icons.place), title: Text(place.name), subtitle: Text(formatPoint(place.point)), onTap: () => Navigator.of(context).pop(place), trailing: PopupMenuButton( onSelected: (value) async { if (value == 'rename') { await _rename(context, ref, place); } else if (value == 'delete') { await _delete(context, ref, place); } }, itemBuilder: (context) => [ PopupMenuItem(value: 'rename', child: Text(l10n.placeRename)), PopupMenuItem(value: 'delete', child: Text(l10n.placeDelete)), ], ), ); } } /// Coordinates at a precision that matches how they were obtained. /// /// Four decimals is about 10 m, which is finer than the coarse fix the app asks /// for and finer than anyone needs to read. More digits would imply an accuracy /// the app does not have. String formatPoint(GeoPoint point) => '${point.latitude.toStringAsFixed(4)}, ${point.longitude.toStringAsFixed(4)}'; Future _promptAndAdd( BuildContext context, WidgetRef ref, GeoPoint point, ) async { final name = await _askForName(context); if (name == null || !context.mounted) return; try { await ref.read(savedPlacesProvider.notifier).add(name: name, point: point); } on SavedPlaceException catch (error) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(messageFor(AppLocalizations.of(context), error))), ); } } Future _rename( BuildContext context, WidgetRef ref, SavedPlace place, ) async { final name = await _askForName(context, initial: place.name); if (name == null || !context.mounted) return; try { await ref.read(savedPlacesProvider.notifier).rename(place.id, name); } on SavedPlaceException catch (error) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(messageFor(AppLocalizations.of(context), error))), ); } } Future _delete( BuildContext context, WidgetRef ref, SavedPlace place, ) async { final l10n = AppLocalizations.of(context); await ref.read(savedPlacesProvider.notifier).remove(place.id); if (!context.mounted) return; ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(l10n.placeDeleted(place.name)))); } Future _askForName(BuildContext context, {String? initial}) { final l10n = AppLocalizations.of(context); final controller = TextEditingController(text: initial); return showDialog( context: context, builder: (context) => AlertDialog( title: Text(initial == null ? l10n.placeAddMapCentre : l10n.placeRename), content: TextField( controller: controller, autofocus: true, maxLength: SavedPlace.maxNameLength, decoration: InputDecoration( labelText: l10n.placeNameLabel, hintText: l10n.placeNameHint, ), onSubmitted: (value) => Navigator.of(context).pop(value), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(l10n.cancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(controller.text), child: Text(l10n.save), ), ], ), ); } /// Turns a rejection into the sentence that explains it. String messageFor(AppLocalizations l10n, SavedPlaceException error) => switch (error.rejection) { SavedPlaceRejection.emptyName => l10n.placeErrorEmptyName, SavedPlaceRejection.nameTooLong => l10n.placeErrorNameTooLong( SavedPlace.maxNameLength, ), SavedPlaceRejection.duplicateName => l10n.placeErrorDuplicateName, SavedPlaceRejection.outsideRegion => l10n.placeErrorOutsideRegion, };