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 _open(String url) async { final uri = Uri.tryParse(url); if (uri == null) return; await launchUrl(uri, mode: LaunchMode.externalApplication); }