Give the map the whole screen and move the credits into settings

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>
This commit is contained in:
2026-09-10 22:21:50 +02:00
co-authored by Claude Opus 5
parent b4b8d089e6
commit aa91b457ed
11 changed files with 227 additions and 213 deletions
@@ -1,92 +0,0 @@
import 'package:flutter/material.dart';
import '../../core/region/region_config.dart';
import '../../l10n/app_localizations.dart';
import '../sources/sources_screen.dart';
/// Always-visible credits for whatever is currently drawn on the map.
///
/// This is a licence obligation, not decoration: OpenStreetMap's ODbL and
/// Radar-DPC's CC BY-SA both require the credit to be shown wherever the data
/// is. It is therefore never collapsed, hidden behind a gesture, or covered by
/// another control, and it lists only the sources actually on screen — crediting
/// OpenStreetMap while showing the offline fallback would be a false claim.
class AttributionBar extends StatelessWidget {
const AttributionBar({
required this.region,
required this.activeSourceIds,
this.additionalCredits = const <String>[],
this.showBaseMapNotice = false,
super.key,
});
final RegionConfig region;
/// Attribution ids, matching `attributions[].id` in the region config, for
/// the data currently rendered.
final Set<String> activeSourceIds;
/// Credits that travel with the data rather than with the region, such as the
/// attribution line inside a radar manifest. Whoever published those frames
/// states there who to credit for them, which is more current than anything
/// baked into the app.
final List<String> additionalCredits;
/// Whether to say that no base map is configured. True when the offline
/// fallback style is in use, so the placeholder is never mistaken for a map.
final bool showBaseMapNotice;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final credits = region.attributions
.where((attribution) => activeSourceIds.contains(attribution.id))
.map((attribution) => attribution.text)
.toList(growable: false);
final parts = <String>[
if (showBaseMapNotice) l10n.baseMapNotConfigured,
...credits,
...additionalCredits.where((credit) => credit.isNotEmpty),
];
return Material(
color: theme.colorScheme.surface.withValues(alpha: 0.85),
child: InkWell(
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const SourcesScreen())),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
Expanded(
child: Text(
parts.join(' · '),
style: theme.textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
l10n.sourcesLink,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.primary,
),
],
),
),
),
);
}
}
@@ -165,14 +165,20 @@ class TargetSelector extends ConsumerWidget {
}
}
/// Search and recentre, stacked over the right edge of the map.
/// 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;
@@ -192,6 +198,13 @@ class MapActionButtons extends ConsumerWidget {
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,
@@ -22,7 +22,6 @@ import '../settings/settings_screen.dart';
import '../timeline/data_age_banner.dart';
import '../timeline/radar_timeline.dart';
import '../timeline/timeline_bar.dart';
import 'attribution_bar.dart';
import 'map_overlay_controls.dart';
import 'map_style.dart';
import 'radar_overlay.dart';
@@ -36,23 +35,13 @@ class RadarMapScreen extends ConsumerWidget {
final l10n = AppLocalizations.of(context);
final region = ref.watch(regionConfigProvider);
// No app bar. Its title was just the app name, which the launcher already
// shows, and its one action now lives over the map with the other controls.
// The map runs full-bleed to the top; the overlaid controls carry their own
// SafeArea.
return Scaffold(
appBar: AppBar(
title: Text(l10n.appTitle),
// Only settings here. Everything that changes what the map is looking
// at lives over the map itself, next to the thing it acts on.
actions: [
IconButton(
tooltip: l10n.settingsOpen,
icon: const Icon(Icons.settings_outlined),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
),
),
],
),
body: switch (region) {
AsyncData(:final value) => _MapWithAttribution(region: value),
AsyncData(:final value) => _MapBody(region: value),
AsyncError(:final error) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
@@ -65,8 +54,8 @@ class RadarMapScreen extends ConsumerWidget {
}
}
class _MapWithAttribution extends ConsumerWidget {
const _MapWithAttribution({required this.region});
class _MapBody extends ConsumerWidget {
const _MapBody({required this.region});
final RegionConfig region;
@@ -74,40 +63,22 @@ class _MapWithAttribution extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final style = MapStyle.forRegion(region);
final manifest = ref.watch(
radarTimelineProvider.select((state) => state.manifest),
);
return Column(
children: [
if (Env.isDemoMode)
MaterialBanner(
content: Text(l10n.demoModeBanner),
actions: const [SizedBox.shrink()],
SafeArea(
bottom: false,
child: MaterialBanner(
content: Text(l10n.demoModeBanner),
actions: const [SizedBox.shrink()],
),
),
Expanded(
child: _RegionMap(region: region, style: style),
),
const DataAgeBanner(),
const TimelineBar(),
// Outside the map rather than floating over it, so the credit can never
// be occluded by a map control. SafeArea keeps it clear of the system
// gesture bar as well — a credit sitting behind the navigation pill is
// a credit that is not being displayed.
SafeArea(
top: false,
child: AttributionBar(
region: region,
activeSourceIds: style.attributionIds,
showBaseMapNotice: style.kind == BaseMapKind.offlineFallback,
// The manifest carries the credit for the frames it indexes, which
// is the whole point of it travelling with the data: whoever
// published these frames says here who to credit for them.
additionalCredits: <String>[
if (manifest != null) manifest.attribution,
],
),
),
],
);
}
@@ -381,7 +352,9 @@ class _RegionMapState extends ConsumerState<_RegionMap> {
northeast: LatLng(bounds.north, bounds.east),
),
),
attributionButtonPosition: AttributionButtonPosition.bottomRight,
// Top right: the bottom-right corner now holds our own control
// column, and the plugin's button was landing underneath it.
attributionButtonPosition: AttributionButtonPosition.topRight,
compassEnabled: false,
rotateGesturesEnabled: false,
tiltGesturesEnabled: false,
@@ -426,6 +399,9 @@ class _RegionMapState extends ConsumerState<_RegionMap> {
right: 12,
bottom: 12,
child: MapActionButtons(
onSettings: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
),
onSearch: () => unawaited(_openSearch()),
onRecentre: () => unawaited(_recentre()),
),