import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/region/region_config.dart'; import '../../l10n/app_localizations.dart'; import '../places/map_target.dart'; import '../places/saved_place.dart'; import '../places/saved_places.dart'; /// Identifies an entry in the target menu. /// /// A sealed value rather than a raw string so a typo cannot silently become a /// menu entry that does nothing. sealed class _TargetChoice { const _TargetChoice(); } class _ChooseFollow extends _TargetChoice { const _ChooseFollow(); } class _ChooseRegion extends _TargetChoice { const _ChooseRegion(); } class _ChoosePlace extends _TargetChoice { const _ChoosePlace(this.place); final SavedPlace place; } class _ChooseSearch extends _TargetChoice { const _ChooseSearch(); } class _ChooseManage extends _TargetChoice { const _ChooseManage(); } /// The target selector that sits over the top of the map. /// /// Over the map rather than in the app bar because it is a map control: it /// changes what the map is looking at, and belongs next to the thing it acts on. class TargetSelector extends ConsumerWidget { const TargetSelector({ required this.region, required this.onFollowRequested, required this.onSearch, required this.onManagePlaces, super.key, }); final RegionConfig region; /// Following needs location permission, and asking for it — with the /// disclosure that has to come first — is not a menu's job. The screen that /// owns the map handles it and only then changes the target. final VoidCallback onFollowRequested; final VoidCallback onSearch; final VoidCallback onManagePlaces; @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final target = ref.watch(mapTargetProvider); final places = ref.watch(savedPlacesProvider).value ?? const []; final label = switch (target) { FollowUser() => l10n.targetFollowMe, PlaceTarget(:final place) => place.name, FreeTarget() => l10n.targetWholeRegion(region.displayName), }; final icon = switch (target) { FollowUser() => Icons.my_location, PlaceTarget() => Icons.place, FreeTarget() => Icons.map_outlined, }; return Material( elevation: 3, borderRadius: BorderRadius.circular(24), color: theme.colorScheme.surface, child: PopupMenuButton<_TargetChoice>( tooltip: l10n.targetChoose, position: PopupMenuPosition.under, onSelected: (choice) async { switch (choice) { case _ChooseFollow(): onFollowRequested(); case _ChooseRegion(): await ref .read(mapTargetProvider.notifier) .select(const FreeTarget()); case _ChoosePlace(:final place): await ref .read(mapTargetProvider.notifier) .select(PlaceTarget(place)); case _ChooseSearch(): onSearch(); case _ChooseManage(): onManagePlaces(); } }, itemBuilder: (context) => >[ CheckedPopupMenuItem<_TargetChoice>( value: const _ChooseFollow(), checked: target is FollowUser, child: Text(l10n.targetFollowMe), ), CheckedPopupMenuItem<_TargetChoice>( value: const _ChooseRegion(), checked: target is FreeTarget, child: Text(l10n.targetWholeRegion(region.displayName)), ), if (places.isNotEmpty) const PopupMenuDivider(), for (final place in places) CheckedPopupMenuItem<_TargetChoice>( value: _ChoosePlace(place), checked: target is PlaceTarget && target.place.id == place.id, child: Text(place.name, overflow: TextOverflow.ellipsis), ), const PopupMenuDivider(), PopupMenuItem<_TargetChoice>( value: const _ChooseSearch(), child: ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.search), title: Text(l10n.searchTitle), ), ), PopupMenuItem<_TargetChoice>( value: const _ChooseManage(), child: ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.place_outlined), title: Text(l10n.placesTitle), ), ), ], child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 18, color: theme.colorScheme.primary), const SizedBox(width: 8), Flexible( child: Text( label, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall, ), ), const SizedBox(width: 4), const Icon(Icons.arrow_drop_down, size: 20), ], ), ), ), ); } } /// Settings, search and recentre, stacked over the right edge of the map. class MapActionButtons extends ConsumerWidget { const MapActionButtons({ required this.onSettings, required this.onSearch, required this.onRecentre, super.key, }); /// Settings is also where the data credits live, so this button is the route /// the OSM attribution guidelines require: the licence information stays /// reachable from the map through a clearly labelled menu. final VoidCallback onSettings; final VoidCallback onSearch; final VoidCallback onRecentre; @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final target = ref.watch(mapTargetProvider); // The recentre button says what it will do. Following but panned away is // the case that matters: the icon has to promise the blue dot, not a place. final recentreIcon = switch (target) { FollowUser() => Icons.my_location, PlaceTarget() => Icons.place, FreeTarget() => Icons.zoom_out_map, }; return Column( mainAxisSize: MainAxisSize.min, children: [ FloatingActionButton.small( heroTag: 'nuvolari-settings', tooltip: l10n.settingsOpen, onPressed: onSettings, child: const Icon(Icons.settings_outlined), ), const SizedBox(height: 10), FloatingActionButton.small( heroTag: 'nuvolari-search', tooltip: l10n.searchOpen, onPressed: onSearch, child: const Icon(Icons.search), ), const SizedBox(height: 10), FloatingActionButton.small( heroTag: 'nuvolari-recentre', tooltip: l10n.mapCentreOnTarget, onPressed: onRecentre, child: Icon(recentreIcon), ), ], ); } }