From 31a4bd5eb63ce884813dc3640d452585586bc8c8 Mon Sep 17 00:00:00 2001 From: Xennis Date: Sat, 20 Jul 2024 22:49:07 +0200 Subject: [PATCH] Enhance offline maps support --- green_walking/lib/l10n/app_de.arb | 12 +++- green_walking/lib/l10n/app_en.arb | 58 ++++++++++++++- green_walking/lib/library/offline.dart | 45 ++++++++++++ green_walking/lib/pages/download_map.dart | 49 +++---------- green_walking/lib/pages/offline_maps.dart | 51 +++++--------- .../lib/widgets/download_map_dialog.dart | 44 ------------ .../widgets/offline/download_map_dialog.dart | 70 +++++++++++++++++++ .../lib/widgets/offline/offline_map_card.dart | 65 +++++++++++++++++ 8 files changed, 276 insertions(+), 118 deletions(-) create mode 100644 green_walking/lib/library/offline.dart delete mode 100644 green_walking/lib/widgets/download_map_dialog.dart create mode 100644 green_walking/lib/widgets/offline/download_map_dialog.dart create mode 100644 green_walking/lib/widgets/offline/offline_map_card.dart diff --git a/green_walking/lib/l10n/app_de.arb b/green_walking/lib/l10n/app_de.arb index 008905d..3006ec5 100644 --- a/green_walking/lib/l10n/app_de.arb +++ b/green_walking/lib/l10n/app_de.arb @@ -56,8 +56,18 @@ "optionDarkTheme": "Dunkel", "optionLightTheme": "Hell", "optionSystem": "System", - "offlineMapsPage": "Offline Karten (Beta)", + "offlineMapsPage": "Offline-Karten (Beta)", "downloadNewOfflineMapButton": "Neue Karte herunterladen", "downloadMapTitle": "Offline-Karte herunterladen", + "downloadMapButton": "Gebiet herunterladen", + "offlineMapCardTitle": "Karte", + "offlineMapCardDownloadTime": "Heruntergeladen: {dateTime}", + "offlineMapStyleCardTitle": "Kartenstil: Outdoor", + "offlineMapStyleCardSubtitle": "Stil (z.B. Schriftart, Icons) der Karte.", + "downloadMapDialogTitle": "Gebiet herunterladen?", + "downloadMapDialogCancel": "Abbruch", + "downloadMapDialogConfirm": "Herunterladen", + "downloadMapDialogEstimateLoading": "Speicherplatz wird geschätzt.", + "downloadMapDialogEstimateResult": "Schätzung:\n{storageSize} Speicherplatz\n{networkTransferSize} Netzwerk-Transfer", "language": "Sprache" } \ No newline at end of file diff --git a/green_walking/lib/l10n/app_en.arb b/green_walking/lib/l10n/app_en.arb index c426799..6695ee1 100644 --- a/green_walking/lib/l10n/app_en.arb +++ b/green_walking/lib/l10n/app_en.arb @@ -371,7 +371,63 @@ }, "downloadMapTitle": "Download offline map", "@downloadMapTitle": { - "description": "Title for download map page" + "description": "Title for download map page." + }, + "downloadMapButton": "Download area", + "@downloadMapButton": { + "description": "Title for download map button." + }, + "offlineMapCardTitle": "Map", + "@offlineMapCardTitle": { + "description": "Title of a offline map card." + }, + "offlineMapCardDownloadTime": "Download: {dateTime}", + "@offlineMapCardDownloadTime": { + "description": "Label of the displayed download time.", + "placeholders": { + "dateTime": { + "type": "String", + "example": "2024-07-20 12:11:02" + } + } + }, + "offlineMapStyleCardTitle": "Map Style: Outdoor", + "@offlineMapStyleCardTitle": { + "description": "Title of a offline map style card." + }, + "offlineMapStyleCardSubtitle": "Style (e.g. fonts, icons) of the map.", + "@offlineMapStyleCardSubtitle": { + "description": "Subtitle of a offline map style card." + }, + "downloadMapDialogTitle": "Download area?", + "@downloadMapDialogTitle": { + "description": "Title of the download map dialog." + }, + "downloadMapDialogCancel": "Cancel", + "@downloadMapDialogCancel": { + "description": "Action button to cancel the dialog." + }, + "downloadMapDialogConfirm": "Download", + "@downloadMapDialogConfirm": { + "description": "Action button to confirm the dialog." + }, + "downloadMapDialogEstimateLoading": "Estimate storage space.", + "@downloadMapDialogEstimateLoading": { + "description": "Text shown while estimating the required storage space." + }, + "downloadMapDialogEstimateResult": "Estimation:\n{storageSize} storage\n{networkTransferSize} network transfer", + "@downloadMapDialogEstimateResult": { + "description": "Text shown while estimating the required storage space.", + "placeholders": { + "storageSize": { + "type": "String", + "example": "102 MB" + }, + "networkTransferSize": { + "type": "String", + "example": "80 MB" + } + } }, "language": "Language" } \ No newline at end of file diff --git a/green_walking/lib/library/offline.dart b/green_walking/lib/library/offline.dart new file mode 100644 index 0000000..ab4a82b --- /dev/null +++ b/green_walking/lib/library/offline.dart @@ -0,0 +1,45 @@ +import 'dart:developer' show log; + +import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; + +import './map_utils.dart'; + +class OfflineMapMetadata { + static const String downloadTime = "download-time"; + + static DateTime? parseMetadata(Map metadata) { + try { + final downloadTime = metadata[OfflineMapMetadata.downloadTime] as int; + return DateTime.fromMillisecondsSinceEpoch(downloadTime); + } catch (e) { + log('failed to parse metadata: $e'); + } + return null; + } + + static Map createMetadata({required DateTime downloadTime}) { + return {OfflineMapMetadata.downloadTime: downloadTime.millisecondsSinceEpoch}; + } +} + +Future createRegionLoadOptions(MapboxMap mapboxMap) async { + final (String styleURI, CoordinateBounds cameraBounds, CameraBounds bounds) = + await (mapboxMap.style.getStyleURI(), mapboxMap.getCameraBounds(), mapboxMap.getBounds()).wait; + return TileRegionLoadOptions( + geometry: _coordinateBoundsToPolygon(cameraBounds).toJson(), + descriptorsOptions: [TilesetDescriptorOptions(styleURI: styleURI, minZoom: 6, maxZoom: bounds.maxZoom.floor())], + acceptExpired: true, + metadata: OfflineMapMetadata.createMetadata(downloadTime: DateTime.now()), + networkRestriction: NetworkRestriction.DISALLOW_EXPENSIVE); +} + +Polygon _coordinateBoundsToPolygon(CoordinateBounds bounds) { + return Polygon.fromPoints(points: [ + [ + bounds.southwest, + Point(coordinates: Position.named(lng: bounds.southwest.coordinates.lng, lat: bounds.northeast.coordinates.lat)), + bounds.northeast, + Point(coordinates: Position.named(lng: bounds.northeast.coordinates.lng, lat: bounds.southwest.coordinates.lat)), + ] + ]); +} diff --git a/green_walking/lib/pages/download_map.dart b/green_walking/lib/pages/download_map.dart index 2144b48..f83d7e5 100644 --- a/green_walking/lib/pages/download_map.dart +++ b/green_walking/lib/pages/download_map.dart @@ -4,9 +4,9 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; -import '../library/map_utils.dart'; import '../config.dart'; -import '../widgets/download_map_dialog.dart'; +import '../library/offline.dart'; +import '../widgets/offline/download_map_dialog.dart'; class DownloadMapPage extends StatefulWidget { const DownloadMapPage({super.key, required this.tileStore}); @@ -20,8 +20,6 @@ class DownloadMapPage extends StatefulWidget { class _DownloadMapPageState extends State { late MapboxMap _mapboxMap; - bool _isLoading = false; - @override Widget build(BuildContext context) { final AppLocalizations locale = AppLocalizations.of(context)!; @@ -36,17 +34,9 @@ class _DownloadMapPageState extends State { ButtonBar( alignment: MainAxisAlignment.center, children: [ - ElevatedButton.icon( - icon: _isLoading - ? Container( - width: 24, - height: 24, - padding: const EdgeInsets.all(2.0), - child: const CircularProgressIndicator(), - ) - : const Icon(Icons.download), - onPressed: _isLoading ? null : _onDownloadPressed, - label: const Text("Download area"), + ElevatedButton( + onPressed: _onDownloadPressed, + child: Text(locale.downloadMapButton), ), ], ), @@ -76,36 +66,15 @@ class _DownloadMapPageState extends State { } Future _onDownloadPressed() async { - setState(() => _isLoading = true); - final styleURI = await _mapboxMap.style.getStyleURI(); - final cameraBounds = await _mapboxMap.getCameraBounds(); - final regionLoadOptions = TileRegionLoadOptions( - geometry: _coordinateBoundsToPolygon(cameraBounds).toJson(), - descriptorsOptions: [TilesetDescriptorOptions(styleURI: styleURI, minZoom: 0, maxZoom: 16)], - acceptExpired: true, - networkRestriction: NetworkRestriction.DISALLOW_EXPENSIVE); - // FIXME: Catch errors here? - // FIXME: Esimate is somehow notworking anymore; - // final estimateRegion = await widget.tileStore.estimateTileRegion("", regionLoadOptions, null, null).timeout(const Duration(seconds: 2)); + final regionLoadOptions = await createRegionLoadOptions(_mapboxMap); if (!mounted) return; - final shouldDownload = - await showDialog(context: context, builder: (context) => const DownloadMapDialog(estimateRegion: null)); - setState(() => _isLoading = false); + final shouldDownload = await showDialog( + context: context, + builder: (context) => DownloadMapDialog(tileStore: widget.tileStore, regionLoadOptions: regionLoadOptions)); if (shouldDownload != null && shouldDownload) { if (!mounted) return; Navigator.of(context).pop(regionLoadOptions); } } } - -Polygon _coordinateBoundsToPolygon(CoordinateBounds bounds) { - return Polygon.fromPoints(points: [ - [ - bounds.southwest, - Point(coordinates: Position.named(lng: bounds.southwest.coordinates.lng, lat: bounds.northeast.coordinates.lat)), - bounds.northeast, - Point(coordinates: Position.named(lng: bounds.northeast.coordinates.lng, lat: bounds.southwest.coordinates.lat)), - ] - ]); -} diff --git a/green_walking/lib/pages/offline_maps.dart b/green_walking/lib/pages/offline_maps.dart index 13232f9..8892ea6 100644 --- a/green_walking/lib/pages/offline_maps.dart +++ b/green_walking/lib/pages/offline_maps.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; import 'package:path_provider/path_provider.dart'; +import '../widgets/offline/offline_map_card.dart'; import 'download_map.dart'; class OfflineMapsPage extends StatefulWidget { @@ -18,6 +19,7 @@ class OfflineMapsPage extends StatefulWidget { class _OfflineMapsPageState extends State { late OfflineManager _offlineManager; late TileStore _tileStore; + List _stylePacks = []; List _regions = []; StreamController? _downloadProgress; @@ -29,12 +31,9 @@ class _OfflineMapsPageState extends State { } void _setAsyncState() async { - final offlineManager = await OfflineManager.create(); final tmpDir = await getTemporaryDirectory(); - final tileStore = await TileStore.createAt(tmpDir.uri); - - final stylePacks = await offlineManager.allStylePacks(); - final regions = await tileStore.allTileRegions(); + final (offlineManager, tileStore) = await (OfflineManager.create(), TileStore.createAt(tmpDir.uri)).wait; + final (stylePacks, regions) = await (offlineManager.allStylePacks(), tileStore.allTileRegions()).wait; setState(() { _offlineManager = offlineManager; @@ -74,15 +73,10 @@ class _OfflineMapsPageState extends State { itemCount: _regions.length, itemBuilder: (context, index) { final region = _regions[index]; - //final expire = DateTime.fromMillisecondsSinceEpoch(pack.expires!) - return Card( - child: ListTile( - title: Text('Map: ${region.id}'), - trailing: IconButton( - icon: const Icon(Icons.delete), - onPressed: () => _onDeleteRegion(region.id, index), - ), - ), + return OfflineMapCard( + region: region, + tileStore: _tileStore, + onDelete: () => _onDeleteRegion(region.id, index), ); }, ), @@ -92,18 +86,10 @@ class _OfflineMapsPageState extends State { itemCount: _stylePacks.length, itemBuilder: (context, index) { final pack = _stylePacks[index]; - //final name = pack.styleURI == CustomMapboxStyles.outdoor ? "outdoor" : "satellite"; - //final expire = DateTime.fromMillisecondsSinceEpoch(pack.expires!) - return Card( - child: ListTile( - title: const Text("Map Style: Outdoor"), - subtitle: const Text("Style (e.g. fonts, icons) of the map."), - trailing: IconButton( - icon: const Icon(Icons.delete), - onPressed: _regions.isEmpty ? () => _onDeleteStylePack(pack.styleURI, index) : null, - ), - ), - ); + return OfflineMapStyleCard( + stylePack: pack, + canBeDeleted: _regions.isEmpty, + onDelete: () => _onDeleteStylePack(pack.styleURI, index)); }, ), ], @@ -122,6 +108,7 @@ class _OfflineMapsPageState extends State { initialData: 0.0, builder: (context, snapshot) { return Column(mainAxisSize: MainAxisSize.min, children: [ + // TODO: Translate Text("Progress: ${(snapshot.requireData * 100).toStringAsFixed(0)}%"), LinearProgressIndicator( value: snapshot.requireData, @@ -144,15 +131,12 @@ class _OfflineMapsPageState extends State { if (styleURI == null) { return; } + final regionId = DateTime.now().millisecondsSinceEpoch.toString(); try { await _downloadStylePack(styleURI); // Get all style packs because we most likely downloaded the same again. final stylePacks = await _offlineManager.allStylePacks(); - //setState(() { - // _stylePacks = stylePacks; - //}); - final tileRegion = await _downloadTileRegion( - regionId: DateTime.now().millisecondsSinceEpoch.toString(), regionLoadOptions: regionLoadOptions); + final tileRegion = await _downloadTileRegion(regionId: regionId, regionLoadOptions: regionLoadOptions); setState(() { _stylePacks = stylePacks; _regions = [..._regions, tileRegion]; @@ -160,7 +144,8 @@ class _OfflineMapsPageState extends State { }); } catch (e) { log('failed to download map: $e'); - _displaySnackBar("Failed to map"); + // TODO: Translate + _displaySnackBar("Failed to download map"); setState(() => _downloadProgress = null); } } @@ -205,6 +190,7 @@ class _OfflineMapsPageState extends State { setState(() { _stylePacks.removeAt(index); }); + // TODO: Add translation _displaySnackBar("Deleted map style"); } catch (e) { log('failed to delete downloaded style pack: $e'); @@ -218,6 +204,7 @@ class _OfflineMapsPageState extends State { setState(() { _regions.removeAt(index); }); + // TODO: Add translation _displaySnackBar("Deleted map"); } catch (e) { log('failed to remove downloaded region: $e'); diff --git a/green_walking/lib/widgets/download_map_dialog.dart b/green_walking/lib/widgets/download_map_dialog.dart deleted file mode 100644 index ac27ec0..0000000 --- a/green_walking/lib/widgets/download_map_dialog.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart' show TileRegionEstimateResult; - -import '../library/util.dart'; - -class DownloadMapDialog extends StatelessWidget { - const DownloadMapDialog({super.key, required this.estimateRegion}); - - final TileRegionEstimateResult? estimateRegion; - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text("Download area?"), - content: SingleChildScrollView( - child: ListBody( - children: [ - Text(_contentText()), - ], - ), - ), - actions: [ - TextButton( - child: Text("cancel".toUpperCase()), - onPressed: () { - Navigator.of(context).pop(false); - }), - TextButton( - child: Text("download".toUpperCase()), - onPressed: () { - Navigator.of(context).pop(true); - }), - ], - ); - } - - String _contentText() { - final estimate = estimateRegion; - if (estimate == null) { - return 'No estimate'; - } - return 'Estimation:\n${formatBytes(estimate.storageSize, 0)} storage\n${formatBytes(estimate.transferSize, 0)} network transfer'; - } -} diff --git a/green_walking/lib/widgets/offline/download_map_dialog.dart b/green_walking/lib/widgets/offline/download_map_dialog.dart new file mode 100644 index 0000000..f5cbfe9 --- /dev/null +++ b/green_walking/lib/widgets/offline/download_map_dialog.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; + +import '../../library/util.dart'; + +class DownloadMapDialog extends StatelessWidget { + const DownloadMapDialog({super.key, required this.tileStore, required this.regionLoadOptions}); + + final TileStore tileStore; + final TileRegionLoadOptions regionLoadOptions; + + @override + Widget build(BuildContext context) { + final AppLocalizations locale = AppLocalizations.of(context)!; + + return AlertDialog( + title: Text(locale.downloadMapDialogTitle), + content: SingleChildScrollView( + child: ListBody( + children: [ + FutureBuilder( + future: _estimateTileRegion(), + builder: (context, snapshot) { + final TileRegionEstimateResult? data = snapshot.data; + if (snapshot.hasData && data != null) { + return Text(locale.downloadMapDialogEstimateResult( + formatBytes(data.storageSize, 0), formatBytes(data.transferSize, 0))); + } + if (snapshot.hasError) { + // TODO: Translate + return Text('Failed to estimate amount of storage: ${snapshot.error}'); + } + return Row( + children: [ + Text(locale.downloadMapDialogEstimateLoading), + Container( + width: 24, + height: 24, + padding: const EdgeInsets.all(2.0), + child: const CircularProgressIndicator(), + ), + ], + ); + }, + ), + ], + ), + ), + actions: [ + TextButton( + child: Text(locale.downloadMapDialogCancel.toUpperCase()), + onPressed: () { + Navigator.pop(context, false); + }), + TextButton( + child: Text(locale.downloadMapDialogConfirm.toUpperCase()), + onPressed: () { + Navigator.pop(context, true); + }), + ], + ); + } + + Future _estimateTileRegion() { + // Some unique ID. + final id = DateTime.now().microsecondsSinceEpoch.toString(); + return tileStore.estimateTileRegion(id, regionLoadOptions, null, null).timeout(const Duration(seconds: 8)); + } +} diff --git a/green_walking/lib/widgets/offline/offline_map_card.dart b/green_walking/lib/widgets/offline/offline_map_card.dart new file mode 100644 index 0000000..24f746e --- /dev/null +++ b/green_walking/lib/widgets/offline/offline_map_card.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; + +import '../../library/offline.dart'; + +class OfflineMapCard extends StatelessWidget { + const OfflineMapCard({super.key, required this.region, required this.tileStore, required this.onDelete}); + + final TileRegion region; + final TileStore tileStore; + final void Function()? onDelete; + + @override + Widget build(BuildContext context) { + final AppLocalizations locale = AppLocalizations.of(context)!; + + return Card( + child: ListTile( + title: Text(locale.offlineMapCardTitle), + subtitle: FutureBuilder( + future: tileStore.tileRegionMetadata(region.id), + builder: (context, snapshot) { + final data = snapshot.data; + if (snapshot.hasData && data != null) { + final downloadTime = OfflineMapMetadata.parseMetadata(data); + if (downloadTime != null) { + return Text(locale.offlineMapCardDownloadTime(downloadTime.toString())); + } + } + return Container(); + }, + ), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: onDelete, + ), + ), + ); + } +} + +class OfflineMapStyleCard extends StatelessWidget { + const OfflineMapStyleCard({super.key, required this.stylePack, required this.canBeDeleted, required this.onDelete}); + + final StylePack stylePack; + final bool canBeDeleted; + final void Function()? onDelete; + + @override + Widget build(BuildContext context) { + final AppLocalizations locale = AppLocalizations.of(context)!; + + return Card( + child: ListTile( + title: Text(locale.offlineMapStyleCardTitle), + subtitle: Text(locale.offlineMapStyleCardSubtitle), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: canBeDeleted ? onDelete : null, + ), + ), + ); + } +}