mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
break up offlin areas
This commit is contained in:
@@ -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<void> _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<List<int>> expectedTiles = computeTileList(worldBounds, kWorldMinZoom, kWorldMaxZoom);
|
||||
if (world != null) {
|
||||
int filesFound = 0;
|
||||
List<List<int>> 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<void> downloadArea({
|
||||
required String id,
|
||||
@@ -257,76 +186,26 @@ class OfflineAreaService {
|
||||
await saveAreasToDisk();
|
||||
|
||||
try {
|
||||
Set<List<int>> 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<List<int>> allTilesSet = allTiles.toSet();
|
||||
Set<List<int>> 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<List<int>> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
191
lib/services/offline_areas/offline_area_downloader.dart
Normal file
191
lib/services/offline_areas/offline_area_downloader.dart
Normal file
@@ -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<bool> downloadArea({
|
||||
required OfflineArea area,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
void Function(double progress)? onProgress,
|
||||
required Future<void> Function() saveAreasToDisk,
|
||||
required Future<void> Function(OfflineArea) getAreaSizeBytes,
|
||||
}) async {
|
||||
// Calculate tiles to download
|
||||
Set<List<int>> 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<bool> _downloadTilesWithRetry({
|
||||
required OfflineArea area,
|
||||
required Set<List<int>> allTiles,
|
||||
required String directory,
|
||||
void Function(double progress)? onProgress,
|
||||
required Future<void> Function() saveAreasToDisk,
|
||||
required Future<void> Function(OfflineArea) getAreaSizeBytes,
|
||||
}) async {
|
||||
int pass = 0;
|
||||
Set<List<int>> 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<bool> _downloadSingleTile(List<int> 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<List<int>> _findMissingTiles(Set<List<int>> allTiles, String directory) {
|
||||
final missingTiles = <List<int>>{};
|
||||
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<void> _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<void> saveTileBytes(int z, int x, int y, String baseDir, List<int> 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<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
|
||||
final file = File('$dir/cameras.json');
|
||||
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
|
||||
}
|
||||
}
|
||||
@@ -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<void> saveTileBytes(int z, int x, int y, String baseDir, List<int> 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<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
|
||||
final file = File('$dir/cameras.json');
|
||||
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
|
||||
}
|
||||
@@ -56,6 +56,32 @@ List<int> 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));
|
||||
|
||||
111
lib/services/offline_areas/world_area_manager.dart
Normal file
111
lib/services/offline_areas/world_area_manager.dart
Normal file
@@ -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<OfflineArea> ensureWorldArea(
|
||||
List<OfflineArea> areas,
|
||||
Future<Directory> Function() getOfflineAreaDir,
|
||||
Future<void> 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<void> _checkAndStartWorldDownload(
|
||||
OfflineArea world,
|
||||
Future<void> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user