show cached tiles

This commit is contained in:
ericz
2026-05-23 12:40:56 +02:00
parent 225d07b440
commit fc67d8d5aa
2 changed files with 283 additions and 0 deletions
+100
View File
@@ -31,6 +31,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
bool _isDownloading = false;
int _completedTiles = 0;
int _failedTiles = 0;
List<CachedTileInfo> _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<MapCacheScreen> {
_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<MapCacheScreen> {
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<MapCacheScreen> {
final cacheService = context.read<MapTileCacheService>();
await cacheService.clearCache();
if (!mounted) return;
await _refreshCachedTiles();
if (!mounted) return;
showDismissibleSnackBar(
context,
content: Text(context.l10n.mapCache_offlineCacheCleared),
);
}
Future<void> _refreshCachedTiles() async {
if (!mounted) return;
setState(() {
_isLoadingCachedTiles = true;
});
final cacheService = context.read<MapTileCacheService>();
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<MapTileCacheService>();
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<MapCacheScreen> {
)
: 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<MapCacheScreen> {
),
],
),
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<MapCacheScreen> {
},
),
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(
+183
View File
@@ -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<CachedTileInfo> 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<String, String> get defaultHeaders => {
'User-Agent': 'flutter_map ($userAgentPackageName)',
};
@@ -232,6 +261,98 @@ class MapTileCacheService extends ChangeNotifier {
await cacheManager.emptyCache();
}
Future<CachedTileInventory> getCachedTileInventory() async {
final repo = _concreteCacheManager.config.repo;
await repo.open();
final objects = await repo.getAllObjects();
final tiles = <CachedTileInfo>[];
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<CachedTileInfo> filterTilesForActiveSource(
Iterable<CachedTileInfo> 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<CachedTileInfo> 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<Polygon> buildCachedTilePolygons(
Iterable<CachedTileInfo> tiles, {
required int zoom,
LatLngBounds? visibleBounds,
int limit = 250,
}) {
final polygons = <Polygon>[];
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())