import 'package:flutter/foundation.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:vector_map_tiles_mbtiles/vector_map_tiles_mbtiles.dart'; import 'package:mbtiles/mbtiles.dart'; import '../models/map_layer.dart'; class TileCacheService { static const String _storeName = 'meshcore_sar_tiles'; // Global flag to ensure ObjectBox is only initialized once static bool _objectBoxInitialized = false; static Future? _objectBoxInitialization; Future? _initializeFuture; FMTCStore? _store; bool _isInitialized = false; bool _isDownloading = false; Future initialize() async { if (_isInitialized) return; _initializeFuture ??= _initializeInternal(); await _initializeFuture; } Future _initializeInternal() async { try { await _ensureObjectBoxInitialized(); final store = FMTCStore(_storeName); try { await store.manage.create(); } catch (_) { // The store may already exist from a prior initialization. } _store = store; _isInitialized = true; } catch (_) { _initializeFuture = null; rethrow; } } Future _ensureObjectBoxInitialized() async { if (_objectBoxInitialized) return; _objectBoxInitialization ??= () async { try { await FMTCObjectBoxBackend().initialise(); } catch (_) { // Treat repeated backend initialization as a no-op. } finally { _objectBoxInitialized = true; } }(); await _objectBoxInitialization; } FMTCStore _requireStore() { final store = _store; if (!_isInitialized || store == null) { throw StateError( 'TileCacheService not initialized. Call initialize() first.', ); } return store; } TileProvider getTileProvider(MapLayer layer) { _requireStore(); return FMTCTileProvider( stores: {_storeName: BrowseStoreStrategy.readUpdateCreate}, loadingStrategy: BrowseLoadingStrategy.cacheFirst, cachedValidDuration: const Duration(days: 30), ); } /// Get tile provider for WMS layers with caching support /// WMS layers require special handling because they use WMSTileLayerOptions TileProvider getTileProviderForWms(MapLayer layer) { _requireStore(); if (!layer.isWms) { throw ArgumentError('Layer must be a WMS layer'); } // Return the same cached tile provider // The WMS URL construction is handled by flutter_map's WMSTileLayerOptions return FMTCTileProvider( stores: {_storeName: BrowseStoreStrategy.readUpdateCreate}, loadingStrategy: BrowseLoadingStrategy.cacheFirst, cachedValidDuration: const Duration(days: 30), ); } Future downloadRegion({ required MapLayer layer, required LatLngBounds bounds, required int minZoom, required int maxZoom, Function(double progress)? onProgress, }) async { if (!_isInitialized) { throw StateError( 'TileCacheService not initialized. Call initialize() first.', ); } if (_isDownloading) { throw StateError('A download is already in progress. Cancel it first.'); } _isDownloading = true; final store = _requireStore(); try { final region = RectangleRegion(bounds); final downloadable = region.toDownloadable( minZoom: minZoom, maxZoom: maxZoom, options: TileLayer(urlTemplate: layer.urlTemplate), ); final download = store.download.startForeground(region: downloadable); await for (final progress in download.downloadProgress) { if (onProgress != null && progress.maxTilesCount > 0) { // Use attemptedTilesCount instead of successfulTilesCount // attemptedTilesCount includes successful + buffered + skipped tiles final percentage = progress.percentageProgress; debugPrint( 'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})', ); onProgress(percentage); } } } finally { _isDownloading = false; } } Future cancelDownload() async { if (!_isInitialized) return; await _requireStore().download.cancel(); } Future clearCache() async { if (!_isInitialized) return; final store = _requireStore(); await store.manage.delete(); await store.manage.create(); } Future getCachedTileCount() async { if (!_isInitialized) return 0; final stats = await _requireStore().stats.length; return stats; } Future getCacheSizeMB() async { if (!_isInitialized) return 0.0; final stats = await _requireStore().stats.size; return stats / (1024 * 1024); } Future> getAvailableStores() async { if (!_isInitialized) { throw StateError( 'TileCacheService not initialized. Call initialize() first.', ); } final stores = await FMTCRoot.stats.storesAvailable; return stores.map((store) => store.storeName).toList(); } Future> getStoreStats() async { if (!_isInitialized) return {}; final store = _requireStore(); final length = await store.stats.length; final size = await store.stats.all.then((a) => a.size); return { 'tileCount': length, 'sizeMB': size / 1024, 'storeName': _storeName, }; } /// Get vector tile provider for MBTiles layers MbTilesVectorTileProvider? getVectorTileProvider(MapLayer layer) { if (!layer.isVector || layer.mbtilesFile == null) { return null; } try { final mbtiles = MbTiles( mbtilesPath: layer.mbtilesFile!.path, gzip: layer.isGzipped ?? false, ); return MbTilesVectorTileProvider( mbtiles: mbtiles, ); } catch (e) { debugPrint('Error creating vector tile provider: $e'); return null; } } /// Export the current tile cache store to an archive file /// /// [outputPath] - Full path where the archive should be saved (e.g., '/path/to/export.fmtc') /// /// Returns the number of tiles exported Future exportStore(String outputPath) async { if (!_isInitialized) { throw StateError( 'TileCacheService not initialized. Call initialize() first.', ); } try { final external = FMTCRoot.external(pathToArchive: outputPath); final result = await external.export(storeNames: [_storeName]); debugPrint('Export completed: $result tiles exported to $outputPath'); return result; } catch (e) { debugPrint('Error exporting store: $e'); rethrow; } } /// Import a tile cache store from an archive file /// /// [filePath] - Path to the .fmtc archive file to import /// [storeNames] - Optional list of store names to import (null = import all) /// [strategy] - Conflict resolution strategy (default: merge) /// /// Returns a map with import statistics (e.g., tile count, stores imported) Future> importStore( String filePath, { List? storeNames, ImportConflictStrategy strategy = ImportConflictStrategy.merge, }) async { if (!_isInitialized) { throw StateError( 'TileCacheService not initialized. Call initialize() first.', ); } try { final external = FMTCRoot.external(pathToArchive: filePath); final result = external.import(storeNames: storeNames, strategy: strategy); // Wait for the import to complete and get tile count final tileCount = await result.complete; // Wait for store states final storesToStates = await result.storesToStates; debugPrint('Import completed: $tileCount tiles imported, ${storesToStates.length} stores'); // Count successful stores (those that weren't skipped) final successfulCount = storesToStates.values.where((state) => state.name != null).length; return { 'successfulStores': successfulCount, 'tileCount': tileCount, 'storesToStates': storesToStates, }; } catch (e) { debugPrint('Error importing store: $e'); rethrow; } } /// List all stores available in an archive file without importing /// /// [filePath] - Path to the .fmtc archive file to inspect /// /// Returns a list of store names contained in the archive Future> listArchiveStores(String filePath) async { try { final external = FMTCRoot.external(pathToArchive: filePath); final stores = await external.listStores; debugPrint('Archive contains ${stores.length} stores: $stores'); return stores; } catch (e) { debugPrint('Error listing archive stores: $e'); rethrow; } } void dispose() { _isInitialized = false; } }