diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart index dda17132..d6ce8489 100644 --- a/lib/screens/map_cache_screen.dart +++ b/lib/screens/map_cache_screen.dart @@ -31,6 +31,11 @@ class _MapCacheScreenState extends State { bool _isDownloading = false; int _completedTiles = 0; int _failedTiles = 0; + List _cachedTiles = const []; + int _cachedTileBytes = 0; + bool _isLoadingCachedTiles = false; + double _overlayZoom = 2.0; + LatLngBounds? _visibleBounds; @override void initState() { @@ -113,8 +118,10 @@ class _MapCacheScreenState extends State { _minZoom = safeMin; _maxZoom = safeMax; _selectedBounds = bounds; + _visibleBounds = bounds; }); _updateEstimate(); + _refreshCachedTiles(); if (bounds != null) { _mapController.fitCamera( CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)), @@ -253,6 +260,8 @@ class _MapCacheScreenState extends State { result.failed, ) : context.l10n.mapCache_cachedTiles(result.downloaded); + await _refreshCachedTiles(); + if (!mounted) return; showDismissibleSnackBar(context, content: Text(message)); } @@ -279,17 +288,61 @@ class _MapCacheScreenState extends State { final cacheService = context.read(); await cacheService.clearCache(); if (!mounted) return; + await _refreshCachedTiles(); + if (!mounted) return; showDismissibleSnackBar( context, content: Text(context.l10n.mapCache_offlineCacheCleared), ); } + Future _refreshCachedTiles() async { + if (!mounted) return; + setState(() { + _isLoadingCachedTiles = true; + }); + final cacheService = context.read(); + final inventory = await cacheService.getCachedTileInventory(); + if (!mounted) return; + setState(() { + _cachedTiles = inventory.tiles; + _cachedTileBytes = inventory.totalBytes; + _isLoadingCachedTiles = false; + }); + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + @override Widget build(BuildContext context) { final tileCache = context.watch(); final source = tileCache.source; final selectedBounds = _selectedBounds; + final activeCachedTiles = tileCache.filterTilesForActiveSource( + _cachedTiles, + ); + final cachedInSelection = tileCache.countTilesForBounds( + activeCachedTiles, + bounds: selectedBounds, + minZoom: _minZoom, + maxZoom: _maxZoom, + ); + final overlayPolygons = tileCache.buildCachedTilePolygons( + activeCachedTiles, + zoom: _overlayZoom.round().clamp(0, 19).toInt(), + visibleBounds: _visibleBounds, + ); + final cacheSummary = + 'Source: ${source.label}\n' + 'Cached tiles for source: ${activeCachedTiles.length}\n' + 'Cached in selected area/zoom: $cachedInSelection\n' + 'Approx cache size: ${_formatBytes(_cachedTileBytes)}'; final l10n = context.l10n; final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final progressValue = _estimatedTiles == 0 @@ -326,6 +379,20 @@ class _MapCacheScreenState extends State { ) : const KeyboardOptions.disabled(), ), + onPositionChanged: (camera, hasGesture) { + final nextZoom = camera.zoom; + final nextBounds = camera.visibleBounds; + if (_overlayZoom != nextZoom || + _visibleBounds?.north != nextBounds.north || + _visibleBounds?.south != nextBounds.south || + _visibleBounds?.east != nextBounds.east || + _visibleBounds?.west != nextBounds.west) { + setState(() { + _overlayZoom = nextZoom; + _visibleBounds = nextBounds; + }); + } + }, ), children: [ TileLayer( @@ -346,9 +413,26 @@ class _MapCacheScreenState extends State { ), ], ), + if (overlayPolygons.isNotEmpty) + PolygonLayer(polygons: overlayPolygons), ], ), if (isDesktop) _buildDesktopMapControls(), + Positioned( + top: 12, + left: isDesktop ? 84 : 12, + child: Card( + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + _isLoadingCachedTiles + ? 'Loading cached tiles...' + : 'Visible cached tiles at z${_overlayZoom.round()}: ${overlayPolygons.length}', + style: const TextStyle(fontSize: 12), + ), + ), + ), + ), Positioned( top: 12, right: 12, @@ -433,6 +517,22 @@ class _MapCacheScreenState extends State { }, ), Text(l10n.mapCache_estimatedTiles(_estimatedTiles)), + const SizedBox(height: 12), + TextFormField( + key: ValueKey( + '$cacheSummary|${activeCachedTiles.length}|$cachedInSelection|$_cachedTileBytes', + ), + initialValue: cacheSummary, + readOnly: true, + minLines: 4, + maxLines: 4, + decoration: InputDecoration( + labelText: _isLoadingCachedTiles + ? 'Cached tiles' + : 'Cached tile summary', + border: const OutlineInputBorder(), + ), + ), if (!source.allowsBulkDownload) ...[ const SizedBox(height: 8), Text( diff --git a/lib/services/map_tile_cache_service.dart b/lib/services/map_tile_cache_service.dart index 5cbdac5c..c0fc9a6b 100644 --- a/lib/services/map_tile_cache_service.dart +++ b/lib/services/map_tile_cache_service.dart @@ -190,6 +190,33 @@ class MapTileCacheResult { }); } +class CachedTileInfo { + final String key; + final String host; + final String sourceId; + final int zoom; + final int x; + final int y; + final int length; + + const CachedTileInfo({ + required this.key, + required this.host, + required this.sourceId, + required this.zoom, + required this.x, + required this.y, + required this.length, + }); +} + +class CachedTileInventory { + final List tiles; + final int totalBytes; + + const CachedTileInventory({required this.tiles, required this.totalBytes}); +} + class MapTileCacheService extends ChangeNotifier { static const String cacheKey = 'map_tile_cache'; static const String userAgentPackageName = 'com.meshcore.open'; @@ -224,6 +251,8 @@ class MapTileCacheService extends ChangeNotifier { String get urlTemplate => _buildUrlTemplate(appSettingsService.settings); + CacheManager get _concreteCacheManager => cacheManager as CacheManager; + Map get defaultHeaders => { 'User-Agent': 'flutter_map ($userAgentPackageName)', }; @@ -232,6 +261,98 @@ class MapTileCacheService extends ChangeNotifier { await cacheManager.emptyCache(); } + Future getCachedTileInventory() async { + final repo = _concreteCacheManager.config.repo; + await repo.open(); + final objects = await repo.getAllObjects(); + final tiles = []; + int totalBytes = 0; + + for (final object in objects) { + totalBytes += object.length ?? 0; + final tile = _parseCachedTile(object); + if (tile != null) { + tiles.add(tile); + } + } + + return CachedTileInventory(tiles: tiles, totalBytes: totalBytes); + } + + List filterTilesForActiveSource( + Iterable tiles, + ) { + final activeSource = source; + if (!activeSource.isStadia) { + return tiles + .where( + (tile) => + tile.sourceId == MapRasterSourceCatalog.osmStandard.id && + tile.host == 'tile.openstreetmap.org', + ) + .toList(); + } + + final activeEndpoint = endpoint; + return tiles + .where( + (tile) => + tile.sourceId == activeSource.id && + tile.host == activeEndpoint.host, + ) + .toList(); + } + + int countTilesForBounds( + Iterable tiles, { + LatLngBounds? bounds, + required int minZoom, + required int maxZoom, + }) { + if (bounds == null) return 0; + final safeMin = math.min(minZoom, maxZoom); + final safeMax = math.max(minZoom, maxZoom); + return tiles.where((tile) { + if (tile.zoom < safeMin || tile.zoom > safeMax) { + return false; + } + final tileBounds = _tileBoundsForTile(tile.x, tile.y, tile.zoom); + return _boundsIntersect(bounds, tileBounds); + }).length; + } + + List buildCachedTilePolygons( + Iterable tiles, { + required int zoom, + LatLngBounds? visibleBounds, + int limit = 250, + }) { + final polygons = []; + for (final tile in tiles) { + if (tile.zoom != zoom) continue; + final tileBounds = _tileBoundsForTile(tile.x, tile.y, tile.zoom); + if (visibleBounds != null && + !_boundsIntersect(visibleBounds, tileBounds)) { + continue; + } + polygons.add( + Polygon( + points: [ + tileBounds.northWest, + tileBounds.northEast, + tileBounds.southEast, + tileBounds.southWest, + ], + borderStrokeWidth: 0.6, + color: const Color(0x5532A852), + borderColor: const Color(0xCC2F8F46), + ), + ); + if (polygons.length >= limit) break; + } + return polygons; + } + int estimateTileCount(LatLngBounds bounds, int minZoom, int maxZoom) { final safeMin = math.min(minZoom, maxZoom); final safeMax = math.max(minZoom, maxZoom); @@ -381,6 +502,42 @@ class MapTileCacheService extends ChangeNotifier { return '$base?$query'; } + CachedTileInfo? _parseCachedTile(CacheObject object) { + final uri = Uri.tryParse(object.key); + if (uri == null) return null; + final segments = uri.pathSegments; + + if (segments.length >= 3 && + segments[segments.length - 3].isNotEmpty && + segments[segments.length - 2].isNotEmpty) { + final zoom = int.tryParse(segments[segments.length - 3]); + final x = int.tryParse(segments[segments.length - 2]); + final ySegment = segments.last; + final y = int.tryParse(ySegment.split('.').first); + + if (zoom == null || x == null || y == null) { + return null; + } + + var sourceId = MapRasterSourceCatalog.osmStandard.id; + if (segments.length >= 5 && segments[0] == 'tiles') { + sourceId = segments[1]; + } + + return CachedTileInfo( + key: object.key, + host: uri.host, + sourceId: sourceId, + zoom: zoom, + x: x, + y: y, + length: object.length ?? 0, + ); + } + + return null; + } + int _lonToTileX(double lon, int zoom, int maxIndex) { final n = 1 << zoom; final value = ((lon + 180.0) / 360.0 * n).floor(); @@ -401,6 +558,32 @@ class MapTileCacheService extends ChangeNotifier { return lat.clamp(-maxLat, maxLat); } + LatLngBounds _tileBoundsForTile(int x, int y, int zoom) { + return LatLngBounds.unsafe( + north: _tileYToLat(y, zoom), + south: _tileYToLat(y + 1, zoom), + east: _tileXToLon(x + 1, zoom), + west: _tileXToLon(x, zoom), + ); + } + + double _tileXToLon(int x, int zoom) { + final n = 1 << zoom; + return x / n * 360.0 - 180.0; + } + + double _tileYToLat(int y, int zoom) { + final n = math.pi - (2.0 * math.pi * y) / (1 << zoom); + return 180.0 / math.pi * math.atan(0.5 * (math.exp(n) - math.exp(-n))); + } + + bool _boundsIntersect(LatLngBounds a, LatLngBounds b) { + return a.west <= b.east && + a.east >= b.west && + a.south <= b.north && + a.north >= b.south; + } + String _buildTileUrl(int x, int y, int zoom, {required String urlTemplate}) { return urlTemplate .replaceAll('{z}', zoom.toString())