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>
205 lines
6.0 KiB
Dart
205 lines
6.0 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../../core/region/region_config.dart';
|
|
import '../../core/region/region_repository.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../timeline/radar_timeline.dart';
|
|
|
|
/// Sources, licences and the independence disclaimer.
|
|
///
|
|
/// Reached from Settings, which is one tap from the map. That path is what the
|
|
/// OSM attribution guidelines call for: the credit does not have to sit on the
|
|
/// map permanently, but it must stay findable from it — "an (i) button in the
|
|
/// corner of the map or an About option in a menu".
|
|
///
|
|
/// The content is driven by the region config, so a new region cannot ship
|
|
/// without its credits, plus the credit the radar manifest carries for the
|
|
/// frames actually on screen.
|
|
class SourcesScreen extends ConsumerWidget {
|
|
const SourcesScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final region = ref.watch(regionConfigProvider);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(l10n.sourcesTitle)),
|
|
body: switch (region) {
|
|
AsyncData(:final value) => _SourcesBody(region: value),
|
|
AsyncError(:final error) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text('${l10n.dataUnavailable}\n$error'),
|
|
),
|
|
),
|
|
_ => Center(child: Text(l10n.loading)),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SourcesBody extends ConsumerWidget {
|
|
const _SourcesBody({required this.region});
|
|
|
|
final RegionConfig region;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final theme = Theme.of(context);
|
|
// The frames carry their own credit, set by whoever published them. It is
|
|
// not in the region config, so without this it would be lost when the
|
|
// attribution bar came off the map.
|
|
final frameCredit = ref.watch(
|
|
radarTimelineProvider.select((state) => state.manifest?.attribution),
|
|
);
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
children: [
|
|
// The disclaimer comes first, before the sources it qualifies: a reader
|
|
// who stops after the first screenful must still have seen it.
|
|
_Card(
|
|
icon: Icons.info_outline,
|
|
title: l10n.sourcesDisclaimerTitle,
|
|
body: l10n.sourcesDisclaimerBody,
|
|
),
|
|
|
|
_SectionHeader(l10n.sourcesDataSection),
|
|
for (final attribution in region.attributions)
|
|
_AttributionTile(attribution: attribution),
|
|
|
|
if (frameCredit != null)
|
|
ListTile(
|
|
leading: const Icon(Icons.radar),
|
|
title: Text(frameCredit),
|
|
subtitle: Text(l10n.sourcesRadarFrames),
|
|
),
|
|
|
|
_Card(
|
|
icon: Icons.copyright_outlined,
|
|
title: l10n.sourcesShareAlikeTitle,
|
|
body: l10n.sourcesShareAlikeBody,
|
|
),
|
|
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => _open(region.alerts.officialBulletinUrl),
|
|
icon: const Icon(Icons.open_in_new),
|
|
label: Text(l10n.sourcesOfficialBulletin),
|
|
),
|
|
),
|
|
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
|
child: Text(
|
|
region.displayName,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.outline,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AttributionTile extends StatelessWidget {
|
|
const _AttributionTile({required this.attribution});
|
|
|
|
final Attribution attribution;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final license = attribution.license;
|
|
|
|
return ListTile(
|
|
title: Text(attribution.text),
|
|
subtitle: Text(
|
|
license == null
|
|
? l10n.sourcesNoLicenseStated
|
|
: l10n.sourcesLicenseLabel(license),
|
|
),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.open_in_new),
|
|
tooltip: l10n.openLink,
|
|
onPressed: () => _open(attribution.url),
|
|
),
|
|
onTap: () => _open(attribution.url),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionHeader extends StatelessWidget {
|
|
const _SectionHeader(this.title);
|
|
|
|
final String title;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Text(
|
|
title,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Card extends StatelessWidget {
|
|
const _Card({required this.icon, required this.title, required this.body});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final String body;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: theme.textTheme.titleSmall),
|
|
const SizedBox(height: 4),
|
|
Text(body, style: theme.textTheme.bodyMedium),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Opens [url] in the browser, ignoring a failure to launch.
|
|
///
|
|
/// A dead link is a poor experience but not a reason to crash the screen the
|
|
/// disclaimer lives on.
|
|
Future<void> _open(String url) async {
|
|
final uri = Uri.tryParse(url);
|
|
if (uri == null) return;
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|