mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-07-09 22:18:38 +02:00
Add offline-first tile system with per-provider caching and error retry
- Add ServicePolicy framework with OSM-specific rate limiting and TTL - Add per-provider disk tile cache (ProviderTileCacheStore) with O(1) lookup, oldest-modified eviction, and ETag/304 revalidation - Rewrite DeflockTileProvider with two paths: common (NetworkTileProvider) and offline-first (disk cache -> local tiles -> network with caching) - Add zoom-aware offline routing so tiles outside offline area zoom ranges use the efficient common path instead of the overhead-heavy offline path - Fix HTTP client lifecycle: dispose() is now a no-op for flutter_map widget recycling; shutdown() handles permanent teardown - Add TileLayerManager with exponential backoff retry (2s->60s cap), provider switch detection, and backoff reset - Guard null provider/tileType in download dialog with localized error - Fix Nominatim cache key to use normalized viewbox values - Comprehensive test coverage (1800+ lines across 6 test files) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
@@ -26,16 +27,63 @@ class MapOverlays extends StatelessWidget {
|
||||
this.onSearchPressed,
|
||||
});
|
||||
|
||||
/// Show full attribution text in a dialog
|
||||
/// Show full attribution text in a dialog with license link.
|
||||
void _showAttributionDialog(BuildContext context, String attribution) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// Get the license URL from the current tile provider's service policy
|
||||
final appState = AppState.instance;
|
||||
final tileType = appState.selectedTileType;
|
||||
final attributionUrl = tileType?.servicePolicy.attributionUrl;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('mapTiles.attribution')),
|
||||
content: SelectableText(
|
||||
attribution,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
attribution,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
if (attributionUrl != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Semantics(
|
||||
link: true,
|
||||
label: locService.t('mapTiles.openLicense', params: [attributionUrl]),
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
try {
|
||||
final uri = Uri.parse(attributionUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
attributionUrl,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -125,23 +173,30 @@ class MapOverlays extends StatelessWidget {
|
||||
Positioned(
|
||||
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom),
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showAttributionDialog(context, attribution!),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: LocalizationService.instance.t('mapTiles.mapAttribution', params: [attribution!]),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onTap: () => _showAttributionDialog(context, attribution!),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,68 +1,124 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/tile_provider.dart' as models;
|
||||
import '../../services/deflock_tile_provider.dart';
|
||||
import '../../services/provider_tile_cache_manager.dart';
|
||||
|
||||
/// Manages tile layer creation, caching, and provider switching.
|
||||
/// Uses DeFlock's custom tile provider for clean integration.
|
||||
/// Manages tile layer creation with per-provider caching and provider switching.
|
||||
///
|
||||
/// Each tile provider/type combination gets its own [DeflockTileProvider]
|
||||
/// instance with isolated caching (separate cache directory, configurable size
|
||||
/// limit, and policy-driven TTL enforcement). Providers are created lazily on
|
||||
/// first use and cached for instant switching.
|
||||
class TileLayerManager {
|
||||
DeflockTileProvider? _tileProvider;
|
||||
final Map<String, DeflockTileProvider> _providers = {};
|
||||
int _mapRebuildKey = 0;
|
||||
String? _lastProviderId;
|
||||
String? _lastTileTypeId;
|
||||
bool? _lastOfflineMode;
|
||||
|
||||
/// Get the current map rebuild key for cache busting
|
||||
/// Stream that triggers flutter_map to drop all tiles and reload.
|
||||
/// Fired after a debounced delay when tile errors are detected.
|
||||
final StreamController<void> _resetController =
|
||||
StreamController<void>.broadcast();
|
||||
|
||||
/// Debounce timer for scheduling a tile reset after errors.
|
||||
Timer? _retryTimer;
|
||||
|
||||
/// Current retry delay — starts at [_minRetryDelay] and doubles on each
|
||||
/// retry cycle (capped at [_maxRetryDelay]). Resets to [_minRetryDelay]
|
||||
/// when a tile loads successfully.
|
||||
Duration _retryDelay = const Duration(seconds: 2);
|
||||
|
||||
static const _minRetryDelay = Duration(seconds: 2);
|
||||
static const _maxRetryDelay = Duration(seconds: 60);
|
||||
|
||||
/// Get the current map rebuild key for cache busting.
|
||||
int get mapRebuildKey => _mapRebuildKey;
|
||||
|
||||
/// Initialize the tile layer manager
|
||||
/// Current retry delay (exposed for testing).
|
||||
@visibleForTesting
|
||||
Duration get retryDelay => _retryDelay;
|
||||
|
||||
/// Stream of reset events (exposed for testing).
|
||||
@visibleForTesting
|
||||
Stream<void> get resetStream => _resetController.stream;
|
||||
|
||||
/// Initialize the tile layer manager.
|
||||
///
|
||||
/// [ProviderTileCacheManager.init] is called in main() before any widgets
|
||||
/// build, so this is a no-op retained for API compatibility.
|
||||
void initialize() {
|
||||
// Don't create tile provider here - create it fresh for each build
|
||||
// Cache directory is already resolved in main().
|
||||
}
|
||||
|
||||
/// Dispose of resources
|
||||
/// Dispose of all provider resources.
|
||||
///
|
||||
/// Synchronous to match Flutter's [State.dispose] contract. Calls
|
||||
/// [DeflockTileProvider.shutdown] to permanently close each provider's HTTP
|
||||
/// client. (We don't call provider.dispose() here — flutter_map already
|
||||
/// called it when the TileLayer widget was removed, and it's safe to call
|
||||
/// again but unnecessary.)
|
||||
void dispose() {
|
||||
_tileProvider?.dispose();
|
||||
_retryTimer?.cancel();
|
||||
_resetController.close();
|
||||
for (final provider in _providers.values) {
|
||||
provider.shutdown();
|
||||
}
|
||||
_providers.clear();
|
||||
}
|
||||
|
||||
/// Check if cache should be cleared and increment rebuild key if needed.
|
||||
/// Returns true if cache was cleared (map should be rebuilt).
|
||||
bool checkAndClearCacheIfNeeded({
|
||||
required String? currentProviderId,
|
||||
required String? currentTileTypeId,
|
||||
required bool currentOfflineMode,
|
||||
}) {
|
||||
bool shouldClear = false;
|
||||
String? reason;
|
||||
|
||||
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId)) {
|
||||
if (_lastProviderId != currentProviderId) {
|
||||
reason = 'provider ($currentProviderId)';
|
||||
shouldClear = true;
|
||||
} else if (_lastTileTypeId != currentTileTypeId) {
|
||||
reason = 'tile type ($currentTileTypeId)';
|
||||
shouldClear = true;
|
||||
} else if ((_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
|
||||
} else if (_lastOfflineMode != currentOfflineMode) {
|
||||
reason = 'offline mode ($currentOfflineMode)';
|
||||
shouldClear = true;
|
||||
}
|
||||
|
||||
if (shouldClear) {
|
||||
// Force map rebuild with new key to bust flutter_map cache
|
||||
// Force map rebuild with new key to bust flutter_map cache.
|
||||
// We don't dispose providers here — they're reusable across switches.
|
||||
_mapRebuildKey++;
|
||||
// Dispose old provider before creating a fresh one (closes HTTP client)
|
||||
_tileProvider?.dispose();
|
||||
_tileProvider = null;
|
||||
// Reset backoff so the new provider starts with a clean slate.
|
||||
// Cancel any pending retry timer — it belongs to the old provider's errors.
|
||||
_retryDelay = _minRetryDelay;
|
||||
_retryTimer?.cancel();
|
||||
debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
|
||||
}
|
||||
|
||||
_lastProviderId = currentProviderId;
|
||||
_lastTileTypeId = currentTileTypeId;
|
||||
_lastOfflineMode = currentOfflineMode;
|
||||
|
||||
return shouldClear;
|
||||
}
|
||||
|
||||
/// Clear the tile request queue (call after cache clear)
|
||||
/// Clear the tile request queue (call after cache clear).
|
||||
///
|
||||
/// In the old architecture this incremented [_mapRebuildKey] a second time
|
||||
/// to force a rebuild after the provider was disposed and recreated. With
|
||||
/// per-provider caching, [checkAndClearCacheIfNeeded] already increments the
|
||||
/// key, so this is now a no-op. Kept for API compatibility with map_view.
|
||||
void clearTileQueue() {
|
||||
// With NetworkTileProvider, clearing is handled by FlutterMap's internal cache
|
||||
// We just need to increment the rebuild key to bust the cache
|
||||
_mapRebuildKey++;
|
||||
debugPrint('[TileLayerManager] Cache cleared - rebuilding map $_mapRebuildKey');
|
||||
// No-op: checkAndClearCacheIfNeeded() already incremented _mapRebuildKey.
|
||||
}
|
||||
|
||||
/// Clear tile queue immediately (for zoom changes, etc.)
|
||||
@@ -70,19 +126,85 @@ class TileLayerManager {
|
||||
// No immediate clearing needed — NetworkTileProvider aborts obsolete requests
|
||||
}
|
||||
|
||||
/// Clear only tiles that are no longer visible in the current bounds
|
||||
/// Clear only tiles that are no longer visible in the current bounds.
|
||||
void clearStaleRequests({required LatLngBounds currentBounds}) {
|
||||
// No selective clearing needed — NetworkTileProvider aborts obsolete requests
|
||||
}
|
||||
|
||||
/// Called by flutter_map when a tile fails to load. Schedules a debounced
|
||||
/// reset so that all failed tiles get retried after the burst of errors
|
||||
/// settles down. Uses exponential backoff: 2s → 4s → 8s → … → 60s cap.
|
||||
///
|
||||
/// Skips retry for [TileLoadCancelledException] (tile scrolled off screen)
|
||||
/// and [TileNotAvailableOfflineException] (no cached data, retrying won't
|
||||
/// help without network).
|
||||
@visibleForTesting
|
||||
void onTileLoadError(
|
||||
TileImage tile,
|
||||
Object error,
|
||||
StackTrace? stackTrace,
|
||||
) {
|
||||
// Cancelled tiles are already gone — no retry needed.
|
||||
if (error is TileLoadCancelledException) return;
|
||||
|
||||
// Offline misses won't resolve by retrying — tile isn't cached.
|
||||
if (error is TileNotAvailableOfflineException) return;
|
||||
|
||||
debugPrint(
|
||||
'[TileLayerManager] Tile error at '
|
||||
'${tile.coordinates.z}/${tile.coordinates.x}/${tile.coordinates.y}, '
|
||||
'scheduling retry in ${_retryDelay.inSeconds}s',
|
||||
);
|
||||
scheduleRetry();
|
||||
}
|
||||
|
||||
/// Schedule a debounced tile reset with exponential backoff.
|
||||
///
|
||||
/// Cancels any pending retry timer and starts a new one at the current
|
||||
/// [_retryDelay]. After the timer fires, [_retryDelay] doubles (capped
|
||||
/// at [_maxRetryDelay]).
|
||||
@visibleForTesting
|
||||
void scheduleRetry() {
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer(_retryDelay, () {
|
||||
if (!_resetController.isClosed) {
|
||||
debugPrint('[TileLayerManager] Firing tile reset to retry failed tiles');
|
||||
_resetController.add(null);
|
||||
}
|
||||
// Back off for next failure cycle
|
||||
_retryDelay = Duration(
|
||||
milliseconds: min(
|
||||
_retryDelay.inMilliseconds * 2,
|
||||
_maxRetryDelay.inMilliseconds,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset backoff to minimum delay. Called when a tile loads successfully
|
||||
/// via the offline-first path, indicating connectivity has been restored.
|
||||
///
|
||||
/// Note: the common path (`NetworkTileImageProvider`) does not call this,
|
||||
/// so backoff resets only when the offline-first path succeeds over the
|
||||
/// network. In practice this is fine — the common path's `RetryClient`
|
||||
/// handles its own retries, and the reset stream only retries tiles that
|
||||
/// flutter_map has already marked as `loadError`.
|
||||
void onTileLoadSuccess() {
|
||||
_retryDelay = _minRetryDelay;
|
||||
}
|
||||
|
||||
/// Build tile layer widget with current provider and type.
|
||||
/// Uses DeFlock's custom tile provider for clean integration with our offline/online system.
|
||||
///
|
||||
/// Gets or creates a [DeflockTileProvider] for the given provider/type
|
||||
/// combination, each with its own isolated cache.
|
||||
Widget buildTileLayer({
|
||||
required models.TileProvider? selectedProvider,
|
||||
required models.TileType? selectedTileType,
|
||||
}) {
|
||||
// Create a fresh tile provider instance if we don't have one or cache was cleared
|
||||
_tileProvider ??= DeflockTileProvider();
|
||||
final tileProvider = _getOrCreateProvider(
|
||||
selectedProvider: selectedProvider,
|
||||
selectedTileType: selectedTileType,
|
||||
);
|
||||
|
||||
// Use the actual urlTemplate from the selected tile type. Our getTileUrl()
|
||||
// override handles the real URL generation; flutter_map uses urlTemplate
|
||||
@@ -94,7 +216,58 @@ class TileLayerManager {
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'me.deflock.deflockapp',
|
||||
maxZoom: selectedTileType?.maxZoom.toDouble() ?? 18.0,
|
||||
tileProvider: _tileProvider!,
|
||||
tileProvider: tileProvider,
|
||||
// Wire the reset stream so failed tiles get retried after a delay.
|
||||
reset: _resetController.stream,
|
||||
errorTileCallback: onTileLoadError,
|
||||
// Clean up error tiles when they scroll off screen.
|
||||
evictErrorTileStrategy: EvictErrorTileStrategy.notVisible,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get or create a [DeflockTileProvider] for the given provider/type.
|
||||
DeflockTileProvider _getOrCreateProvider({
|
||||
required models.TileProvider? selectedProvider,
|
||||
required models.TileType? selectedTileType,
|
||||
}) {
|
||||
if (selectedProvider == null || selectedTileType == null) {
|
||||
// No provider configured — return a fallback with default config.
|
||||
return _providers.putIfAbsent(
|
||||
'_fallback',
|
||||
() => DeflockTileProvider(
|
||||
providerId: 'unknown',
|
||||
tileType: models.TileType(
|
||||
id: 'unknown',
|
||||
name: 'Unknown',
|
||||
urlTemplate: 'https://unknown.invalid/tiles/{z}/{x}/{y}',
|
||||
attribution: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final key = '${selectedProvider.id}/${selectedTileType.id}';
|
||||
return _providers.putIfAbsent(key, () {
|
||||
final cachingProvider = ProviderTileCacheManager.isInitialized
|
||||
? ProviderTileCacheManager.getOrCreate(
|
||||
providerId: selectedProvider.id,
|
||||
tileTypeId: selectedTileType.id,
|
||||
policy: selectedTileType.servicePolicy,
|
||||
)
|
||||
: null;
|
||||
|
||||
debugPrint(
|
||||
'[TileLayerManager] Creating provider for $key '
|
||||
'(cache: ${cachingProvider != null ? "enabled" : "disabled"})',
|
||||
);
|
||||
|
||||
return DeflockTileProvider(
|
||||
providerId: selectedProvider.id,
|
||||
tileType: selectedTileType,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
cachingProvider: cachingProvider,
|
||||
onNetworkSuccess: onTileLoadSuccess,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user