diff --git a/lib/cubits/map_download_cubit.dart b/lib/cubits/map_download_cubit.dart new file mode 100644 index 0000000..8249fc1 --- /dev/null +++ b/lib/cubits/map_download_cubit.dart @@ -0,0 +1,544 @@ +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'; + +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({ + MapDownloadStatus? status, + 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, + ); +} + +class MapDownloadCubit extends Cubit { + final _dio = Dio(); + CancelToken? _cancelToken; + + 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, + required double longitude, + required bool needsDisplayMaps, + required bool needsRoutingMaps, + }) async { + if (state.status != MapDownloadStatus.idle && + state.status != MapDownloadStatus.error) { + return; + } + + _cancelToken = CancelToken(); + + try { + emit(state.copyWith(status: MapDownloadStatus.locating)); + + final slug = await _resolveSlug(latitude, longitude); + if (slug == null) { + emit(state.copyWith( + status: MapDownloadStatus.error, + errorMessage: 'unsupported', + )); + return; + } + + 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() { + final received = displayReceived + valhallaReceived; + if (!isClosed) { + emit(state.copyWith( + status: MapDownloadStatus.downloading, + progress: totalSize > 0 ? received / totalSize : 0, + regionName: regionName, + downloadedBytes: received, + totalBytes: totalSize, + )); + } + } + + // Download display tiles + if (needsDisplayMaps) { + 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, + 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'; + final partPath = '${downloadDir.path}/valhalla_tiles_$slug.tar.part'; + final finalPath = '${appDir.path}/valhalla/tiles.tar'; + + await _downloadFileResumable( + url: url, + partialPath: partPath, + onProgress: (received, total) { + valhallaReceived = received; + updateProgress(); + }, + ); + + // Verify integrity + final expectedDigest = valhallaAsset?['digest'] as String?; + if (expectedDigest != null) { + await _verifyDigest(partPath, expectedDigest); + } + + // Atomic install + await Directory('${appDir.path}/valhalla').create(recursive: true); + await File(partPath).rename(finalPath); + } + + // Restart services + if (!isClosed) { + emit(state.copyWith( + status: MapDownloadStatus.installing, + regionName: regionName, + )); + } + + if (needsRoutingMaps) { + await Process.run('systemctl', ['restart', 'valhalla']); + } + + // 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)) { + if (!isClosed) { + emit(state.copyWith( + status: MapDownloadStatus.idle, + hasPartialDownload: true, + )); + } + } else { + developer.log('Download failed: $e', name: 'MapDownload'); + if (!isClosed) { + emit(state.copyWith( + 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(); + } + + // --- 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 _cleanDownloadDir(Directory dir) async { + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } + + Future _downloadFileResumable({ + required String url, + required String partialPath, + required void Function(int received, int total) onProgress, + }) async { + 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, + options: Options( + responseType: ResponseType.stream, + followRedirects: true, + headers: existingBytes > 0 ? {'Range': 'bytes=$existingBytes-'} : null, + ), + cancelToken: _cancelToken, + ); + + 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', + '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..20a1130 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -17,6 +17,20 @@ "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", + "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 e8a4f5c..081140a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -17,6 +17,31 @@ "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", + "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 2a2d25c..f0b8b62 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -182,6 +182,90 @@ 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 @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 ba83038..1ddf813 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -54,6 +54,52 @@ 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 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 17f5489..724d100 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -54,6 +54,52 @@ 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 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 33d6c8d..a06c793 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,10 @@ 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 downloadState = context.watch().state; + final showDownloadSection = anyMissing || downloadState.updateAvailable || downloadState.hasPartialDownload; + final String title; if (!navState.routingAvailable && !navState.localDisplayMapsAvailable) { title = l10n.navSetupTitleBothUnavailable; @@ -38,84 +57,205 @@ 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, + ), + if (showDownloadSection) ...[ + 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, ), - dataModuleStyle: const QrDataModuleStyle( - dataModuleShape: QrDataModuleShape.square, - color: Colors.black, + 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), - ), - ], + 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.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( + value: downloadState.progress, + color: Colors.green.shade600, + backgroundColor: isDark ? Colors.white24 : Colors.black12, + ), + const SizedBox(height: 6), + Text( + hasSize + ? l10n.navSetupDownloadProgressBytes(downloadedMB, totalMB) + : 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: + final errorMsg = downloadState.errorMessage == 'insufficient_space' + ? l10n.navSetupInsufficientSpace + : downloadState.errorMessage == 'unsupported' + ? l10n.navSetupDownloadUnsupported + : l10n.navSetupDownloadError; + return Column( + children: [ + Text(errorMsg, + style: TextStyle(fontSize: 13, color: Colors.red.shade400)), + const SizedBox(height: 4), + _downloadButton(context, gps, l10n, downloadState), + ], + ); + + 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, downloadState); + } + } + + 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(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: isUpdate || !navState.localDisplayMapsAvailable, + needsRoutingMaps: isUpdate || !navState.routingAvailable, + ), + ); + } } class _StatusRow extends StatelessWidget {