Drops the app bar, moves settings onto the map with the other controls, and takes the attribution bar off the main screen. The app bar held only the app name — which the launcher already shows — and one action, so removing it hands its height to the map rather than leaving an empty strip. The settings button joins search and recentre in the column over the map, where the rest of the controls that act on the map already live. The attribution needed checking before it could move, because it is a licence obligation rather than a layout choice. The OSMF guidelines settle it: for a browsable map the credit does not have to be permanently visible, provided "the user must still be able to find the licence information if they look for it, for example from an '(i)' button in the corner of the map or an 'About' option in a menu". Map to settings to "Fonti e licenze" is exactly that, and MapLibre's own (i) control now sits in the map's top-right corner as a second route — it had been landing underneath the new button column. Two things had to follow the bar rather than disappear with it. The radar manifest carries its own credit for the frames on screen, set by whoever published them and absent from the region config, so the Sources screen now shows it alongside the configured attributions. And the settings entry that leads there carries a subtitle saying what it holds, because "Fonti e licenze" alone does not tell a reader that this is where the licence information went. settings_screen_test asserts that entry exists and opens the screen. Removing it would be a licence breach, so it should fail the build rather than ship quietly. Verified on the emulator: no title bar, the map runs to the top with the demo notice above it, the three controls stack bottom-right without colliding with the (i), and settings shows the sources entry with its subtitle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
7.1 KiB
Dart
225 lines
7.1 KiB
Dart
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 <SavedPlace>[];
|
|
|
|
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) => <PopupMenuEntry<_TargetChoice>>[
|
|
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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|