import 'dart:io'; import 'dart:convert'; import 'package:flutter/foundation.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_downloader.dart'; import '../app_state.dart'; import 'map_data_provider.dart'; /// Service for managing download, storage, and retrieval of offline map areas and cameras. class OfflineAreaService { static final OfflineAreaService _instance = OfflineAreaService._(); factory OfflineAreaService() => _instance; bool _initialized = false; Future? _initializationFuture; OfflineAreaService._(); final List _areas = []; List get offlineAreas => List.unmodifiable(_areas); /// Check if any areas are currently downloading bool get hasActiveDownloads => _areas.any((area) => area.status == OfflineAreaStatus.downloading); /// Fast check: do we have any completed offline areas for a specific provider/type? /// This allows smart cache routing without expensive filesystem searches. /// Safe to call before initialization - returns false if not yet initialized. bool hasOfflineAreasForProvider(String providerId, String tileTypeId) { if (!_initialized) { return false; // No offline areas loaded yet } return _areas.any((area) => area.status == OfflineAreaStatus.complete && area.tileProviderId == providerId && area.tileTypeId == tileTypeId ); } /// Cancel all active downloads (used when enabling offline mode) Future cancelActiveDownloads() async { final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList(); for (final area in activeAreas) { area.status = OfflineAreaStatus.cancelled; if (!area.isPermanent) { // Clean up non-permanent areas final dir = Directory(area.directory); if (await dir.exists()) { await dir.delete(recursive: true); } _areas.remove(area); } } await saveAreasToDisk(); debugPrint('OfflineAreaService: Cancelled ${activeAreas.length} active downloads due to offline mode'); } /// Ensure the service is initialized (areas loaded from disk) Future ensureInitialized() async { if (_initialized) return; _initializationFuture ??= _initialize(); await _initializationFuture; } Future _initialize() async { if (_initialized) return; await _loadAreasFromDisk(); await _cleanupLegacyWorldAreas(); _initialized = true; } Future getOfflineAreaDir() async { final dir = await getApplicationDocumentsDirectory(); final areaRoot = Directory("${dir.path}/offline_areas"); if (!areaRoot.existsSync()) { areaRoot.createSync(recursive: true); } return areaRoot; } Future _getMetadataPath() async { final dir = await getOfflineAreaDir(); return File("${dir.path}/offline_areas.json"); } Future getAreaSizeBytes(OfflineArea area) async { int total = 0; final dir = Directory(area.directory); if (await dir.exists()) { await for (var fse in dir.list(recursive: true)) { if (fse is File) { total += await fse.length(); } } } area.sizeBytes = total; await saveAreasToDisk(); return total; } Future saveAreasToDisk() async { try { final file = await _getMetadataPath(); final offlineDir = await getOfflineAreaDir(); // Convert areas to JSON with relative paths for portability final areaJsonList = _areas.map((area) { final json = area.toJson(); // Convert absolute path to relative path for storage if (json['directory'].toString().startsWith(offlineDir.path)) { final relativePath = json['directory'].toString().replaceFirst('${offlineDir.path}/', ''); json['directory'] = relativePath; } return json; }).toList(); final content = jsonEncode(areaJsonList); await file.writeAsString(content); } catch (e) { debugPrint('Failed to save offline areas: $e'); } } Future _loadAreasFromDisk() async { try { final file = await _getMetadataPath(); if (!(await file.exists())) return; final str = await file.readAsString(); if (str.trim().isEmpty) return; late final List data; try { data = jsonDecode(str); } catch (e) { debugPrint('Failed to parse offline areas json: $e'); return; } _areas.clear(); for (final areaJson in data) { // Migrate stored directory paths to be relative for portability String storedDir = areaJson['directory']; String relativePath = storedDir; // If it's an absolute path, extract just the folder name if (storedDir.startsWith('/')) { if (storedDir.contains('/offline_areas/')) { final parts = storedDir.split('/offline_areas/'); if (parts.length == 2) { relativePath = parts[1]; // Just the folder name (e.g., "world" or "2025-08-19...") } } } // Always construct absolute path at runtime final offlineDir = await getOfflineAreaDir(); final fullPath = '${offlineDir.path}/$relativePath'; // Update the JSON to use the full path for this session areaJson['directory'] = fullPath; final area = OfflineArea.fromJson(areaJson); if (!Directory(area.directory).existsSync()) { area.status = OfflineAreaStatus.error; } else { // Reset error status if directory now exists (fixes areas that were previously broken due to path issues) if (area.status == OfflineAreaStatus.error) { area.status = OfflineAreaStatus.complete; } getAreaSizeBytes(area); } _areas.add(area); } } catch (e) { debugPrint('Failed to load offline areas: $e'); } } Future downloadArea({ required String id, required LatLngBounds bounds, required int minZoom, required int maxZoom, required String directory, void Function(double progress)? onProgress, void Function(OfflineAreaStatus status)? onComplete, String? name, String? tileProviderId, String? tileProviderName, String? tileTypeId, String? tileTypeName, }) async { OfflineArea? area; for (final a in _areas) { if (a.id == id) { area = a; break; } } if (area != null) { _areas.remove(area); final dirObj = Directory(area.directory); if (await dirObj.exists()) { await dirObj.delete(recursive: true); } } area = OfflineArea( id: id, name: name ?? area?.name ?? '', bounds: bounds, minZoom: minZoom, maxZoom: maxZoom, directory: directory, isPermanent: area?.isPermanent ?? false, tileProviderId: tileProviderId, tileProviderName: tileProviderName, tileTypeId: tileTypeId, tileTypeName: tileTypeName, ); _areas.add(area); await saveAreasToDisk(); try { final success = await OfflineAreaDownloader.downloadArea( area: area, bounds: bounds, minZoom: minZoom, maxZoom: maxZoom, directory: directory, onProgress: onProgress, saveAreasToDisk: saveAreasToDisk, getAreaSizeBytes: getAreaSizeBytes, ); await getAreaSizeBytes(area); if (success) { area.status = OfflineAreaStatus.complete; area.progress = 1.0; debugPrint('Area $id: download completed successfully.'); } else { area.status = OfflineAreaStatus.error; debugPrint('Area $id: download failed after maximum retry attempts.'); if (!area.isPermanent) { final dirObj = Directory(area.directory); if (await dirObj.exists()) { await dirObj.delete(recursive: true); } _areas.remove(area); } } await saveAreasToDisk(); onComplete?.call(area.status); } catch (e) { area.status = OfflineAreaStatus.error; await saveAreasToDisk(); onComplete?.call(area.status); } } void cancelDownload(String id) async { final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found'); area.status = OfflineAreaStatus.cancelled; final dir = Directory(area.directory); if (await dir.exists()) { await dir.delete(recursive: true); } _areas.remove(area); await saveAreasToDisk(); } void deleteArea(String id) async { final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found'); final dir = Directory(area.directory); if (await dir.exists()) { await dir.delete(recursive: true); } _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; // Only reset tile count if we're actually refreshing tiles if (refreshTiles) { 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 { final worldAreas = _areas.where((area) => area.isPermanent || area.id == 'world').toList(); if (worldAreas.isNotEmpty) { debugPrint('OfflineAreaService: Cleaning up ${worldAreas.length} legacy world area(s)'); for (final area in worldAreas) { final dir = Directory(area.directory); if (await dir.exists()) { await dir.delete(recursive: true); debugPrint('OfflineAreaService: Deleted world area directory: ${area.directory}'); } _areas.remove(area); } await saveAreasToDisk(); debugPrint('OfflineAreaService: Legacy world area cleanup complete'); } } }