mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-08 17:53:25 +10:00
show cached tiles
This commit is contained in:
@@ -31,6 +31,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
bool _isDownloading = false;
|
bool _isDownloading = false;
|
||||||
int _completedTiles = 0;
|
int _completedTiles = 0;
|
||||||
int _failedTiles = 0;
|
int _failedTiles = 0;
|
||||||
|
List<CachedTileInfo> _cachedTiles = const [];
|
||||||
|
int _cachedTileBytes = 0;
|
||||||
|
bool _isLoadingCachedTiles = false;
|
||||||
|
double _overlayZoom = 2.0;
|
||||||
|
LatLngBounds? _visibleBounds;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -113,8 +118,10 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
_minZoom = safeMin;
|
_minZoom = safeMin;
|
||||||
_maxZoom = safeMax;
|
_maxZoom = safeMax;
|
||||||
_selectedBounds = bounds;
|
_selectedBounds = bounds;
|
||||||
|
_visibleBounds = bounds;
|
||||||
});
|
});
|
||||||
_updateEstimate();
|
_updateEstimate();
|
||||||
|
_refreshCachedTiles();
|
||||||
if (bounds != null) {
|
if (bounds != null) {
|
||||||
_mapController.fitCamera(
|
_mapController.fitCamera(
|
||||||
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
|
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
|
||||||
@@ -253,6 +260,8 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
result.failed,
|
result.failed,
|
||||||
)
|
)
|
||||||
: context.l10n.mapCache_cachedTiles(result.downloaded);
|
: context.l10n.mapCache_cachedTiles(result.downloaded);
|
||||||
|
await _refreshCachedTiles();
|
||||||
|
if (!mounted) return;
|
||||||
showDismissibleSnackBar(context, content: Text(message));
|
showDismissibleSnackBar(context, content: Text(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,17 +288,61 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
final cacheService = context.read<MapTileCacheService>();
|
final cacheService = context.read<MapTileCacheService>();
|
||||||
await cacheService.clearCache();
|
await cacheService.clearCache();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
await _refreshCachedTiles();
|
||||||
|
if (!mounted) return;
|
||||||
showDismissibleSnackBar(
|
showDismissibleSnackBar(
|
||||||
context,
|
context,
|
||||||
content: Text(context.l10n.mapCache_offlineCacheCleared),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tileCache = context.watch<MapTileCacheService>();
|
final tileCache = context.watch<MapTileCacheService>();
|
||||||
final source = tileCache.source;
|
final source = tileCache.source;
|
||||||
final selectedBounds = _selectedBounds;
|
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 l10n = context.l10n;
|
||||||
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
|
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
|
||||||
final progressValue = _estimatedTiles == 0
|
final progressValue = _estimatedTiles == 0
|
||||||
@@ -326,6 +379,20 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
)
|
)
|
||||||
: const KeyboardOptions.disabled(),
|
: 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: [
|
children: [
|
||||||
TileLayer(
|
TileLayer(
|
||||||
@@ -346,9 +413,26 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (overlayPolygons.isNotEmpty)
|
||||||
|
PolygonLayer(polygons: overlayPolygons),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (isDesktop) _buildDesktopMapControls(),
|
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(
|
Positioned(
|
||||||
top: 12,
|
top: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
@@ -433,6 +517,22 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
Text(l10n.mapCache_estimatedTiles(_estimatedTiles)),
|
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) ...[
|
if (!source.allowsBulkDownload) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -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 {
|
class MapTileCacheService extends ChangeNotifier {
|
||||||
static const String cacheKey = 'map_tile_cache';
|
static const String cacheKey = 'map_tile_cache';
|
||||||
static const String userAgentPackageName = 'com.meshcore.open';
|
static const String userAgentPackageName = 'com.meshcore.open';
|
||||||
@@ -224,6 +251,8 @@ class MapTileCacheService extends ChangeNotifier {
|
|||||||
|
|
||||||
String get urlTemplate => _buildUrlTemplate(appSettingsService.settings);
|
String get urlTemplate => _buildUrlTemplate(appSettingsService.settings);
|
||||||
|
|
||||||
|
CacheManager get _concreteCacheManager => cacheManager as CacheManager;
|
||||||
|
|
||||||
Map<String, String> get defaultHeaders => {
|
Map<String, String> get defaultHeaders => {
|
||||||
'User-Agent': 'flutter_map ($userAgentPackageName)',
|
'User-Agent': 'flutter_map ($userAgentPackageName)',
|
||||||
};
|
};
|
||||||
@@ -232,6 +261,98 @@ class MapTileCacheService extends ChangeNotifier {
|
|||||||
await cacheManager.emptyCache();
|
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) {
|
int estimateTileCount(LatLngBounds bounds, int minZoom, int maxZoom) {
|
||||||
final safeMin = math.min(minZoom, maxZoom);
|
final safeMin = math.min(minZoom, maxZoom);
|
||||||
final safeMax = math.max(minZoom, maxZoom);
|
final safeMax = math.max(minZoom, maxZoom);
|
||||||
@@ -381,6 +502,42 @@ class MapTileCacheService extends ChangeNotifier {
|
|||||||
return '$base?$query';
|
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) {
|
int _lonToTileX(double lon, int zoom, int maxIndex) {
|
||||||
final n = 1 << zoom;
|
final n = 1 << zoom;
|
||||||
final value = ((lon + 180.0) / 360.0 * n).floor();
|
final value = ((lon + 180.0) / 360.0 * n).floor();
|
||||||
@@ -401,6 +558,32 @@ class MapTileCacheService extends ChangeNotifier {
|
|||||||
return lat.clamp(-maxLat, maxLat);
|
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}) {
|
String _buildTileUrl(int x, int y, int zoom, {required String urlTemplate}) {
|
||||||
return urlTemplate
|
return urlTemplate
|
||||||
.replaceAll('{z}', zoom.toString())
|
.replaceAll('{z}', zoom.toString())
|
||||||
|
|||||||
Reference in New Issue
Block a user