From 51945d3f2c077de1674af9b9f659f7b8a770fcc0 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Thu, 5 Mar 2026 11:19:21 +0100 Subject: [PATCH 1/2] feat: auto-download maps from GPS position in navigation setup --- lib/cubits/map_download_cubit.dart | 204 +++++++++++++++ lib/cubits/navigation_availability_cubit.dart | 2 + lib/l10n/app_de.arb | 9 + lib/l10n/app_en.arb | 14 + lib/l10n/app_localizations.dart | 54 ++++ lib/l10n/app_localizations_de.dart | 29 +++ lib/l10n/app_localizations_en.dart | 29 +++ lib/screens/navigation_setup_screen.dart | 246 +++++++++++++----- 8 files changed, 520 insertions(+), 67 deletions(-) create mode 100644 lib/cubits/map_download_cubit.dart diff --git a/lib/cubits/map_download_cubit.dart b/lib/cubits/map_download_cubit.dart new file mode 100644 index 0000000..d80dec8 --- /dev/null +++ b/lib/cubits/map_download_cubit.dart @@ -0,0 +1,204 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +enum MapDownloadStatus { idle, locating, downloading, installing, done, error } + +class MapDownloadState { + final MapDownloadStatus status; + final double progress; + final String? regionName; + final String? errorMessage; + + const MapDownloadState({ + this.status = MapDownloadStatus.idle, + this.progress = 0.0, + this.regionName, + this.errorMessage, + }); + + MapDownloadState copyWith({ + MapDownloadStatus? status, + double? progress, + String? regionName, + String? errorMessage, + }) => + MapDownloadState( + status: status ?? this.status, + progress: progress ?? this.progress, + regionName: regionName ?? this.regionName, + errorMessage: errorMessage ?? this.errorMessage, + ); +} + +class MapDownloadCubit extends Cubit { + final _dio = Dio(); + CancelToken? _cancelToken; + + MapDownloadCubit() : super(const MapDownloadState()); + + Future startDownload({ + required double latitude, + required double longitude, + required bool needsDisplayMaps, + required bool needsRoutingMaps, + }) async { + if (state.status != MapDownloadStatus.idle && state.status != MapDownloadStatus.error) return; + + _cancelToken = CancelToken(); + + try { + emit(const MapDownloadState(status: MapDownloadStatus.locating)); + + final slug = await _resolveSlug(latitude, longitude); + if (slug == null) { + emit(const MapDownloadState(status: MapDownloadStatus.error, errorMessage: 'unsupported')); + return; + } + + final regionName = _slugToDisplayName[slug] ?? slug; + double displayProgress = 0; + double routingProgress = 0; + + void updateProgress() { + double total; + if (needsDisplayMaps && needsRoutingMaps) { + total = (displayProgress + routingProgress) / 2; + } else if (needsDisplayMaps) { + total = displayProgress; + } else { + total = routingProgress; + } + emit(MapDownloadState( + status: MapDownloadStatus.downloading, + progress: total, + regionName: regionName, + )); + } + + if (needsDisplayMaps) { + final url = 'https://github.com/librescoot/osm-tiles/releases/download/latest/tiles_$slug.mbtiles'; + await _downloadFile( + url: url, + dest: '/tmp/scootui_map.mbtiles', + onProgress: (p) { + displayProgress = p; + updateProgress(); + }, + ); + } + + if (needsRoutingMaps) { + final url = 'https://github.com/librescoot/valhalla-tiles/releases/download/latest/valhalla_tiles_$slug.tar'; + await _downloadFile( + url: url, + dest: '/tmp/scootui_valhalla_tiles.tar', + onProgress: (p) { + routingProgress = p; + updateProgress(); + }, + ); + } + + emit(MapDownloadState(status: MapDownloadStatus.installing, regionName: regionName)); + + if (needsDisplayMaps) { + await Directory('/data/maps').create(recursive: true); + await File('/tmp/scootui_map.mbtiles').rename('/data/maps/map.mbtiles'); + } + + if (needsRoutingMaps) { + await Directory('/data/valhalla').create(recursive: true); + final result = await Process.run('tar', ['-xf', '/tmp/scootui_valhalla_tiles.tar', '-C', '/data/valhalla/']); + await File('/tmp/scootui_valhalla_tiles.tar').delete(); + if (result.exitCode != 0) { + throw Exception('tar: ${result.stderr}'); + } + } + + if (needsDisplayMaps) { + await Process.run('systemctl', ['restart', 'librescoot-mbtileserver']); + } + if (needsRoutingMaps) { + await Process.run('systemctl', ['restart', 'librescoot-valhalla']); + } + + emit(MapDownloadState(status: MapDownloadStatus.done, regionName: regionName)); + } catch (e) { + if (e is DioException && CancelToken.isCancel(e)) { + emit(const MapDownloadState(status: MapDownloadStatus.idle)); + } else { + emit(MapDownloadState( + status: MapDownloadStatus.error, + errorMessage: e.toString(), + )); + } + } + } + + void cancel() { + _cancelToken?.cancel(); + _cancelToken = null; + } + + void reset() { + cancel(); + emit(const MapDownloadState(status: MapDownloadStatus.idle)); + } + + @override + Future close() { + cancel(); + return super.close(); + } + + Future _resolveSlug(double latitude, double longitude) async { + final response = await _dio.get( + 'https://nominatim.openstreetmap.org/reverse', + queryParameters: {'lat': latitude, 'lon': longitude, 'format': 'json', 'zoom': 5}, + options: Options(headers: {'User-Agent': 'LibreScoot/1.0 (navigation setup)'}), + ); + final state = response.data?['address']?['state'] as String?; + if (state == null) return null; + return _stateToSlug[state]; + } + + Future _downloadFile({ + required String url, + required String dest, + required void Function(double) onProgress, + }) async { + await _dio.download( + url, + dest, + cancelToken: _cancelToken, + onReceiveProgress: (received, total) { + if (total > 0) onProgress(received / total); + }, + ); + } + + static const _stateToSlug = { + 'Baden-Württemberg': 'baden-wuerttemberg', + 'Bayern': 'bayern', + 'Berlin': 'berlin_brandenburg', + 'Brandenburg': 'berlin_brandenburg', + 'Bremen': 'bremen', + 'Hamburg': 'hamburg', + 'Hessen': 'hessen', + 'Mecklenburg-Vorpommern': 'mecklenburg-vorpommern', + 'Niedersachsen': 'niedersachsen', + 'Nordrhein-Westfalen': 'nordrhein-westfalen', + 'Rheinland-Pfalz': 'rheinland-pfalz', + 'Saarland': 'saarland', + 'Sachsen': 'sachsen', + 'Sachsen-Anhalt': 'sachsen-anhalt', + 'Schleswig-Holstein': 'schleswig-holstein', + 'Thüringen': 'thueringen', + }; + + static const _slugToDisplayName = { + 'berlin_brandenburg': 'Berlin/Brandenburg', + }; +} diff --git a/lib/cubits/navigation_availability_cubit.dart b/lib/cubits/navigation_availability_cubit.dart index 03d936a..1499888 100644 --- a/lib/cubits/navigation_availability_cubit.dart +++ b/lib/cubits/navigation_availability_cubit.dart @@ -90,6 +90,8 @@ class NavigationAvailabilityCubit extends Cubit { static NavigationAvailabilityState watch(BuildContext context) => context.watch().state; + Future recheck() => _checkAndPublish(); + Future _checkAndPublish() async { if (_checking) return; _checking = true; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fd955ec..5218a20 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -17,6 +17,15 @@ "navSetupRoutingEngine": "Routing-Engine", "navSetupNoRoutingBody": "Kartenanzeige und Routing sind unabhängig. Kartenkacheln können lokal (offline .mbtiles) oder online sein. Für Routing wird eine Valhalla-Engine benötigt — lokal (Routing-Karten erforderlich) oder ein Remote-Server.", "navSetupScanForInstructions": "Für Anleitung scannen", + "navSetupDownloadButton": "Karten herunterladen", + "navSetupDownloadLocating": "Region wird erkannt...", + "navSetupDownloadProgress": "Herunterladen... {percent}%", + "navSetupDownloadInstalling": "Karten werden installiert...", + "navSetupDownloadDone": "Karten installiert. Navigationsdienste werden neu gestartet...", + "navSetupDownloadError": "Download fehlgeschlagen", + "navSetupDownloadWaitingGps": "Warte auf GPS-Signal...", + "navSetupDownloadNoInternet": "Keine Internetverbindung", + "navSetupDownloadUnsupported": "Keine Karten für Ihren Standort verfügbar", "menuEnterDestinationCode": "Zielcode eingeben", "menuSavedLocations": "Gespeicherte Orte", "menuSavedLocationsHeader": "GESPEICHERTE ORTE", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e8a4f5c..122e067 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -17,6 +17,20 @@ "navSetupRoutingEngine": "Routing engine", "navSetupNoRoutingBody": "Map display and routing are independent. Display tiles can be local (offline .mbtiles) or online. Routing requires a Valhalla engine — local (needs routing maps) or a remote server.", "navSetupScanForInstructions": "Scan for setup instructions", + "navSetupDownloadButton": "Download maps", + "navSetupDownloadLocating": "Detecting your region...", + "navSetupDownloadProgress": "Downloading... {percent}%", + "@navSetupDownloadProgress": { + "placeholders": { + "percent": {"type": "int"} + } + }, + "navSetupDownloadInstalling": "Installing maps...", + "navSetupDownloadDone": "Maps installed. Restarting navigation services...", + "navSetupDownloadError": "Download failed", + "navSetupDownloadWaitingGps": "Waiting for GPS fix...", + "navSetupDownloadNoInternet": "No internet connection", + "navSetupDownloadUnsupported": "No maps available for your location", "menuEnterDestinationCode": "Enter Destination Code", "menuSavedLocations": "Saved Locations", "menuSavedLocationsHeader": "SAVED LOCATIONS", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2a2d25c..7673a49 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -182,6 +182,60 @@ abstract class AppLocalizations { /// **'Scan for setup instructions'** String get navSetupScanForInstructions; + /// No description provided for @navSetupDownloadButton. + /// + /// In en, this message translates to: + /// **'Download maps'** + String get navSetupDownloadButton; + + /// No description provided for @navSetupDownloadLocating. + /// + /// In en, this message translates to: + /// **'Detecting your region...'** + String get navSetupDownloadLocating; + + /// No description provided for @navSetupDownloadProgress. + /// + /// In en, this message translates to: + /// **'Downloading... {percent}%'** + String navSetupDownloadProgress(int percent); + + /// No description provided for @navSetupDownloadInstalling. + /// + /// In en, this message translates to: + /// **'Installing maps...'** + String get navSetupDownloadInstalling; + + /// No description provided for @navSetupDownloadDone. + /// + /// In en, this message translates to: + /// **'Maps installed. Restarting navigation services...'** + String get navSetupDownloadDone; + + /// No description provided for @navSetupDownloadError. + /// + /// In en, this message translates to: + /// **'Download failed'** + String get navSetupDownloadError; + + /// No description provided for @navSetupDownloadWaitingGps. + /// + /// In en, this message translates to: + /// **'Waiting for GPS fix...'** + String get navSetupDownloadWaitingGps; + + /// No description provided for @navSetupDownloadNoInternet. + /// + /// In en, this message translates to: + /// **'No internet connection'** + String get navSetupDownloadNoInternet; + + /// No description provided for @navSetupDownloadUnsupported. + /// + /// In en, this message translates to: + /// **'No maps available for your location'** + String get navSetupDownloadUnsupported; + /// No description provided for @menuEnterDestinationCode. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ba83038..b4db1a9 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -54,6 +54,35 @@ class AppLocalizationsDe extends AppLocalizations { @override String get navSetupScanForInstructions => 'Für Anleitung scannen'; + @override + String get navSetupDownloadButton => 'Karten herunterladen'; + + @override + String get navSetupDownloadLocating => 'Region wird erkannt...'; + + @override + String navSetupDownloadProgress(int percent) { + return 'Herunterladen... $percent%'; + } + + @override + String get navSetupDownloadInstalling => 'Karten werden installiert...'; + + @override + String get navSetupDownloadDone => 'Karten installiert. Navigationsdienste werden neu gestartet...'; + + @override + String get navSetupDownloadError => 'Download fehlgeschlagen'; + + @override + String get navSetupDownloadWaitingGps => 'Warte auf GPS-Signal...'; + + @override + String get navSetupDownloadNoInternet => 'Keine Internetverbindung'; + + @override + String get navSetupDownloadUnsupported => 'Keine Karten für Ihren Standort verfügbar'; + @override String get menuEnterDestinationCode => 'Zielcode eingeben'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 17f5489..bba3e0c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -54,6 +54,35 @@ class AppLocalizationsEn extends AppLocalizations { @override String get navSetupScanForInstructions => 'Scan for setup instructions'; + @override + String get navSetupDownloadButton => 'Download maps'; + + @override + String get navSetupDownloadLocating => 'Detecting your region...'; + + @override + String navSetupDownloadProgress(int percent) { + return 'Downloading... $percent%'; + } + + @override + String get navSetupDownloadInstalling => 'Installing maps...'; + + @override + String get navSetupDownloadDone => 'Maps installed. Restarting navigation services...'; + + @override + String get navSetupDownloadError => 'Download failed'; + + @override + String get navSetupDownloadWaitingGps => 'Waiting for GPS fix...'; + + @override + String get navSetupDownloadNoInternet => 'No internet connection'; + + @override + String get navSetupDownloadUnsupported => 'No maps available for your location'; + @override String get menuEnterDestinationCode => 'Enter Destination Code'; diff --git a/lib/screens/navigation_setup_screen.dart b/lib/screens/navigation_setup_screen.dart index 33d6c8d..bea5a69 100644 --- a/lib/screens/navigation_setup_screen.dart +++ b/lib/screens/navigation_setup_screen.dart @@ -2,11 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import '../cubits/map_download_cubit.dart'; import '../cubits/mdb_cubits.dart'; import '../cubits/navigation_availability_cubit.dart'; import '../cubits/screen_cubit.dart'; import '../cubits/theme_cubit.dart'; import '../l10n/l10n.dart'; +import '../state/enums.dart'; +import '../state/gps.dart'; import '../widgets/general/control_gestures_detector.dart'; import '../widgets/general/control_hints.dart'; @@ -15,6 +18,18 @@ const _docsUrl = 'https://librescoot.org/docs/navigation.html'; class NavigationSetupScreen extends StatelessWidget { const NavigationSetupScreen({super.key}); + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => MapDownloadCubit(), + child: const _Content(), + ); + } +} + +class _Content extends StatelessWidget { + const _Content(); + @override Widget build(BuildContext context) { final isDark = ThemeCubit.watch(context).isDark; @@ -27,6 +42,8 @@ class NavigationSetupScreen extends StatelessWidget { final fgDim = isDark ? Colors.white60 : Colors.black54; final divider = isDark ? Colors.white12 : Colors.black12; + final anyMissing = !navState.localDisplayMapsAvailable || !navState.routingAvailable; + final String title; if (!navState.routingAvailable && !navState.localDisplayMapsAvailable) { title = l10n.navSetupTitleBothUnavailable; @@ -38,84 +55,179 @@ class NavigationSetupScreen extends StatelessWidget { title = l10n.navSetupTitle; } - return ControlGestureDetector( - stream: vehicleSync.stream, - initialData: vehicleSync.state, - requireInitialRelease: true, - onRightTap: () => context.read().closeNavigationSetup(), - child: Container( - color: bg, - child: Column( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(32, 48, 32, 24), - child: Column( - children: [ - Text( - title, - style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: fg), - ), - const SizedBox(height: 16), - _StatusRow( - label: l10n.navSetupLocalDisplayMaps, - available: navState.localDisplayMapsAvailable, - isDark: isDark, - ), - const SizedBox(height: 8), - _StatusRow( - label: l10n.navSetupRoutingEngine, - available: navState.routingAvailable, - isDark: isDark, - ), - const SizedBox(height: 16), - Text( - l10n.navSetupNoRoutingBody, - style: TextStyle(fontSize: 14, color: fgDim, height: 1.4), - textAlign: TextAlign.center, - ), - const Spacer(), - QrImageView( - data: _docsUrl, - version: QrVersions.auto, - size: 140, - backgroundColor: Colors.white, - eyeStyle: const QrEyeStyle( - eyeShape: QrEyeShape.square, - color: Colors.black, + return BlocListener( + listenWhen: (prev, curr) => prev.status != curr.status && curr.status == MapDownloadStatus.done, + listener: (context, _) { + context.read().recheck(); + }, + child: ControlGestureDetector( + stream: vehicleSync.stream, + initialData: vehicleSync.state, + requireInitialRelease: true, + onRightTap: () => context.read().closeNavigationSetup(), + child: Container( + color: bg, + child: Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(32, 48, 32, 24), + child: Column( + children: [ + Text( + title, + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: fg), + ), + const SizedBox(height: 16), + _StatusRow( + label: l10n.navSetupLocalDisplayMaps, + available: navState.localDisplayMapsAvailable, + isDark: isDark, + ), + const SizedBox(height: 8), + _StatusRow( + label: l10n.navSetupRoutingEngine, + available: navState.routingAvailable, + isDark: isDark, ), - dataModuleStyle: const QrDataModuleStyle( - dataModuleShape: QrDataModuleShape.square, - color: Colors.black, + if (anyMissing) ...[ + const SizedBox(height: 12), + Divider(color: divider), + const SizedBox(height: 4), + _DownloadSection(isDark: isDark, navState: navState), + ], + const SizedBox(height: 16), + Text( + l10n.navSetupNoRoutingBody, + style: TextStyle(fontSize: 14, color: fgDim, height: 1.4), + textAlign: TextAlign.center, ), - ), - const SizedBox(height: 8), - Text( - l10n.navSetupScanForInstructions, - style: TextStyle(fontSize: 12, color: fgDim), - ), - ], + const Spacer(), + QrImageView( + data: _docsUrl, + version: QrVersions.auto, + size: 140, + backgroundColor: Colors.white, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + ), + const SizedBox(height: 8), + Text( + l10n.navSetupScanForInstructions, + style: TextStyle(fontSize: 12, color: fgDim), + ), + ], + ), ), ), - ), - // Controls bar - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - decoration: BoxDecoration( - border: Border(top: BorderSide(color: divider)), - ), - child: ControlHints( - leftAction: null, - rightAction: l10n.aboutBackAction, + // Controls bar + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: divider)), + ), + child: ControlHints( + leftAction: null, + rightAction: l10n.aboutBackAction, + ), ), - ), - ], + ], + ), ), ), ); } +} + +class _DownloadSection extends StatelessWidget { + final bool isDark; + final NavigationAvailabilityState navState; + + const _DownloadSection({required this.isDark, required this.navState}); + + @override + Widget build(BuildContext context) { + final downloadState = context.watch().state; + final internet = context.watch().state; + final gps = context.watch().state; + final l10n = context.l10n; + + final fgDim = isDark ? Colors.white60 : Colors.black54; + final isOnline = internet.status == ConnectionStatus.connected; + final hasGps = gps.state == GpsState.fixEstablished && gps.latitude != 0; + + switch (downloadState.status) { + case MapDownloadStatus.locating: + return Text(l10n.navSetupDownloadLocating, + style: TextStyle(fontSize: 13, color: fgDim)); + + case MapDownloadStatus.downloading: + final percent = (downloadState.progress * 100).toInt(); + return Column( + children: [ + LinearProgressIndicator( + value: downloadState.progress, + color: Colors.green.shade600, + backgroundColor: isDark ? Colors.white24 : Colors.black12, + ), + const SizedBox(height: 6), + Text(l10n.navSetupDownloadProgress(percent), + style: TextStyle(fontSize: 13, color: fgDim)), + ], + ); + + case MapDownloadStatus.installing: + return Text(l10n.navSetupDownloadInstalling, + style: TextStyle(fontSize: 13, color: fgDim)); + + case MapDownloadStatus.done: + return Text(l10n.navSetupDownloadDone, + style: TextStyle(fontSize: 13, color: Colors.green.shade600)); + + case MapDownloadStatus.error: + return Column( + children: [ + Text(l10n.navSetupDownloadError, + style: TextStyle(fontSize: 13, color: Colors.red.shade400)), + const SizedBox(height: 4), + _downloadButton(context, gps, l10n), + ], + ); + + case MapDownloadStatus.idle: + if (!isOnline) { + return Text(l10n.navSetupDownloadNoInternet, + style: TextStyle(fontSize: 13, color: fgDim)); + } + if (!hasGps) { + return Text(l10n.navSetupDownloadWaitingGps, + style: TextStyle(fontSize: 13, color: fgDim)); + } + return _downloadButton(context, gps, l10n); + } + } + Widget _downloadButton(BuildContext context, GpsData gps, dynamic l10n) { + return TextButton.icon( + style: TextButton.styleFrom(padding: EdgeInsets.zero), + icon: Icon(Icons.download_outlined, color: Colors.green.shade600, size: 18), + label: Text(l10n.navSetupDownloadButton, + style: TextStyle(color: Colors.green.shade600, fontSize: 13)), + onPressed: () => context.read().startDownload( + latitude: gps.latitude, + longitude: gps.longitude, + needsDisplayMaps: !navState.localDisplayMapsAvailable, + needsRoutingMaps: !navState.routingAvailable, + ), + ); + } } class _StatusRow extends StatelessWidget { From 624e03fffa106ef1071334d5548a435c75532c26 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Thu, 12 Mar 2026 18:44:12 +0100 Subject: [PATCH 2/2] feat: resumable map downloads, update checking, integrity verification - Resume partial downloads via HTTP Range headers (survives app restarts) - Check for map updates on NavigationSetupScreen open (digest comparison) - SHA256 integrity verification against GitHub release digests - Byte-weighted progress with MB display across display + valhalla tiles - Disk space check before download - Track installed map metadata in metadata.json - Fix valhalla: use tar directly (no extraction), correct service name - Remove nonexistent mbtileserver restart --- lib/cubits/map_download_cubit.dart | 476 +++++++++++++++++++---- lib/l10n/app_de.arb | 5 + lib/l10n/app_en.arb | 11 + lib/l10n/app_localizations.dart | 30 ++ lib/l10n/app_localizations_de.dart | 17 + lib/l10n/app_localizations_en.dart | 17 + lib/models/map_metadata.dart | 83 ++++ lib/screens/navigation_setup_screen.dart | 50 ++- 8 files changed, 610 insertions(+), 79 deletions(-) create mode 100644 lib/models/map_metadata.dart diff --git a/lib/cubits/map_download_cubit.dart b/lib/cubits/map_download_cubit.dart index d80dec8..8249fc1 100644 --- a/lib/cubits/map_download_cubit.dart +++ b/lib/cubits/map_download_cubit.dart @@ -1,21 +1,44 @@ +import 'dart:developer' as developer; import 'dart:io'; +import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:path_provider/path_provider.dart'; -enum MapDownloadStatus { idle, locating, downloading, installing, done, error } +import '../models/map_metadata.dart'; + +enum MapDownloadStatus { + idle, + checkingUpdates, + locating, + downloading, + installing, + done, + error, +} class MapDownloadState { final MapDownloadStatus status; final double progress; final String? regionName; final String? errorMessage; + final bool updateAvailable; + final MapMetadata? installedMeta; + final int downloadedBytes; + final int totalBytes; + final bool hasPartialDownload; const MapDownloadState({ this.status = MapDownloadStatus.idle, this.progress = 0.0, this.regionName, this.errorMessage, + this.updateAvailable = false, + this.installedMeta, + this.downloadedBytes = 0, + this.totalBytes = 0, + this.hasPartialDownload = false, }); MapDownloadState copyWith({ @@ -23,12 +46,22 @@ class MapDownloadState { double? progress, String? regionName, String? errorMessage, + bool? updateAvailable, + MapMetadata? installedMeta, + int? downloadedBytes, + int? totalBytes, + bool? hasPartialDownload, }) => MapDownloadState( status: status ?? this.status, progress: progress ?? this.progress, regionName: regionName ?? this.regionName, errorMessage: errorMessage ?? this.errorMessage, + updateAvailable: updateAvailable ?? this.updateAvailable, + installedMeta: installedMeta ?? this.installedMeta, + downloadedBytes: downloadedBytes ?? this.downloadedBytes, + totalBytes: totalBytes ?? this.totalBytes, + hasPartialDownload: hasPartialDownload ?? this.hasPartialDownload, ); } @@ -36,7 +69,87 @@ class MapDownloadCubit extends Cubit { final _dio = Dio(); CancelToken? _cancelToken; - MapDownloadCubit() : super(const MapDownloadState()); + MapDownloadCubit() : super(const MapDownloadState()) { + _init(); + } + + Future _init() async { + final meta = await MapMetadata.load(); + if (meta != null) { + emit(state.copyWith(installedMeta: meta, regionName: _displayName(meta.region))); + checkForUpdates(); + } + await _checkPartialDownload(); + } + + Future _checkPartialDownload() async { + try { + final downloadDir = await _downloadDir(); + final regionFile = File('${downloadDir.path}/region'); + if (await regionFile.exists()) { + final region = await regionFile.readAsString(); + final hasPartial = await _hasPartialFiles(downloadDir); + if (hasPartial) { + emit(state.copyWith( + hasPartialDownload: true, + regionName: _displayName(region.trim()), + )); + } + } + } catch (_) {} + } + + Future _hasPartialFiles(Directory downloadDir) async { + if (!await downloadDir.exists()) return false; + return await downloadDir + .list() + .any((f) => f.path.endsWith('.part')); + } + + Future checkForUpdates() async { + final meta = state.installedMeta ?? await MapMetadata.load(); + if (meta == null) return; + + emit(state.copyWith(status: MapDownloadStatus.checkingUpdates)); + + try { + bool updateAvailable = false; + + if (meta.displayTiles != null) { + final release = await _fetchReleaseInfo('librescoot/osm-tiles'); + final asset = _findAsset(release, 'tiles_${meta.region}.mbtiles'); + if (asset != null) { + final remoteDigest = asset['digest'] as String?; + if (remoteDigest != null && remoteDigest != meta.displayTiles!.digest) { + updateAvailable = true; + } + } + } + + if (!updateAvailable && meta.valhallaTiles != null) { + final release = await _fetchReleaseInfo('librescoot/valhalla-tiles'); + final asset = _findAsset(release, 'valhalla_tiles_${meta.region}.tar'); + if (asset != null) { + final remoteDigest = asset['digest'] as String?; + if (remoteDigest != null && remoteDigest != meta.valhallaTiles!.digest) { + updateAvailable = true; + } + } + } + + if (!isClosed) { + emit(state.copyWith( + status: MapDownloadStatus.idle, + updateAvailable: updateAvailable, + )); + } + } catch (e) { + developer.log('Update check failed: $e', name: 'MapDownload'); + if (!isClosed) { + emit(state.copyWith(status: MapDownloadStatus.idle)); + } + } + } Future startDownload({ required double latitude, @@ -44,95 +157,205 @@ class MapDownloadCubit extends Cubit { required bool needsDisplayMaps, required bool needsRoutingMaps, }) async { - if (state.status != MapDownloadStatus.idle && state.status != MapDownloadStatus.error) return; + if (state.status != MapDownloadStatus.idle && + state.status != MapDownloadStatus.error) { + return; + } _cancelToken = CancelToken(); try { - emit(const MapDownloadState(status: MapDownloadStatus.locating)); + emit(state.copyWith(status: MapDownloadStatus.locating)); final slug = await _resolveSlug(latitude, longitude); if (slug == null) { - emit(const MapDownloadState(status: MapDownloadStatus.error, errorMessage: 'unsupported')); + emit(state.copyWith( + status: MapDownloadStatus.error, + errorMessage: 'unsupported', + )); return; } - final regionName = _slugToDisplayName[slug] ?? slug; - double displayProgress = 0; - double routingProgress = 0; + final regionName = _displayName(slug); + final downloadDir = await _downloadDir(); + await downloadDir.create(recursive: true); + + // If region changed from a partial download, clean up old files + await _cleanPartialIfRegionChanged(downloadDir, slug); + await File('${downloadDir.path}/region').writeAsString(slug); + + // Fetch release metadata (used for disk space check, integrity, and metadata) + final appDir = await getApplicationDocumentsDirectory(); + Map? displayRelease; + Map? valhallaRelease; + Map? displayAsset; + Map? valhallaAsset; + + if (needsDisplayMaps) { + displayRelease = await _fetchReleaseInfo('librescoot/osm-tiles'); + displayAsset = _findAsset(displayRelease, 'tiles_$slug.mbtiles'); + } + if (needsRoutingMaps) { + valhallaRelease = await _fetchReleaseInfo('librescoot/valhalla-tiles'); + valhallaAsset = _findAsset(valhallaRelease, 'valhalla_tiles_$slug.tar'); + } + + // Check disk space + final spaceCheck = await Process.run('df', ['-B1', '--output=avail', appDir.path]); + if (spaceCheck.exitCode == 0) { + final lines = (spaceCheck.stdout as String).trim().split('\n'); + if (lines.length >= 2) { + final available = int.tryParse(lines.last.trim()) ?? 0; + final neededBytes = + ((displayAsset?['size'] as int?) ?? 0) + + ((valhallaAsset?['size'] as int?) ?? 0); + if (neededBytes > 0 && available < neededBytes * 1.1) { + emit(state.copyWith( + status: MapDownloadStatus.error, + errorMessage: 'insufficient_space', + regionName: regionName, + )); + return; + } + } + } + + // Calculate total download size for byte-weighted progress + final displaySize = (displayAsset?['size'] as int?) ?? 0; + final valhallaSize = (valhallaAsset?['size'] as int?) ?? 0; + final totalSize = (needsDisplayMaps ? displaySize : 0) + + (needsRoutingMaps ? valhallaSize : 0); + + int displayReceived = 0; + int valhallaReceived = 0; void updateProgress() { - double total; - if (needsDisplayMaps && needsRoutingMaps) { - total = (displayProgress + routingProgress) / 2; - } else if (needsDisplayMaps) { - total = displayProgress; - } else { - total = routingProgress; + final received = displayReceived + valhallaReceived; + if (!isClosed) { + emit(state.copyWith( + status: MapDownloadStatus.downloading, + progress: totalSize > 0 ? received / totalSize : 0, + regionName: regionName, + downloadedBytes: received, + totalBytes: totalSize, + )); } - emit(MapDownloadState( - status: MapDownloadStatus.downloading, - progress: total, - regionName: regionName, - )); } + // Download display tiles if (needsDisplayMaps) { - final url = 'https://github.com/librescoot/osm-tiles/releases/download/latest/tiles_$slug.mbtiles'; - await _downloadFile( + final url = + 'https://github.com/librescoot/osm-tiles/releases/download/latest/tiles_$slug.mbtiles'; + final partPath = '${downloadDir.path}/tiles_$slug.mbtiles.part'; + final finalPath = '${appDir.path}/maps/map.mbtiles'; + + await _downloadFileResumable( url: url, - dest: '/tmp/scootui_map.mbtiles', - onProgress: (p) { - displayProgress = p; + partialPath: partPath, + onProgress: (received, total) { + displayReceived = received; updateProgress(); }, ); + + // Verify integrity + final expectedDigest = displayAsset?['digest'] as String?; + if (expectedDigest != null) { + await _verifyDigest(partPath, expectedDigest); + } + + // Atomic install + await Directory('${appDir.path}/maps').create(recursive: true); + await File(partPath).rename(finalPath); } + // Download valhalla tiles if (needsRoutingMaps) { - final url = 'https://github.com/librescoot/valhalla-tiles/releases/download/latest/valhalla_tiles_$slug.tar'; - await _downloadFile( + final url = + 'https://github.com/librescoot/valhalla-tiles/releases/download/latest/valhalla_tiles_$slug.tar'; + final partPath = '${downloadDir.path}/valhalla_tiles_$slug.tar.part'; + final finalPath = '${appDir.path}/valhalla/tiles.tar'; + + await _downloadFileResumable( url: url, - dest: '/tmp/scootui_valhalla_tiles.tar', - onProgress: (p) { - routingProgress = p; + partialPath: partPath, + onProgress: (received, total) { + valhallaReceived = received; updateProgress(); }, ); - } - emit(MapDownloadState(status: MapDownloadStatus.installing, regionName: regionName)); + // Verify integrity + final expectedDigest = valhallaAsset?['digest'] as String?; + if (expectedDigest != null) { + await _verifyDigest(partPath, expectedDigest); + } - if (needsDisplayMaps) { - await Directory('/data/maps').create(recursive: true); - await File('/tmp/scootui_map.mbtiles').rename('/data/maps/map.mbtiles'); + // Atomic install + await Directory('${appDir.path}/valhalla').create(recursive: true); + await File(partPath).rename(finalPath); } - if (needsRoutingMaps) { - await Directory('/data/valhalla').create(recursive: true); - final result = await Process.run('tar', ['-xf', '/tmp/scootui_valhalla_tiles.tar', '-C', '/data/valhalla/']); - await File('/tmp/scootui_valhalla_tiles.tar').delete(); - if (result.exitCode != 0) { - throw Exception('tar: ${result.stderr}'); - } + // Restart services + if (!isClosed) { + emit(state.copyWith( + status: MapDownloadStatus.installing, + regionName: regionName, + )); } - if (needsDisplayMaps) { - await Process.run('systemctl', ['restart', 'librescoot-mbtileserver']); - } if (needsRoutingMaps) { - await Process.run('systemctl', ['restart', 'librescoot-valhalla']); + await Process.run('systemctl', ['restart', 'valhalla']); } - emit(MapDownloadState(status: MapDownloadStatus.done, regionName: regionName)); + // Save metadata + final existingMeta = await MapMetadata.load(); + final meta = MapMetadata( + region: slug, + displayTiles: needsDisplayMaps + ? MapTileInfo( + digest: (displayAsset?['digest'] as String?) ?? '', + publishedAt: (displayRelease?['published_at'] as String?) ?? '', + size: displaySize, + ) + : existingMeta?.displayTiles, + valhallaTiles: needsRoutingMaps + ? MapTileInfo( + digest: (valhallaAsset?['digest'] as String?) ?? '', + publishedAt: (valhallaRelease?['published_at'] as String?) ?? '', + size: valhallaSize, + ) + : existingMeta?.valhallaTiles, + ); + await meta.save(); + + // Clean up download dir + await _cleanDownloadDir(downloadDir); + + if (!isClosed) { + emit(MapDownloadState( + status: MapDownloadStatus.done, + regionName: regionName, + installedMeta: meta, + updateAvailable: false, + )); + } } catch (e) { if (e is DioException && CancelToken.isCancel(e)) { - emit(const MapDownloadState(status: MapDownloadStatus.idle)); + if (!isClosed) { + emit(state.copyWith( + status: MapDownloadStatus.idle, + hasPartialDownload: true, + )); + } } else { - emit(MapDownloadState( - status: MapDownloadStatus.error, - errorMessage: e.toString(), - )); + developer.log('Download failed: $e', name: 'MapDownload'); + if (!isClosed) { + emit(state.copyWith( + status: MapDownloadStatus.error, + errorMessage: e.toString(), + )); + } } } } @@ -153,32 +376,149 @@ class MapDownloadCubit extends Cubit { return super.close(); } - Future _resolveSlug(double latitude, double longitude) async { - final response = await _dio.get( - 'https://nominatim.openstreetmap.org/reverse', - queryParameters: {'lat': latitude, 'lon': longitude, 'format': 'json', 'zoom': 5}, - options: Options(headers: {'User-Agent': 'LibreScoot/1.0 (navigation setup)'}), - ); - final state = response.data?['address']?['state'] as String?; - if (state == null) return null; - return _stateToSlug[state]; + // --- Private helpers --- + + Future _downloadDir() async { + final appDir = await getApplicationDocumentsDirectory(); + return Directory('${appDir.path}/maps/.download'); + } + + Future _cleanPartialIfRegionChanged( + Directory downloadDir, String newSlug) async { + final regionFile = File('${downloadDir.path}/region'); + if (await regionFile.exists()) { + final oldSlug = (await regionFile.readAsString()).trim(); + if (oldSlug != newSlug) { + developer.log('Region changed from $oldSlug to $newSlug, cleaning partial files', + name: 'MapDownload'); + await _cleanDownloadDir(downloadDir); + await downloadDir.create(recursive: true); + } + } } - Future _downloadFile({ + Future _cleanDownloadDir(Directory dir) async { + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } + + Future _downloadFileResumable({ required String url, - required String dest, - required void Function(double) onProgress, + required String partialPath, + required void Function(int received, int total) onProgress, }) async { - await _dio.download( + final partialFile = File(partialPath); + int existingBytes = 0; + + if (await partialFile.exists()) { + existingBytes = await partialFile.length(); + developer.log('Resuming download from $existingBytes bytes: $url', + name: 'MapDownload'); + } + + final response = await _dio.get( url, - dest, + options: Options( + responseType: ResponseType.stream, + followRedirects: true, + headers: existingBytes > 0 ? {'Range': 'bytes=$existingBytes-'} : null, + ), cancelToken: _cancelToken, - onReceiveProgress: (received, total) { - if (total > 0) onProgress(received / total); + ); + + final isResume = response.statusCode == 206; + final contentLength = + int.tryParse(response.headers.value('content-length') ?? '') ?? 0; + final totalBytes = isResume ? existingBytes + contentLength : contentLength; + + // If server doesn't support range (returned 200 instead of 206), start over + if (existingBytes > 0 && !isResume) { + existingBytes = 0; + developer.log('Server does not support range requests, restarting download', + name: 'MapDownload'); + } + + final sink = partialFile.openWrite( + mode: isResume ? FileMode.append : FileMode.write); + int received = existingBytes; + + try { + await for (final chunk in response.data!.stream) { + sink.add(chunk); + received += chunk.length; + onProgress(received, totalBytes); + } + await sink.flush(); + } finally { + await sink.close(); + } + } + + Future _verifyDigest(String filePath, String expectedDigest) async { + // expectedDigest format: "sha256:abcdef..." + if (!expectedDigest.startsWith('sha256:')) return; + final expectedHash = expectedDigest.substring(7); + + developer.log('Verifying SHA256 of $filePath', name: 'MapDownload'); + final file = File(filePath); + final digest = await file + .openRead() + .transform(sha256) + .map((d) => d.toString()) + .first; + + if (digest != expectedHash) { + developer.log( + 'SHA256 mismatch: expected $expectedHash, got $digest', + name: 'MapDownload'); + await file.delete(); + throw Exception('Download integrity check failed'); + } + developer.log('SHA256 verified OK', name: 'MapDownload'); + } + + Future> _fetchReleaseInfo(String repo) async { + final response = await _dio.get( + 'https://api.github.com/repos/$repo/releases/tags/latest', + options: Options(headers: {'User-Agent': 'LibreScoot/1.0'}), + ); + return response.data as Map; + } + + Map? _findAsset( + Map release, String filename) { + final assets = release['assets'] as List?; + if (assets == null) return null; + for (final asset in assets) { + if ((asset as Map)['name'] == filename) { + return asset; + } + } + return null; + } + + Future _resolveSlug(double latitude, double longitude) async { + final response = await _dio.get( + 'https://nominatim.openstreetmap.org/reverse', + queryParameters: { + 'lat': latitude, + 'lon': longitude, + 'format': 'json', + 'zoom': 5, }, + options: Options( + headers: {'User-Agent': 'LibreScoot/1.0 (navigation setup)'}), + cancelToken: _cancelToken, ); + final state = response.data?['address']?['state'] as String?; + if (state == null) return null; + return _stateToSlug[state]; } + static String _displayName(String slug) => + _slugToDisplayName[slug] ?? slug.replaceAll('_', '/').replaceAll('-', ' '); + static const _stateToSlug = { 'Baden-Württemberg': 'baden-wuerttemberg', 'Bayern': 'bayern', diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5218a20..20a1130 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -26,6 +26,11 @@ "navSetupDownloadWaitingGps": "Warte auf GPS-Signal...", "navSetupDownloadNoInternet": "Keine Internetverbindung", "navSetupDownloadUnsupported": "Keine Karten für Ihren Standort verfügbar", + "navSetupUpdateButton": "Karten aktualisieren", + "navSetupResumeButton": "Download fortsetzen", + "navSetupCheckingUpdates": "Suche nach Updates...", + "navSetupDownloadProgressBytes": "Herunterladen... {downloaded} / {total} MB", + "navSetupInsufficientSpace": "Nicht genügend Speicherplatz", "menuEnterDestinationCode": "Zielcode eingeben", "menuSavedLocations": "Gespeicherte Orte", "menuSavedLocationsHeader": "GESPEICHERTE ORTE", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 122e067..081140a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -31,6 +31,17 @@ "navSetupDownloadWaitingGps": "Waiting for GPS fix...", "navSetupDownloadNoInternet": "No internet connection", "navSetupDownloadUnsupported": "No maps available for your location", + "navSetupUpdateButton": "Update maps", + "navSetupResumeButton": "Resume download", + "navSetupCheckingUpdates": "Checking for updates...", + "navSetupDownloadProgressBytes": "Downloading... {downloaded} / {total} MB", + "@navSetupDownloadProgressBytes": { + "placeholders": { + "downloaded": {"type": "String"}, + "total": {"type": "String"} + } + }, + "navSetupInsufficientSpace": "Not enough storage space", "menuEnterDestinationCode": "Enter Destination Code", "menuSavedLocations": "Saved Locations", "menuSavedLocationsHeader": "SAVED LOCATIONS", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7673a49..f0b8b62 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -236,6 +236,36 @@ abstract class AppLocalizations { /// **'No maps available for your location'** String get navSetupDownloadUnsupported; + /// No description provided for @navSetupUpdateButton. + /// + /// In en, this message translates to: + /// **'Update maps'** + String get navSetupUpdateButton; + + /// No description provided for @navSetupResumeButton. + /// + /// In en, this message translates to: + /// **'Resume download'** + String get navSetupResumeButton; + + /// No description provided for @navSetupCheckingUpdates. + /// + /// In en, this message translates to: + /// **'Checking for updates...'** + String get navSetupCheckingUpdates; + + /// No description provided for @navSetupDownloadProgressBytes. + /// + /// In en, this message translates to: + /// **'Downloading... {downloaded} / {total} MB'** + String navSetupDownloadProgressBytes(String downloaded, String total); + + /// No description provided for @navSetupInsufficientSpace. + /// + /// In en, this message translates to: + /// **'Not enough storage space'** + String get navSetupInsufficientSpace; + /// No description provided for @menuEnterDestinationCode. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b4db1a9..1ddf813 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -83,6 +83,23 @@ class AppLocalizationsDe extends AppLocalizations { @override String get navSetupDownloadUnsupported => 'Keine Karten für Ihren Standort verfügbar'; + @override + String get navSetupUpdateButton => 'Karten aktualisieren'; + + @override + String get navSetupResumeButton => 'Download fortsetzen'; + + @override + String get navSetupCheckingUpdates => 'Suche nach Updates...'; + + @override + String navSetupDownloadProgressBytes(String downloaded, String total) { + return 'Herunterladen... $downloaded / $total MB'; + } + + @override + String get navSetupInsufficientSpace => 'Nicht genügend Speicherplatz'; + @override String get menuEnterDestinationCode => 'Zielcode eingeben'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index bba3e0c..724d100 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -83,6 +83,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get navSetupDownloadUnsupported => 'No maps available for your location'; + @override + String get navSetupUpdateButton => 'Update maps'; + + @override + String get navSetupResumeButton => 'Resume download'; + + @override + String get navSetupCheckingUpdates => 'Checking for updates...'; + + @override + String navSetupDownloadProgressBytes(String downloaded, String total) { + return 'Downloading... $downloaded / $total MB'; + } + + @override + String get navSetupInsufficientSpace => 'Not enough storage space'; + @override String get menuEnterDestinationCode => 'Enter Destination Code'; diff --git a/lib/models/map_metadata.dart b/lib/models/map_metadata.dart new file mode 100644 index 0000000..a5f44e7 --- /dev/null +++ b/lib/models/map_metadata.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +class MapTileInfo { + final String digest; + final String publishedAt; + final int size; + + const MapTileInfo({ + required this.digest, + required this.publishedAt, + required this.size, + }); + + factory MapTileInfo.fromJson(Map json) => MapTileInfo( + digest: json['digest'] as String, + publishedAt: json['publishedAt'] as String, + size: json['size'] as int, + ); + + Map toJson() => { + 'digest': digest, + 'publishedAt': publishedAt, + 'size': size, + }; +} + +class MapMetadata { + final String region; + final MapTileInfo? displayTiles; + final MapTileInfo? valhallaTiles; + + const MapMetadata({ + required this.region, + this.displayTiles, + this.valhallaTiles, + }); + + factory MapMetadata.fromJson(Map json) => MapMetadata( + region: json['region'] as String, + displayTiles: json['displayTiles'] != null + ? MapTileInfo.fromJson(json['displayTiles'] as Map) + : null, + valhallaTiles: json['valhallaTiles'] != null + ? MapTileInfo.fromJson( + json['valhallaTiles'] as Map) + : null, + ); + + Map toJson() => { + 'region': region, + if (displayTiles != null) 'displayTiles': displayTiles!.toJson(), + if (valhallaTiles != null) 'valhallaTiles': valhallaTiles!.toJson(), + }; + + static Future _metadataPath() async { + final appDir = await getApplicationDocumentsDirectory(); + return '${appDir.path}/maps/metadata.json'; + } + + static Future load() async { + try { + final path = await _metadataPath(); + final file = File(path); + if (!await file.exists()) return null; + final json = jsonDecode(await file.readAsString()) as Map; + return MapMetadata.fromJson(json); + } catch (_) { + return null; + } + } + + Future save() async { + final path = await _metadataPath(); + final file = File(path); + await file.parent.create(recursive: true); + final tmpFile = File('$path.tmp'); + await tmpFile.writeAsString(jsonEncode(toJson())); + await tmpFile.rename(path); + } +} diff --git a/lib/screens/navigation_setup_screen.dart b/lib/screens/navigation_setup_screen.dart index bea5a69..a06c793 100644 --- a/lib/screens/navigation_setup_screen.dart +++ b/lib/screens/navigation_setup_screen.dart @@ -43,6 +43,8 @@ class _Content extends StatelessWidget { final divider = isDark ? Colors.white12 : Colors.black12; final anyMissing = !navState.localDisplayMapsAvailable || !navState.routingAvailable; + final downloadState = context.watch().state; + final showDownloadSection = anyMissing || downloadState.updateAvailable || downloadState.hasPartialDownload; final String title; if (!navState.routingAvailable && !navState.localDisplayMapsAvailable) { @@ -90,7 +92,7 @@ class _Content extends StatelessWidget { available: navState.routingAvailable, isDark: isDark, ), - if (anyMissing) ...[ + if (showDownloadSection) ...[ const SizedBox(height: 12), Divider(color: divider), const SizedBox(height: 4), @@ -164,12 +166,19 @@ class _DownloadSection extends StatelessWidget { final hasGps = gps.state == GpsState.fixEstablished && gps.latitude != 0; switch (downloadState.status) { + case MapDownloadStatus.checkingUpdates: + return Text(l10n.navSetupCheckingUpdates, + style: TextStyle(fontSize: 13, color: fgDim)); + case MapDownloadStatus.locating: return Text(l10n.navSetupDownloadLocating, style: TextStyle(fontSize: 13, color: fgDim)); case MapDownloadStatus.downloading: final percent = (downloadState.progress * 100).toInt(); + final downloadedMB = (downloadState.downloadedBytes / 1048576).toStringAsFixed(0); + final totalMB = (downloadState.totalBytes / 1048576).toStringAsFixed(0); + final hasSize = downloadState.totalBytes > 0; return Column( children: [ LinearProgressIndicator( @@ -178,8 +187,12 @@ class _DownloadSection extends StatelessWidget { backgroundColor: isDark ? Colors.white24 : Colors.black12, ), const SizedBox(height: 6), - Text(l10n.navSetupDownloadProgress(percent), - style: TextStyle(fontSize: 13, color: fgDim)), + Text( + hasSize + ? l10n.navSetupDownloadProgressBytes(downloadedMB, totalMB) + : l10n.navSetupDownloadProgress(percent), + style: TextStyle(fontSize: 13, color: fgDim), + ), ], ); @@ -192,12 +205,17 @@ class _DownloadSection extends StatelessWidget { style: TextStyle(fontSize: 13, color: Colors.green.shade600)); case MapDownloadStatus.error: + final errorMsg = downloadState.errorMessage == 'insufficient_space' + ? l10n.navSetupInsufficientSpace + : downloadState.errorMessage == 'unsupported' + ? l10n.navSetupDownloadUnsupported + : l10n.navSetupDownloadError; return Column( children: [ - Text(l10n.navSetupDownloadError, + Text(errorMsg, style: TextStyle(fontSize: 13, color: Colors.red.shade400)), const SizedBox(height: 4), - _downloadButton(context, gps, l10n), + _downloadButton(context, gps, l10n, downloadState), ], ); @@ -210,21 +228,31 @@ class _DownloadSection extends StatelessWidget { return Text(l10n.navSetupDownloadWaitingGps, style: TextStyle(fontSize: 13, color: fgDim)); } - return _downloadButton(context, gps, l10n); + return _downloadButton(context, gps, l10n, downloadState); } } - Widget _downloadButton(BuildContext context, GpsData gps, dynamic l10n) { + Widget _downloadButton( + BuildContext context, GpsData gps, dynamic l10n, MapDownloadState downloadState) { + final isUpdate = downloadState.updateAvailable; + final isResume = downloadState.hasPartialDownload && !isUpdate; + final label = isUpdate + ? l10n.navSetupUpdateButton + : isResume + ? l10n.navSetupResumeButton + : l10n.navSetupDownloadButton; + final icon = isUpdate ? Icons.update_outlined : Icons.download_outlined; + return TextButton.icon( style: TextButton.styleFrom(padding: EdgeInsets.zero), - icon: Icon(Icons.download_outlined, color: Colors.green.shade600, size: 18), - label: Text(l10n.navSetupDownloadButton, + icon: Icon(icon, color: Colors.green.shade600, size: 18), + label: Text(label, style: TextStyle(color: Colors.green.shade600, fontSize: 13)), onPressed: () => context.read().startDownload( latitude: gps.latitude, longitude: gps.longitude, - needsDisplayMaps: !navState.localDisplayMapsAvailable, - needsRoutingMaps: !navState.routingAvailable, + needsDisplayMaps: isUpdate || !navState.localDisplayMapsAvailable, + needsRoutingMaps: isUpdate || !navState.routingAvailable, ), ); }