Files
deflock-app/lib/services/offline_area_service.dart

424 lines
13 KiB
Dart

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<void>? _initializationFuture;
OfflineAreaService._();
final List<OfflineArea> _areas = [];
List<OfflineArea> 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<void> 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<void> ensureInitialized() async {
if (_initialized) return;
_initializationFuture ??= _initialize();
await _initializationFuture;
}
Future<void> _initialize() async {
if (_initialized) return;
await _loadAreasFromDisk();
await _cleanupLegacyWorldAreas();
_initialized = true;
}
Future<Directory> getOfflineAreaDir() async {
final dir = await getApplicationDocumentsDirectory();
final areaRoot = Directory("${dir.path}/offline_areas");
if (!areaRoot.existsSync()) {
areaRoot.createSync(recursive: true);
}
return areaRoot;
}
Future<File> _getMetadataPath() async {
final dir = await getOfflineAreaDir();
return File("${dir.path}/offline_areas.json");
}
Future<int> 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<void> 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<void> _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<void> 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<void> 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<bool> _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<bool> _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<void> _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');
}
}
}