From 2b26bf918836db9fece23ba4afc55477003987a5 Mon Sep 17 00:00:00 2001 From: stopflock Date: Mon, 29 Sep 2025 21:28:40 -0500 Subject: [PATCH] Preview tile fetching on startup, offline area refresh, more cameras->nodes --- lib/app_state.dart | 14 +++ lib/localizations/de.json | 16 ++- lib/localizations/en.json | 16 ++- lib/localizations/es.json | 16 ++- lib/localizations/fr.json | 16 ++- lib/localizations/it.json | 16 ++- lib/localizations/pt.json | 16 ++- lib/localizations/zh.json | 16 ++- .../offline_areas_section.dart | 107 ++++++++++++++++- lib/services/offline_area_service.dart | 109 ++++++++++++++++++ .../offline_area_downloader.dart | 8 +- lib/services/tile_preview_service.dart | 80 +++++++++++++ 12 files changed, 403 insertions(+), 27 deletions(-) create mode 100644 lib/services/tile_preview_service.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index 61e6107..059d1d1 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -9,6 +9,7 @@ import 'models/pending_upload.dart'; import 'models/tile_provider.dart'; import 'services/offline_area_service.dart'; import 'services/node_cache.dart'; +import 'services/tile_preview_service.dart'; import 'widgets/camera_provider_with_cache.dart'; import 'state/auth_state.dart'; import 'state/operator_profile_state.dart'; @@ -101,6 +102,10 @@ class AppState extends ChangeNotifier { Future _init() async { // Initialize all state modules await _settingsState.init(); + + // Attempt to fetch missing tile type preview tiles (fails silently) + _fetchMissingTilePreviews(); + await _operatorProfileState.init(); await _profileState.init(); await _uploadQueueState.init(); @@ -297,6 +302,15 @@ class AppState extends ChangeNotifier { } // ---------- Private Methods ---------- + /// Attempts to fetch missing tile preview images in the background (fire and forget) + void _fetchMissingTilePreviews() { + // Run asynchronously without awaiting to avoid blocking app startup + TilePreviewService.fetchMissingPreviews(_settingsState).catchError((error) { + // Silently ignore errors - this is best effort + debugPrint('AppState: Tile preview fetching failed silently: $error'); + }); + } + void _startUploader() { _uploadQueueState.startUploader( offlineMode: offlineMode, diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 9ed1a00..14a663d 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -79,7 +79,7 @@ "withinTileLimit": "Innerhalb {} Kachel-Limit", "exceedsTileLimit": "Aktuelle Auswahl überschreitet {} Kachel-Limit", "offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.", - "downloadStarted": "Download gestartet! Lade Kacheln und Kameras...", + "downloadStarted": "Download gestartet! Lade Kacheln und Knoten...", "downloadFailed": "Download konnte nicht gestartet werden: {}" }, "uploadMode": { @@ -225,7 +225,7 @@ "longitude": "Länge", "tiles": "Kacheln", "size": "Größe", - "cameras": "Kameras", + "nodes": "Knoten", "areaIdFallback": "Bereich {}...", "renameArea": "Bereich umbenennen", "refreshWorldTiles": "Welt-Kacheln aktualisieren/neu herunterladen", @@ -236,7 +236,17 @@ "renameButton": "Umbenennen", "megabytes": "MB", "kilobytes": "KB", - "progress": "{}%" + "progress": "{}%", + "refreshArea": "Bereich aktualisieren", + "refreshAreaDialogTitle": "Offline-Bereich aktualisieren", + "refreshAreaDialogSubtitle": "Wählen Sie aus, was für diesen Bereich aktualisiert werden soll:", + "refreshTiles": "Karten-Kacheln aktualisieren", + "refreshTilesSubtitle": "Alle Kacheln für aktualisierte Bilder erneut herunterladen", + "refreshNodes": "Knoten aktualisieren", + "refreshNodesSubtitle": "Knotendaten für diesen Bereich erneut abrufen", + "startRefresh": "Aktualisierung starten", + "refreshStarted": "Aktualisierung gestartet!", + "refreshFailed": "Aktualisierung fehlgeschlagen: {}" }, "refineTagsSheet": { "title": "Tags Verfeinern", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index f3a8168..4414d3e 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -79,7 +79,7 @@ "withinTileLimit": "Within {} tile limit", "exceedsTileLimit": "Current selection exceeds {} tile limit", "offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.", - "downloadStarted": "Download started! Fetching tiles and cameras...", + "downloadStarted": "Download started! Fetching tiles and nodes...", "downloadFailed": "Failed to start download: {}" }, "uploadMode": { @@ -225,7 +225,7 @@ "longitude": "Lon", "tiles": "Tiles", "size": "Size", - "cameras": "Cameras", + "nodes": "Nodes", "areaIdFallback": "Area {}...", "renameArea": "Rename area", "refreshWorldTiles": "Refresh/re-download world tiles", @@ -236,7 +236,17 @@ "renameButton": "Rename", "megabytes": "MB", "kilobytes": "KB", - "progress": "{}%" + "progress": "{}%", + "refreshArea": "Refresh area", + "refreshAreaDialogTitle": "Refresh Offline Area", + "refreshAreaDialogSubtitle": "Choose what to refresh for this area:", + "refreshTiles": "Refresh Map Tiles", + "refreshTilesSubtitle": "Re-download all tiles for updated imagery", + "refreshNodes": "Refresh Nodes", + "refreshNodesSubtitle": "Re-fetch node data for this area", + "startRefresh": "Start Refresh", + "refreshStarted": "Refresh started!", + "refreshFailed": "Refresh failed: {}" }, "refineTagsSheet": { "title": "Refine Tags", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 6f8b035..44bf449 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -79,7 +79,7 @@ "withinTileLimit": "Dentro del límite de {} mosaicos", "exceedsTileLimit": "La selección actual excede el límite de {} mosaicos", "offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.", - "downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y cámaras...", + "downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...", "downloadFailed": "Error al iniciar la descarga: {}" }, "uploadMode": { @@ -225,7 +225,7 @@ "longitude": "Lon", "tiles": "Teselas", "size": "Tamaño", - "cameras": "Cámaras", + "nodes": "Nodos", "areaIdFallback": "Área {}...", "renameArea": "Renombrar área", "refreshWorldTiles": "Actualizar/re-descargar teselas mundiales", @@ -236,7 +236,17 @@ "renameButton": "Renombrar", "megabytes": "MB", "kilobytes": "KB", - "progress": "{}%" + "progress": "{}%", + "refreshArea": "Actualizar área", + "refreshAreaDialogTitle": "Actualizar Área sin Conexión", + "refreshAreaDialogSubtitle": "Elija qué actualizar para esta área:", + "refreshTiles": "Actualizar Mosaicos del Mapa", + "refreshTilesSubtitle": "Volver a descargar todos los mosaicos para imágenes actualizadas", + "refreshNodes": "Actualizar Nodos", + "refreshNodesSubtitle": "Volver a obtener datos de nodos para esta área", + "startRefresh": "Iniciar Actualización", + "refreshStarted": "¡Actualización iniciada!", + "refreshFailed": "Actualización falló: {}" }, "refineTagsSheet": { "title": "Refinar Etiquetas", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 429cde5..49cb1bd 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -79,7 +79,7 @@ "withinTileLimit": "Dans la limite de {} tuiles", "exceedsTileLimit": "La sélection actuelle dépasse la limite de {} tuiles", "offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.", - "downloadStarted": "Téléchargement démarré! Récupération des tuiles et caméras...", + "downloadStarted": "Téléchargement démarré! Récupération des tuiles et nœuds...", "downloadFailed": "Échec du démarrage du téléchargement: {}" }, "uploadMode": { @@ -225,7 +225,7 @@ "longitude": "Lon", "tiles": "Tuiles", "size": "Taille", - "cameras": "Caméras", + "nodes": "Nœuds", "areaIdFallback": "Zone {}...", "renameArea": "Renommer la zone", "refreshWorldTiles": "Actualiser/re-télécharger les tuiles mondiales", @@ -236,7 +236,17 @@ "renameButton": "Renommer", "megabytes": "Mo", "kilobytes": "Ko", - "progress": "{}%" + "progress": "{}%", + "refreshArea": "Actualiser la zone", + "refreshAreaDialogTitle": "Actualiser la Zone Hors Ligne", + "refreshAreaDialogSubtitle": "Choisissez quoi actualiser pour cette zone :", + "refreshTiles": "Actualiser les Tuiles de Carte", + "refreshTilesSubtitle": "Télécharger à nouveau toutes les tuiles pour des images mises à jour", + "refreshNodes": "Actualiser les Nœuds", + "refreshNodesSubtitle": "Récupérer à nouveau les données de nœuds pour cette zone", + "startRefresh": "Démarrer l'Actualisation", + "refreshStarted": "Actualisation démarrée !", + "refreshFailed": "Actualisation échouée : {}" }, "refineTagsSheet": { "title": "Affiner les Étiquettes", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index c80cb36..00163bb 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -79,7 +79,7 @@ "withinTileLimit": "Entro il limite di {} tile", "exceedsTileLimit": "La selezione corrente supera il limite di {} tile", "offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.", - "downloadStarted": "Download avviato! Recupero tile e telecamere...", + "downloadStarted": "Download avviato! Recupero tile e nodi...", "downloadFailed": "Impossibile avviare il download: {}" }, "uploadMode": { @@ -225,7 +225,7 @@ "longitude": "Lon", "tiles": "Tile", "size": "Dimensione", - "cameras": "Telecamere", + "nodes": "Nodi", "areaIdFallback": "Area {}...", "renameArea": "Rinomina area", "refreshWorldTiles": "Aggiorna/ri-scarica tile mondiali", @@ -236,7 +236,17 @@ "renameButton": "Rinomina", "megabytes": "MB", "kilobytes": "KB", - "progress": "{}%" + "progress": "{}%", + "refreshArea": "Aggiorna area", + "refreshAreaDialogTitle": "Aggiorna Area Offline", + "refreshAreaDialogSubtitle": "Scegli cosa aggiornare per quest'area:", + "refreshTiles": "Aggiorna Tile Mappa", + "refreshTilesSubtitle": "Riscarica tutte le tile per immagini aggiornate", + "refreshNodes": "Aggiorna Nodi", + "refreshNodesSubtitle": "Ricarica i dati dei nodi per quest'area", + "startRefresh": "Avvia Aggiornamento", + "refreshStarted": "Aggiornamento avviato!", + "refreshFailed": "Aggiornamento fallito: {}" }, "refineTagsSheet": { "title": "Affina Tag", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 3ba76e9..971b9fa 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -79,7 +79,7 @@ "withinTileLimit": "Dentro do limite de {} tiles", "exceedsTileLimit": "A seleção atual excede o limite de {} tiles", "offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.", - "downloadStarted": "Download iniciado! Buscando tiles e câmeras...", + "downloadStarted": "Download iniciado! Buscando tiles e nós...", "downloadFailed": "Falha ao iniciar o download: {}" }, "uploadMode": { @@ -225,7 +225,7 @@ "longitude": "Lon", "tiles": "Tiles", "size": "Tamanho", - "cameras": "Câmeras", + "nodes": "Nós", "areaIdFallback": "Área {}...", "renameArea": "Renomear área", "refreshWorldTiles": "Atualizar/rebaixar tiles mundiais", @@ -236,7 +236,17 @@ "renameButton": "Renomear", "megabytes": "MB", "kilobytes": "KB", - "progress": "{}%" + "progress": "{}%", + "refreshArea": "Atualizar área", + "refreshAreaDialogTitle": "Atualizar Área Offline", + "refreshAreaDialogSubtitle": "Escolha o que atualizar para esta área:", + "refreshTiles": "Atualizar Tiles do Mapa", + "refreshTilesSubtitle": "Baixar novamente todos os tiles para imagens atualizadas", + "refreshNodes": "Atualizar Nós", + "refreshNodesSubtitle": "Buscar novamente os dados dos nós para esta área", + "startRefresh": "Iniciar Atualização", + "refreshStarted": "Atualização iniciada!", + "refreshFailed": "Atualização falhou: {}" }, "refineTagsSheet": { "title": "Refinar Tags", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 222fc4d..0cb4501 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -79,7 +79,7 @@ "withinTileLimit": "在 {} 瓦片限制内", "exceedsTileLimit": "当前选择超出 {} 瓦片限制", "offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。", - "downloadStarted": "下载已开始!正在获取瓦片和摄像头...", + "downloadStarted": "下载已开始!正在获取瓦片和节点...", "downloadFailed": "启动下载失败:{}" }, "uploadMode": { @@ -225,7 +225,7 @@ "longitude": "经度", "tiles": "瓦片", "size": "大小", - "cameras": "摄像头", + "nodes": "节点", "areaIdFallback": "区域 {}...", "renameArea": "重命名区域", "refreshWorldTiles": "刷新/重新下载世界瓦片", @@ -236,7 +236,17 @@ "renameButton": "重命名", "megabytes": "MB", "kilobytes": "KB", - "progress": "{}%" + "progress": "{}%", + "refreshArea": "刷新区域", + "refreshAreaDialogTitle": "刷新离线区域", + "refreshAreaDialogSubtitle": "选择要为此区域刷新的内容:", + "refreshTiles": "刷新地图瓦片", + "refreshTilesSubtitle": "重新下载所有瓦片以获取更新的图像", + "refreshNodes": "刷新节点", + "refreshNodesSubtitle": "重新获取此区域的节点数据", + "startRefresh": "开始刷新", + "refreshStarted": "刷新已开始!", + "refreshFailed": "刷新失败:{}" }, "refineTagsSheet": { "title": "细化标签", diff --git a/lib/screens/settings_screen_sections/offline_areas_section.dart b/lib/screens/settings_screen_sections/offline_areas_section.dart index 54e7ec3..2b70593 100644 --- a/lib/screens/settings_screen_sections/offline_areas_section.dart +++ b/lib/screens/settings_screen_sections/offline_areas_section.dart @@ -24,6 +24,39 @@ class _OfflineAreasSectionState extends State { }); } + void _showRefreshDialog(OfflineArea area) { + showDialog( + context: context, + builder: (context) => _RefreshAreaDialog( + area: area, + onRefresh: (refreshTiles, refreshNodes) { + try { + // ignore: unawaited_futures + service.refreshArea( + id: area.id, + refreshTiles: refreshTiles, + refreshNodes: refreshNodes, + onProgress: (progress) => setState(() {}), + onComplete: (status) => setState(() {}), + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(LocalizationService.instance.t('offlineAreas.refreshStarted')), + ), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(LocalizationService.instance.t('offlineAreas.refreshFailed', params: [e.toString()])), + ), + ); + } + }, + ), + ); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -59,7 +92,7 @@ class _OfflineAreasSectionState extends State { subtitle += '\n${locService.t('offlineAreas.tiles')}: ${area.tilesTotal}'; } subtitle += '\n${locService.t('offlineAreas.size')}: $diskStr'; - subtitle += '\n${locService.t('offlineAreas.cameras')}: ${area.nodes.length}'; + subtitle += '\n${locService.t('offlineAreas.nodes')}: ${area.nodes.length}'; return Card( child: ListTile( leading: Icon(area.status == OfflineAreaStatus.complete @@ -113,7 +146,12 @@ class _OfflineAreasSectionState extends State { } }, ), - if (area.status != OfflineAreaStatus.downloading) + if (area.status != OfflineAreaStatus.downloading) ...[ + IconButton( + icon: const Icon(Icons.refresh, color: Colors.blue), + tooltip: locService.t('offlineAreas.refreshArea'), + onPressed: () => _showRefreshDialog(area), + ), IconButton( icon: const Icon(Icons.delete, color: Colors.red), tooltip: locService.t('offlineAreas.deleteOfflineArea'), @@ -122,6 +160,7 @@ class _OfflineAreasSectionState extends State { setState(() {}); }, ), + ], ], ), subtitle: Text(subtitle), @@ -168,3 +207,67 @@ class _OfflineAreasSectionState extends State { ); } } + +class _RefreshAreaDialog extends StatefulWidget { + final OfflineArea area; + final Function(bool refreshTiles, bool refreshNodes) onRefresh; + + const _RefreshAreaDialog({ + required this.area, + required this.onRefresh, + }); + + @override + State<_RefreshAreaDialog> createState() => _RefreshAreaDialogState(); +} + +class _RefreshAreaDialogState extends State<_RefreshAreaDialog> { + bool _refreshTiles = true; + bool _refreshNodes = true; + + @override + Widget build(BuildContext context) { + final locService = LocalizationService.instance; + + return AlertDialog( + title: Text(locService.t('offlineAreas.refreshAreaDialogTitle')), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(locService.t('offlineAreas.refreshAreaDialogSubtitle')), + const SizedBox(height: 16), + CheckboxListTile( + title: Text(locService.t('offlineAreas.refreshTiles')), + subtitle: Text(locService.t('offlineAreas.refreshTilesSubtitle')), + value: _refreshTiles, + onChanged: (value) => setState(() => _refreshTiles = value ?? true), + dense: true, + ), + CheckboxListTile( + title: Text(locService.t('offlineAreas.refreshNodes')), + subtitle: Text(locService.t('offlineAreas.refreshNodesSubtitle')), + value: _refreshNodes, + onChanged: (value) => setState(() => _refreshNodes = value ?? true), + dense: true, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(locService.t('actions.cancel')), + ), + ElevatedButton( + onPressed: (_refreshTiles || _refreshNodes) + ? () { + Navigator.of(context).pop(); + widget.onRefresh(_refreshTiles, _refreshNodes); + } + : null, + child: Text(locService.t('offlineAreas.startRefresh')), + ), + ], + ); + } +} diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 33e5c09..2d29164 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -272,6 +272,115 @@ class OfflineAreaService { _areas.remove(area); await saveAreasToDisk(); } + + /// Refresh/update an existing offline area - tiles, nodes, or both + Future refreshArea({ + required String id, + required bool refreshTiles, + required bool refreshNodes, + void Function(double progress)? onProgress, + void Function(OfflineAreaStatus status)? onComplete, + }) async { + final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found'); + + if (area.status == OfflineAreaStatus.downloading) { + throw 'Area is already downloading'; + } + + // Set area to downloading state + area.status = OfflineAreaStatus.downloading; + area.progress = 0.0; + area.tilesDownloaded = 0; + await saveAreasToDisk(); + + try { + bool success = true; + + if (refreshTiles && refreshNodes) { + // Refresh both - use the full download process + success = await OfflineAreaDownloader.downloadArea( + area: area, + bounds: area.bounds, + minZoom: area.minZoom, + maxZoom: area.maxZoom, + directory: area.directory, + onProgress: onProgress, + saveAreasToDisk: saveAreasToDisk, + getAreaSizeBytes: getAreaSizeBytes, + ); + } else if (refreshTiles) { + // Refresh tiles only + success = await _refreshTilesOnly(area, onProgress); + } else if (refreshNodes) { + // Refresh nodes only + success = await _refreshNodesOnly(area, onProgress); + } else { + // Neither option selected - shouldn't happen but handle gracefully + success = true; + area.progress = 1.0; + } + + await getAreaSizeBytes(area); + + if (success) { + area.status = OfflineAreaStatus.complete; + area.progress = 1.0; + debugPrint('Area $id: refresh completed successfully.'); + } else { + area.status = OfflineAreaStatus.error; + debugPrint('Area $id: refresh failed after maximum retry attempts.'); + } + await saveAreasToDisk(); + onComplete?.call(area.status); + } catch (e) { + area.status = OfflineAreaStatus.error; + await saveAreasToDisk(); + onComplete?.call(area.status); + debugPrint('Area $id: refresh failed with exception: $e'); + } + } + + /// Refresh only the tiles for an area + Future _refreshTilesOnly(OfflineArea area, void Function(double progress)? onProgress) async { + final allTiles = computeTileList(area.bounds, area.minZoom, area.maxZoom); + area.tilesTotal = allTiles.length; + + return await OfflineAreaDownloader.downloadTilesWithRetry( + area: area, + allTiles: allTiles, + directory: area.directory, + onProgress: onProgress, + saveAreasToDisk: saveAreasToDisk, + getAreaSizeBytes: getAreaSizeBytes, + ); + } + + /// Refresh only the nodes for an area + Future _refreshNodesOnly(OfflineArea area, void Function(double progress)? onProgress) async { + try { + // Use the same logic as in the downloader for consistency + final nodeZoom = (area.minZoom + 1).clamp(8, 16); + final expandedNodeBounds = OfflineAreaDownloader.calculateNodeBounds(area.bounds, nodeZoom); + + final nodes = await MapDataProvider().getAllNodesForDownload( + bounds: expandedNodeBounds, + profiles: AppState.instance.profiles, + ); + + area.nodes = nodes; + await OfflineAreaDownloader.saveNodes(nodes, area.directory); + + // Set progress to complete for nodes-only refresh + onProgress?.call(1.0); + area.progress = 1.0; + + debugPrint('Area ${area.id}: Refreshed ${nodes.length} nodes'); + return true; + } catch (e) { + debugPrint('Area ${area.id}: Failed to refresh nodes: $e'); + return false; + } + } /// Remove any legacy world areas from previous versions Future _cleanupLegacyWorldAreas() async { diff --git a/lib/services/offline_areas/offline_area_downloader.dart b/lib/services/offline_areas/offline_area_downloader.dart index 057bfab..1f2566e 100644 --- a/lib/services/offline_areas/offline_area_downloader.dart +++ b/lib/services/offline_areas/offline_area_downloader.dart @@ -30,7 +30,7 @@ class OfflineAreaDownloader { area.tilesTotal = allTiles.length; // Download tiles with retry logic - final success = await _downloadTilesWithRetry( + final success = await downloadTilesWithRetry( area: area, allTiles: allTiles, directory: directory, @@ -51,7 +51,7 @@ class OfflineAreaDownloader { } /// Download tiles with retry logic - static Future _downloadTilesWithRetry({ + static Future downloadTilesWithRetry({ required OfflineArea area, required Set> allTiles, required String directory, @@ -138,7 +138,7 @@ class OfflineAreaDownloader { // Modest expansion: use tiles at minZoom + 1 instead of minZoom // This gives a reasonable buffer without capturing entire states final nodeZoom = (minZoom + 1).clamp(8, 16); // Reasonable bounds for node fetching - final expandedNodeBounds = _calculateNodeBounds(bounds, nodeZoom); + final expandedNodeBounds = calculateNodeBounds(bounds, nodeZoom); final nodes = await MapDataProvider().getAllNodesForDownload( bounds: expandedNodeBounds, @@ -150,7 +150,7 @@ class OfflineAreaDownloader { } /// Calculate expanded bounds that cover the entire tile area at minimum zoom - static LatLngBounds _calculateNodeBounds(LatLngBounds visibleBounds, int minZoom) { + static LatLngBounds calculateNodeBounds(LatLngBounds visibleBounds, int minZoom) { final tiles = computeTileList(visibleBounds, minZoom, minZoom); if (tiles.isEmpty) return visibleBounds; diff --git a/lib/services/tile_preview_service.dart b/lib/services/tile_preview_service.dart new file mode 100644 index 0000000..5260c3f --- /dev/null +++ b/lib/services/tile_preview_service.dart @@ -0,0 +1,80 @@ +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import '../models/tile_provider.dart'; +import '../state/settings_state.dart'; + +/// Service for fetching missing tile preview images +class TilePreviewService { + static const int _previewZoom = 10; + static const int _previewX = 512; + static const int _previewY = 384; + static const Duration _timeout = Duration(seconds: 10); + + /// Attempt to fetch missing preview tiles for tile types that don't already have preview data + /// Fails silently - no error handling or user notification on failure + static Future fetchMissingPreviews(SettingsState settingsState) async { + try { + bool anyUpdates = false; + + for (final provider in settingsState.tileProviders) { + final updatedTileTypes = []; + bool providerNeedsUpdate = false; + + for (final tileType in provider.tileTypes) { + // Only fetch if preview tile is missing + if (tileType.previewTile == null) { + // Skip if tile type requires API key but provider doesn't have one + if (tileType.requiresApiKey && (provider.apiKey == null || provider.apiKey!.isEmpty)) { + updatedTileTypes.add(tileType); + continue; + } + + final previewData = await _fetchPreviewForTileType(tileType, provider.apiKey); + if (previewData != null) { + // Create updated tile type with preview data + final updatedTileType = tileType.copyWith(previewTile: previewData); + updatedTileTypes.add(updatedTileType); + providerNeedsUpdate = true; + } else { + updatedTileTypes.add(tileType); + } + } else { + updatedTileTypes.add(tileType); + } + } + + if (providerNeedsUpdate) { + final updatedProvider = provider.copyWith(tileTypes: updatedTileTypes); + await settingsState.addOrUpdateTileProvider(updatedProvider); + anyUpdates = true; + } + } + + if (anyUpdates) { + debugPrint('TilePreviewService: Updated providers with new preview tiles'); + } + } catch (e) { + // Fail silently as requested + debugPrint('TilePreviewService: Error during preview fetching: $e'); + } + } + + static Future _fetchPreviewForTileType(TileType tileType, String? apiKey) async { + try { + final url = tileType.getTileUrl(_previewZoom, _previewX, _previewY, apiKey: apiKey); + + final response = await http.get(Uri.parse(url)).timeout(_timeout); + + if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) { + debugPrint('TilePreviewService: Fetched preview for ${tileType.name}'); + return response.bodyBytes; + } + } catch (e) { + // Fail silently - just log for debugging + debugPrint('TilePreviewService: Failed to fetch preview for ${tileType.name}: $e'); + } + return null; + } +} \ No newline at end of file