diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 4ef19a0..1a54191 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -1,17 +1,16 @@ import 'dart:io'; import 'dart:convert'; -import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; import 'package:path_provider/path_provider.dart'; import 'offline_areas/offline_area_models.dart'; import 'offline_areas/offline_tile_utils.dart'; -import 'offline_areas/offline_area_service_tile_fetch.dart'; // Only used for file IO during area downloads. +import 'offline_areas/offline_area_downloader.dart'; +import 'offline_areas/world_area_manager.dart'; import '../models/osm_camera_node.dart'; import '../app_state.dart'; import 'map_data_provider.dart'; -import 'map_data_submodules/cameras_from_overpass.dart'; import 'package:flock_map_app/dev_config.dart'; /// Service for managing download, storage, and retrieval of offline map areas and cameras. @@ -39,7 +38,7 @@ class OfflineAreaService { if (_initialized) return; await _loadAreasFromDisk(); - await _ensureAndAutoDownloadWorldArea(); + await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea); _initialized = true; } @@ -151,77 +150,7 @@ class OfflineAreaService { } } - Future _ensureAndAutoDownloadWorldArea() async { - final dir = await getOfflineAreaDir(); - final worldDir = "${dir.path}/world"; - final LatLngBounds worldBounds = globalWorldBounds(); - OfflineArea? world; - for (final a in _areas) { - if (a.isPermanent) { world = a; break; } - } - final Set> expectedTiles = computeTileList(worldBounds, kWorldMinZoom, kWorldMaxZoom); - if (world != null) { - int filesFound = 0; - List> missingTiles = []; - for (final tile in expectedTiles) { - final f = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png'); - if (f.existsSync()) { - filesFound++; - } else if (missingTiles.length < 10) { - missingTiles.add(tile); - } - } - if (filesFound != expectedTiles.length) { - debugPrint('World area: missing ${expectedTiles.length - filesFound} tiles. First few: $missingTiles'); - } else { - debugPrint('World area: all tiles accounted for.'); - } - world.tilesTotal = expectedTiles.length; - world.tilesDownloaded = filesFound; - world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal); - if (filesFound == world.tilesTotal) { - world.status = OfflineAreaStatus.complete; - await saveAreasToDisk(); - return; - } else { - world.status = OfflineAreaStatus.downloading; - await saveAreasToDisk(); - downloadArea( - id: world.id, - bounds: world.bounds, - minZoom: world.minZoom, - maxZoom: world.maxZoom, - directory: world.directory, - name: world.name, - ); - return; - } - } - // If not present, create and start download - world = OfflineArea( - id: 'permanent_world', - name: 'World (required)', - bounds: worldBounds, - minZoom: kWorldMinZoom, - maxZoom: kWorldMaxZoom, - directory: worldDir, - status: OfflineAreaStatus.downloading, - progress: 0.0, - isPermanent: true, - tilesTotal: expectedTiles.length, - tilesDownloaded: 0, - ); - _areas.insert(0, world); - await saveAreasToDisk(); - downloadArea( - id: world.id, - bounds: world.bounds, - minZoom: world.minZoom, - maxZoom: world.maxZoom, - directory: world.directory, - name: world.name, - ); - } + Future downloadArea({ required String id, @@ -257,76 +186,26 @@ class OfflineAreaService { await saveAreasToDisk(); try { - Set> allTiles; - if (area.isPermanent) { - allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom); - } else { - allTiles = computeTileList(bounds, minZoom, maxZoom); - } - area.tilesTotal = allTiles.length; - const int maxPasses = 3; - int pass = 0; - Set> allTilesSet = allTiles.toSet(); - Set> tilesToFetch = allTilesSet; - bool success = false; - int totalDone = 0; - while (pass < maxPasses && tilesToFetch.isNotEmpty) { - pass++; - int doneThisPass = 0; - debugPrint('DownloadArea: pass #$pass for area $id. Need ${tilesToFetch.length} tiles.'); - for (final tile in tilesToFetch) { - if (area.status == OfflineAreaStatus.cancelled) break; - try { - final bytes = await MapDataProvider().getTile( - z: tile[0], x: tile[1], y: tile[2], source: MapSource.remote); - if (bytes.isNotEmpty) { - await saveTileBytes(tile[0], tile[1], tile[2], directory, bytes); - } - totalDone++; - doneThisPass++; - area.tilesDownloaded = totalDone; - area.progress = area.tilesTotal == 0 ? 0.0 : ((area.tilesDownloaded) / area.tilesTotal); - } catch (e) { - debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e"); - } - if (onProgress != null) onProgress(area.progress); - } - await getAreaSizeBytes(area); - await saveAreasToDisk(); - Set> missingTiles = {}; - for (final tile in allTilesSet) { - final f = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png'); - if (!f.existsSync()) missingTiles.add(tile); - } - if (missingTiles.isEmpty) { - success = true; - break; - } - tilesToFetch = missingTiles; - } + final success = await OfflineAreaDownloader.downloadArea( + area: area, + bounds: bounds, + minZoom: minZoom, + maxZoom: maxZoom, + directory: directory, + onProgress: onProgress, + saveAreasToDisk: saveAreasToDisk, + getAreaSizeBytes: getAreaSizeBytes, + ); - if (!area.isPermanent) { - // Calculate expanded camera bounds that cover the entire tile area at minimum zoom - final cameraBounds = _calculateCameraBounds(bounds, minZoom); - final cameras = await MapDataProvider().getAllCamerasForDownload( - bounds: cameraBounds, - profiles: AppState.instance.enabledProfiles, - ); - area.cameras = cameras; - await saveCameras(cameras, directory); - debugPrint('Area $id: Downloaded ${cameras.length} cameras from expanded bounds (${cameraBounds.north.toStringAsFixed(6)}, ${cameraBounds.west.toStringAsFixed(6)}) to (${cameraBounds.south.toStringAsFixed(6)}, ${cameraBounds.east.toStringAsFixed(6)})'); - } else { - area.cameras = []; - } await getAreaSizeBytes(area); if (success) { area.status = OfflineAreaStatus.complete; area.progress = 1.0; - debugPrint('Area $id: all tiles accounted for and area marked complete.'); + debugPrint('Area $id: download completed successfully.'); } else { area.status = OfflineAreaStatus.error; - debugPrint('Area $id: MISSING tiles after $maxPasses passes. First 10: ${tilesToFetch.toList().take(10)}'); + debugPrint('Area $id: download failed after maximum retry attempts.'); if (!area.isPermanent) { final dirObj = Directory(area.directory); if (await dirObj.exists()) { @@ -336,11 +215,11 @@ class OfflineAreaService { } } await saveAreasToDisk(); - if (onComplete != null) onComplete(area.status); + onComplete?.call(area.status); } catch (e) { area.status = OfflineAreaStatus.error; await saveAreasToDisk(); - if (onComplete != null) onComplete(area.status); + onComplete?.call(area.status); } } @@ -354,7 +233,7 @@ class OfflineAreaService { _areas.remove(area); await saveAreasToDisk(); if (area.isPermanent) { - _ensureAndAutoDownloadWorldArea(); + await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea); } } @@ -368,56 +247,5 @@ class OfflineAreaService { await saveAreasToDisk(); } - /// Calculate expanded bounds that cover the entire tile area at minimum zoom - /// This ensures we fetch all cameras that could be relevant for the offline area - LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) { - // Get all tiles that cover the visible bounds at minimum zoom - final tiles = computeTileList(visibleBounds, minZoom, minZoom); - if (tiles.isEmpty) return visibleBounds; - - // Find the bounding box of all these tiles - double minLat = 90.0, maxLat = -90.0; - double minLon = 180.0, maxLon = -180.0; - - for (final tile in tiles) { - final z = tile[0]; - final x = tile[1]; - final y = tile[2]; - - // Convert tile coordinates back to lat/lng bounds - final tileBounds = _tileToLatLngBounds(x, y, z); - - minLat = math.min(minLat, tileBounds.south); - maxLat = math.max(maxLat, tileBounds.north); - minLon = math.min(minLon, tileBounds.west); - maxLon = math.max(maxLon, tileBounds.east); - } - - return LatLngBounds( - LatLng(minLat, minLon), - LatLng(maxLat, maxLon), - ); - } - - /// Convert tile coordinates to LatLng bounds - LatLngBounds _tileToLatLngBounds(int x, int y, int z) { - final n = math.pow(2, z); - final lonDeg = x / n * 360.0 - 180.0; - final latRad = math.atan(_sinh(math.pi * (1 - 2 * y / n))); - final latDeg = latRad * 180.0 / math.pi; - - final lonDegNext = (x + 1) / n * 360.0 - 180.0; - final latRadNext = math.atan(_sinh(math.pi * (1 - 2 * (y + 1) / n))); - final latDegNext = latRadNext * 180.0 / math.pi; - - return LatLngBounds( - LatLng(latDegNext, lonDeg), // SW corner - LatLng(latDeg, lonDegNext), // NE corner - ); - } - - /// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2 - double _sinh(double x) { - return (math.exp(x) - math.exp(-x)) / 2; - } + } diff --git a/lib/services/offline_areas/offline_area_downloader.dart b/lib/services/offline_areas/offline_area_downloader.dart new file mode 100644 index 0000000..491a01d --- /dev/null +++ b/lib/services/offline_areas/offline_area_downloader.dart @@ -0,0 +1,191 @@ +import 'dart:io'; +import 'dart:convert'; +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; + +import '../../app_state.dart'; +import '../../models/osm_camera_node.dart'; +import '../map_data_provider.dart'; +import 'offline_area_models.dart'; +import 'offline_tile_utils.dart'; +import 'package:flock_map_app/dev_config.dart'; + +/// Handles the actual downloading process for offline areas +class OfflineAreaDownloader { + static const int _maxRetryPasses = 3; + + /// Download tiles and cameras for an offline area + static Future downloadArea({ + required OfflineArea area, + required LatLngBounds bounds, + required int minZoom, + required int maxZoom, + required String directory, + void Function(double progress)? onProgress, + required Future Function() saveAreasToDisk, + required Future Function(OfflineArea) getAreaSizeBytes, + }) async { + // Calculate tiles to download + Set> allTiles; + if (area.isPermanent) { + allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom); + } else { + allTiles = computeTileList(bounds, minZoom, maxZoom); + } + area.tilesTotal = allTiles.length; + + // Download tiles with retry logic + final success = await _downloadTilesWithRetry( + area: area, + allTiles: allTiles, + directory: directory, + onProgress: onProgress, + saveAreasToDisk: saveAreasToDisk, + getAreaSizeBytes: getAreaSizeBytes, + ); + + // Download cameras for non-permanent areas + if (!area.isPermanent) { + await _downloadCameras( + area: area, + bounds: bounds, + minZoom: minZoom, + directory: directory, + ); + } else { + area.cameras = []; + } + + return success; + } + + /// Download tiles with retry logic + static Future _downloadTilesWithRetry({ + required OfflineArea area, + required Set> allTiles, + required String directory, + void Function(double progress)? onProgress, + required Future Function() saveAreasToDisk, + required Future Function(OfflineArea) getAreaSizeBytes, + }) async { + int pass = 0; + Set> tilesToFetch = allTiles; + int totalDone = 0; + + while (pass < _maxRetryPasses && tilesToFetch.isNotEmpty) { + pass++; + debugPrint('DownloadArea: pass #$pass for area ${area.id}. Need ${tilesToFetch.length} tiles.'); + + for (final tile in tilesToFetch) { + if (area.status == OfflineAreaStatus.cancelled) break; + + if (await _downloadSingleTile(tile, directory)) { + totalDone++; + area.tilesDownloaded = totalDone; + area.progress = area.tilesTotal == 0 ? 0.0 : (totalDone / area.tilesTotal); + onProgress?.call(area.progress); + } + } + + await getAreaSizeBytes(area); + await saveAreasToDisk(); + + // Check for missing tiles + tilesToFetch = _findMissingTiles(allTiles, directory); + if (tilesToFetch.isEmpty) { + return true; // Success! + } + } + + return false; // Failed after max retries + } + + /// Download a single tile + static Future _downloadSingleTile(List tile, String directory) async { + try { + final bytes = await MapDataProvider().getTile( + z: tile[0], + x: tile[1], + y: tile[2], + source: MapSource.remote, + ); + if (bytes.isNotEmpty) { + await OfflineAreaDownloader.saveTileBytes(tile[0], tile[1], tile[2], directory, bytes); + return true; + } + } catch (e) { + debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e"); + } + return false; + } + + /// Find tiles that are missing from disk + static Set> _findMissingTiles(Set> allTiles, String directory) { + final missingTiles = >{}; + for (final tile in allTiles) { + final file = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png'); + if (!file.existsSync()) { + missingTiles.add(tile); + } + } + return missingTiles; + } + + /// Download cameras for the area with expanded bounds + static Future _downloadCameras({ + required OfflineArea area, + required LatLngBounds bounds, + required int minZoom, + required String directory, + }) async { + // Calculate expanded camera bounds that cover the entire tile area at minimum zoom + final cameraBounds = _calculateCameraBounds(bounds, minZoom); + final cameras = await MapDataProvider().getAllCamerasForDownload( + bounds: cameraBounds, + profiles: AppState.instance.enabledProfiles, + ); + area.cameras = cameras; + await OfflineAreaDownloader.saveCameras(cameras, directory); + debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds'); + } + + /// Calculate expanded bounds that cover the entire tile area at minimum zoom + static LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) { + final tiles = computeTileList(visibleBounds, minZoom, minZoom); + if (tiles.isEmpty) return visibleBounds; + + // Find the bounding box of all these tiles + double minLat = 90.0, maxLat = -90.0; + double minLon = 180.0, maxLon = -180.0; + + for (final tile in tiles) { + final tileBounds = tileToLatLngBounds(tile[1], tile[2], tile[0]); + + minLat = math.min(minLat, tileBounds.south); + maxLat = math.max(maxLat, tileBounds.north); + minLon = math.min(minLon, tileBounds.west); + maxLon = math.max(maxLon, tileBounds.east); + } + + return LatLngBounds( + LatLng(minLat, minLon), + LatLng(maxLat, maxLon), + ); + } + + /// Save tile bytes to disk + static Future saveTileBytes(int z, int x, int y, String baseDir, List bytes) async { + final dir = Directory('$baseDir/tiles/$z/$x'); + await dir.create(recursive: true); + final file = File('${dir.path}/$y.png'); + await file.writeAsBytes(bytes); + } + + /// Save cameras to disk as JSON + static Future saveCameras(List cams, String dir) async { + final file = File('$dir/cameras.json'); + await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList())); + } +} \ No newline at end of file diff --git a/lib/services/offline_areas/offline_area_service_tile_fetch.dart b/lib/services/offline_areas/offline_area_service_tile_fetch.dart deleted file mode 100644 index 6da9410..0000000 --- a/lib/services/offline_areas/offline_area_service_tile_fetch.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:io'; -import 'dart:convert'; -import '../../models/osm_camera_node.dart'; - -/// Disk IO utilities for offline area file management ONLY. No network requests should occur here. - -/// Save-to-disk for a tile that has already been fetched elsewhere. -Future saveTileBytes(int z, int x, int y, String baseDir, List bytes) async { - final dir = Directory('$baseDir/tiles/$z/$x'); - await dir.create(recursive: true); - final file = File('${dir.path}/$y.png'); - await file.writeAsBytes(bytes); -} - -/// Save-to-disk for cameras.json; called only by OfflineAreaService during area download -Future saveCameras(List cams, String dir) async { - final file = File('$dir/cameras.json'); - await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList())); -} diff --git a/lib/services/offline_areas/offline_tile_utils.dart b/lib/services/offline_areas/offline_tile_utils.dart index 9c987fc..b3da977 100644 --- a/lib/services/offline_areas/offline_tile_utils.dart +++ b/lib/services/offline_areas/offline_tile_utils.dart @@ -56,6 +56,32 @@ List latLonToTile(double lat, double lon, int zoom) { return [xtile, ytile]; } +/// Convert tile coordinates back to LatLng bounds +LatLngBounds tileToLatLngBounds(int x, int y, int z) { + final n = pow(2, z); + + // Calculate bounds for this tile + final lonWest = x / n * 360.0 - 180.0; + final lonEast = (x + 1) / n * 360.0 - 180.0; + + // For latitude, we need to invert the mercator projection + final latNorthRad = atan(sinh(pi * (1 - 2 * y / n))); + final latSouthRad = atan(sinh(pi * (1 - 2 * (y + 1) / n))); + + final latNorth = latNorthRad * 180.0 / pi; + final latSouth = latSouthRad * 180.0 / pi; + + return LatLngBounds( + LatLng(latSouth, lonWest), // SW corner + LatLng(latNorth, lonEast), // NE corner + ); +} + +/// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2 +double sinh(double x) { + return (exp(x) - exp(-x)) / 2; +} + LatLngBounds globalWorldBounds() { // Use slightly shrunken bounds to avoid tile index overflow at extreme coordinates return LatLngBounds(LatLng(-85.0, -179.9), LatLng(85.0, 179.9)); diff --git a/lib/services/offline_areas/world_area_manager.dart b/lib/services/offline_areas/world_area_manager.dart new file mode 100644 index 0000000..06ea8a3 --- /dev/null +++ b/lib/services/offline_areas/world_area_manager.dart @@ -0,0 +1,111 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import 'package:path_provider/path_provider.dart'; + +import 'offline_area_models.dart'; +import 'offline_tile_utils.dart'; +import 'package:flock_map_app/dev_config.dart'; + +/// Manages the world area (permanent offline area for base map) +class WorldAreaManager { + static const String _worldAreaId = 'world'; + static const String _worldAreaName = 'World Base Map'; + + /// Ensure world area exists and check if download is needed + static Future ensureWorldArea( + List areas, + Future Function() getOfflineAreaDir, + Future Function({ + required String id, + required LatLngBounds bounds, + required int minZoom, + required int maxZoom, + required String directory, + String? name, + }) downloadArea, + ) async { + // Find existing world area + OfflineArea? world; + for (final area in areas) { + if (area.isPermanent) { + world = area; + break; + } + } + + // Create world area if it doesn't exist + if (world == null) { + final appDocDir = await getOfflineAreaDir(); + final dir = "${appDocDir.path}/$_worldAreaId"; + world = OfflineArea( + id: _worldAreaId, + name: _worldAreaName, + bounds: globalWorldBounds(), + minZoom: kWorldMinZoom, + maxZoom: kWorldMaxZoom, + directory: dir, + status: OfflineAreaStatus.downloading, + isPermanent: true, + ); + areas.insert(0, world); + } + + // Check world area status and start download if needed + await _checkAndStartWorldDownload(world, downloadArea); + return world; + } + + /// Check world area download status and start if needed + static Future _checkAndStartWorldDownload( + OfflineArea world, + Future Function({ + required String id, + required LatLngBounds bounds, + required int minZoom, + required int maxZoom, + required String directory, + String? name, + }) downloadArea, + ) async { + if (world.status == OfflineAreaStatus.complete) return; + + // Count existing tiles + final expectedTiles = computeTileList( + globalWorldBounds(), + kWorldMinZoom, + kWorldMaxZoom, + ); + + int filesFound = 0; + for (final tile in expectedTiles) { + final file = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png'); + if (file.existsSync()) { + filesFound++; + } + } + + // Update world area stats + world.tilesTotal = expectedTiles.length; + world.tilesDownloaded = filesFound; + world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal); + + if (filesFound == world.tilesTotal) { + world.status = OfflineAreaStatus.complete; + debugPrint('WorldAreaManager: World area download already complete.'); + } else { + world.status = OfflineAreaStatus.downloading; + debugPrint('WorldAreaManager: Starting world area download. ${world.tilesDownloaded}/${world.tilesTotal} tiles found.'); + + // Start download (fire and forget) + downloadArea( + id: world.id, + bounds: world.bounds, + minZoom: world.minZoom, + maxZoom: world.maxZoom, + directory: world.directory, + name: world.name, + ); + } + } +} \ No newline at end of file