From a21e807d882fb2e9a92fa8eff38cc03de3a3d008 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 23 Aug 2025 17:42:53 -0500 Subject: [PATCH] lot of changes, got rid of custom cache stuff, now stepping in the way of http fetch instead of screwing with flutter map. --- lib/main.dart | 2 +- lib/screens/home_screen.dart | 3 +- lib/services/map_data_provider.dart | 5 + .../map_data_submodules/tiles_from_osm.dart | 42 +----- lib/services/simple_tile_service.dart | 99 ++++++++++++++ lib/state/settings_state.dart | 9 +- lib/widgets/map_view.dart | 124 ++++++++---------- lib/widgets/tile_provider_with_cache.dart | 96 -------------- 8 files changed, 164 insertions(+), 216 deletions(-) create mode 100644 lib/services/simple_tile_service.dart delete mode 100644 lib/widgets/tile_provider_with_cache.dart diff --git a/lib/main.dart b/lib/main.dart index db56c14..131b23e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +5,7 @@ import 'app_state.dart'; import 'screens/home_screen.dart'; import 'screens/settings_screen.dart'; -import 'widgets/tile_provider_with_cache.dart'; + Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 8c27579..4633078 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -5,7 +5,7 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../dev_config.dart'; import '../widgets/map_view.dart'; -import '../widgets/tile_provider_with_cache.dart'; + import '../widgets/add_camera_sheet.dart'; import '../widgets/camera_provider_with_cache.dart'; import '../widgets/download_area_dialog.dart'; @@ -41,7 +41,6 @@ class _HomeScreenState extends State { return MultiProvider( providers: [ - ChangeNotifierProvider(create: (_) => TileProviderWithCache()), ChangeNotifierProvider(create: (_) => CameraProviderWithCache()), ], child: Scaffold( diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index 00943bb..5cb6c8d 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -144,4 +144,9 @@ class MapDataProvider { } } } + + /// Clear any queued tile requests (call when map view changes significantly) + void clearTileQueue() { + clearOSMTileQueue(); + } } \ No newline at end of file diff --git a/lib/services/map_data_submodules/tiles_from_osm.dart b/lib/services/map_data_submodules/tiles_from_osm.dart index 461ac7b..c472932 100644 --- a/lib/services/map_data_submodules/tiles_from_osm.dart +++ b/lib/services/map_data_submodules/tiles_from_osm.dart @@ -9,15 +9,10 @@ import '../network_status.dart'; /// Global semaphore to limit simultaneous tile fetches final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent -/// Cancellation token to invalidate all pending requests -int _globalCancelToken = 0; - -/// Clear queued tile requests and cancel all retries +/// Clear queued tile requests when map view changes significantly void clearOSMTileQueue() { - final oldToken = _globalCancelToken; - _globalCancelToken++; // Invalidate all pending requests and retries final clearedCount = _tileFetchSemaphore.clearQueue(); - debugPrint('[OSMTiles] Cancel token: $oldToken -> $_globalCancelToken, cleared $clearedCount queued'); + debugPrint('[OSMTiles] Cleared $clearedCount queued tile requests'); } /// Fetches a tile from OSM, with in-memory retries/backoff, and global concurrency limit. @@ -37,17 +32,7 @@ Future> fetchOSMTile({ kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms), ]; - // Remember the cancel token when we start this request - final requestCancelToken = _globalCancelToken; - print('[fetchOSMTile] START $z/$x/$y with token $requestCancelToken (global: $_globalCancelToken)'); - while (true) { - // Check if this request was cancelled - if (requestCancelToken != _globalCancelToken) { - print('[fetchOSMTile] CANCELLED $z/$x/$y (token: $requestCancelToken vs $_globalCancelToken)'); - throw Exception('Tile request cancelled'); - } - await _tileFetchSemaphore.acquire(); try { print('[fetchOSMTile] FETCH $z/$x/$y'); @@ -55,12 +40,6 @@ Future> fetchOSMTile({ final resp = await http.get(Uri.parse(url)); print('[fetchOSMTile] HTTP ${resp.statusCode} for $z/$x/$y, length=${resp.bodyBytes.length}'); - // Check cancellation after HTTP request completes - this is the key check! - if (requestCancelToken != _globalCancelToken) { - print('[fetchOSMTile] CANCELLED $z/$x/$y after HTTP (token: $requestCancelToken vs $_globalCancelToken)'); - throw Exception('Tile request cancelled'); - } - if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) { print('[fetchOSMTile] SUCCESS $z/$x/$y'); NetworkStatus.instance.reportOsmTileSuccess(); @@ -71,11 +50,6 @@ Future> fetchOSMTile({ throw HttpException('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}'); } } catch (e) { - // Don't retry cancelled requests - if (e.toString().contains('cancelled')) { - rethrow; - } - print('[fetchOSMTile] Exception $z/$x/$y: $e'); // Report network issues on connection errors @@ -92,19 +66,7 @@ Future> fetchOSMTile({ final delay = delays[attempt - 1].clamp(0, 60000); print("[fetchOSMTile] Attempt $attempt for $z/$x/$y failed: $e. Retrying in ${delay}ms."); - - // Check cancellation before and after delay - if (requestCancelToken != _globalCancelToken) { - print('[fetchOSMTile] CANCELLED $z/$x/$y before retry'); - throw Exception('Tile request cancelled'); - } - await Future.delayed(Duration(milliseconds: delay)); - - if (requestCancelToken != _globalCancelToken) { - print('[fetchOSMTile] CANCELLED $z/$x/$y after retry delay'); - throw Exception('Tile request cancelled'); - } } finally { _tileFetchSemaphore.release(); } diff --git a/lib/services/simple_tile_service.dart b/lib/services/simple_tile_service.dart new file mode 100644 index 0000000..9cc3aa2 --- /dev/null +++ b/lib/services/simple_tile_service.dart @@ -0,0 +1,99 @@ +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; + +import 'map_data_provider.dart'; + +/// Simple HTTP client that routes tile requests through the centralized MapDataProvider. +/// This ensures all tile fetching (offline/online routing, retries, etc.) is in one place. +class SimpleTileHttpClient extends http.BaseClient { + final http.Client _inner = http.Client(); + final MapDataProvider _mapDataProvider = MapDataProvider(); + + @override + Future send(http.BaseRequest request) async { + // Only intercept tile requests to OSM + if (request.url.host == 'tile.openstreetmap.org') { + return _handleTileRequest(request); + } + + // Pass through all other requests + return _inner.send(request); + } + + Future _handleTileRequest(http.BaseRequest request) async { + final pathSegments = request.url.pathSegments; + + // Parse z/x/y from URL like: /15/5242/12666.png + if (pathSegments.length == 3) { + final z = int.tryParse(pathSegments[0]); + final x = int.tryParse(pathSegments[1]); + final yPng = pathSegments[2]; + final y = int.tryParse(yPng.replaceAll('.png', '')); + + if (z != null && x != null && y != null) { + return _getTile(z, x, y); + } + } + + // Malformed tile URL - pass through to OSM + return _inner.send(request); + } + + Future _getTile(int z, int x, int y) async { + try { + // Use centralized tile fetching from MapDataProvider + final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y); + debugPrint('[SimpleTileService] Serving tile $z/$x/$y via MapDataProvider'); + + return http.StreamedResponse( + Stream.value(tileBytes), + 200, + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=604800', // 1 week cache + 'Expires': _httpDateFormat(DateTime.now().add(Duration(days: 7))), + 'Last-Modified': _httpDateFormat(DateTime.now().subtract(Duration(hours: 1))), + }, + ); + + } catch (e) { + debugPrint('[SimpleTileService] Tile fetch failed for $z/$x/$y: $e'); + + // Return 404 for any failure - let flutter_map handle gracefully + return http.StreamedResponse( + Stream.value([]), + 404, + reasonPhrase: 'Tile not available: $e', + ); + } + } + + /// Clear any queued tile requests when map view changes + void clearTileQueue() { + _mapDataProvider.clearTileQueue(); + } + + /// Format date for HTTP headers (RFC 7231) + String _httpDateFormat(DateTime date) { + final utc = date.toUtc(); + final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + final weekday = weekdays[utc.weekday - 1]; + final day = utc.day.toString().padLeft(2, '0'); + final month = months[utc.month - 1]; + final year = utc.year; + final hour = utc.hour.toString().padLeft(2, '0'); + final minute = utc.minute.toString().padLeft(2, '0'); + final second = utc.second.toString().padLeft(2, '0'); + + return '$weekday, $day $month $year $hour:$minute:$second GMT'; + } + + @override + void close() { + _inner.close(); + super.close(); + } +} \ No newline at end of file diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 67b24e4..de81cf2 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../widgets/tile_provider_with_cache.dart'; + // Enum for upload mode (Production, OSM Sandbox, Simulate) enum UploadMode { production, sandbox, simulate } @@ -49,16 +49,9 @@ class SettingsState extends ChangeNotifier { } Future setOfflineMode(bool enabled) async { - final wasOffline = _offlineMode; _offlineMode = enabled; final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_offlineModePrefsKey, enabled); - - if (wasOffline && !enabled) { - // Transitioning from offline to online: clear tile cache! - TileProviderWithCache.clearCache(); - } - notifyListeners(); } diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 7654d6b..f4a4335 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -1,16 +1,18 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; +import 'package:http/http.dart' as http; import '../app_state.dart'; import '../services/offline_area_service.dart'; +import '../services/simple_tile_service.dart'; import '../models/osm_camera_node.dart'; import '../models/camera_profile.dart'; import 'debouncer.dart'; -import 'tile_provider_with_cache.dart'; import 'camera_provider_with_cache.dart'; import 'map/camera_markers.dart'; import 'map/direction_cones.dart'; @@ -36,38 +38,36 @@ class MapView extends StatefulWidget { class _MapViewState extends State { late final MapController _controller; - final Debouncer _debounce = Debouncer(kDebounceCameraRefresh); + final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh); + final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150)); StreamSubscription? _positionSub; LatLng? _currentLatLng; late final CameraProviderWithCache _cameraProvider; + late final SimpleTileHttpClient _tileHttpClient; // Track profile changes to trigger camera refresh List? _lastEnabledProfiles; - // Track offline mode changes to trigger tile refresh - bool? _lastOfflineMode; - int _mapRebuildCounter = 0; + // Track map position to only clear queue on significant changes + double? _lastZoom; + LatLng? _lastCenter; @override void initState() { super.initState(); - // _debounceTileLayerUpdate removed OfflineAreaService(); _controller = widget.controller; + _tileHttpClient = SimpleTileHttpClient(); _initLocation(); // Set up camera overlay caching _cameraProvider = CameraProviderWithCache.instance; _cameraProvider.addListener(_onCamerasUpdated); - // Ensure initial overlays are fetched + // Fetch initial cameras WidgetsBinding.instance.addPostFrameCallback((_) { - // Set up tile refresh callback - final tileProvider = Provider.of(context, listen: false); - tileProvider.setOnTilesCachedCallback(_onTilesCached); - _refreshCamerasFromProvider(); }); } @@ -75,17 +75,10 @@ class _MapViewState extends State { @override void dispose() { _positionSub?.cancel(); - _debounce.dispose(); + _cameraDebounce.dispose(); + _tileDebounce.dispose(); _cameraProvider.removeListener(_onCamerasUpdated); - - // Clean up tile refresh callback - try { - final tileProvider = Provider.of(context, listen: false); - tileProvider.setOnTilesCachedCallback(null); - } catch (e) { - // Context might be disposed already - that's okay - } - + _tileHttpClient.close(); super.dispose(); } @@ -93,14 +86,7 @@ class _MapViewState extends State { if (mounted) setState(() {}); } - void _onTilesCached() { - // When new tiles are cached, just trigger a widget rebuild - // This should cause the TileLayer to re-render with cached tiles - debugPrint('[MapView] Tile cached callback triggered, calling setState'); - if (mounted) { - setState(() {}); - } - } + void _refreshCamerasFromProvider() { final appState = context.read(); @@ -178,6 +164,19 @@ class _MapViewState extends State { return ids1.length == ids2.length && ids1.containsAll(ids2); } + /// Calculate approximate distance between two LatLng points in meters + double _distanceInMeters(LatLng point1, LatLng point2) { + const double earthRadius = 6371000; // Earth radius in meters + final dLat = (point2.latitude - point1.latitude) * (3.14159 / 180); + final dLon = (point2.longitude - point1.longitude) * (3.14159 / 180); + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(point1.latitude * (3.14159 / 180)) * + math.cos(point2.latitude * (3.14159 / 180)) * + math.sin(dLon / 2) * math.sin(dLon / 2); + final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + return earthRadius * c; + } + @override Widget build(BuildContext context) { final appState = context.watch(); @@ -197,15 +196,6 @@ class _MapViewState extends State { }); } - // Check if offline mode changed and force complete map rebuild - final currentOfflineMode = appState.offlineMode; - if (_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode) { - // Offline mode changed - increment counter to force FlutterMap rebuild - _mapRebuildCounter++; - debugPrint('[MapView] Offline mode changed, forcing map rebuild #$_mapRebuildCounter'); - } - _lastOfflineMode = currentOfflineMode; - // Seed add‑mode target once, after first controller center is available. if (session != null && session.target == null) { try { @@ -254,7 +244,7 @@ class _MapViewState extends State { return Stack( children: [ FlutterMap( - key: ValueKey('map_rebuild_$_mapRebuildCounter'), + key: ValueKey('map_offline_${appState.offlineMode}'), mapController: _controller, options: MapOptions( initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194), @@ -267,43 +257,39 @@ class _MapViewState extends State { appState.updateSession(target: pos.center); } - // Simple approach: cancel tiles on ANY significant view change - final tileProvider = Provider.of(context, listen: false); - tileProvider.cancelAllTileRequests(); + // TODO: Re-enable smart queue clearing once we debug tile loading issues + // For now, let flutter_map handle its own request management + /* + // Only clear tile queue on significant movement changes + final currentZoom = pos.zoom; + final currentCenter = pos.center; + final zoomChanged = _lastZoom == null || (currentZoom - _lastZoom!).abs() > 0.1; + final centerChanged = _lastCenter == null || + _distanceInMeters(_lastCenter!, currentCenter) > 100; // 100m threshold - // Request more cameras on any map movement/zoom at valid zoom level - // This ensures cameras load even when zooming without panning (like with zoom buttons) + if (zoomChanged || centerChanged) { + _tileDebounce(() { + debugPrint('[MapView] Significant map change - clearing stale tile requests'); + _tileHttpClient.clearTileQueue(); + }); + _lastZoom = currentZoom; + _lastCenter = currentCenter; + } + */ + + // Request more cameras on any map movement/zoom at valid zoom level (slower debounce) if (pos.zoom >= 10) { - _debounce(_refreshCamerasFromProvider); + _cameraDebounce(_refreshCamerasFromProvider); } }, ), children: [ TileLayer( - tileProvider: Provider.of(context), - urlTemplate: 'unused-{z}-{x}-{y}', - tileSize: 256, - tileBuilder: (ctx, tileWidget, tileImage) { - try { - final str = tileImage.toString(); - final regex = RegExp(r'TileCoordinate\((\d+), (\d+), (\d+)\)'); - final match = regex.firstMatch(str); - if (match != null) { - final x = match.group(1); - final y = match.group(2); - final z = match.group(3); - final key = '$z/$x/$y'; - final bytes = TileProviderWithCache.tileCache[key]; - if (bytes != null && bytes.isNotEmpty) { - return Image.memory(bytes, gaplessPlayback: true, fit: BoxFit.cover); - } - } - return tileWidget; - } catch (e) { - print('tileBuilder error: $e for tileImage: ${tileImage.toString()}'); - return tileWidget; - } - }, + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.stopflock.flock_map_app', + tileProvider: NetworkTileProvider( + httpClient: _tileHttpClient, + ), ), cameraLayers, // Built-in scale bar from flutter_map diff --git a/lib/widgets/tile_provider_with_cache.dart b/lib/widgets/tile_provider_with_cache.dart deleted file mode 100644 index 56fb2a9..0000000 --- a/lib/widgets/tile_provider_with_cache.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'dart:typed_data'; -import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import '../services/map_data_provider.dart'; -import '../services/map_data_submodules/tiles_from_osm.dart'; - -/// In-memory tile cache and async provider for custom tiles. -class TileProviderWithCache extends TileProvider with ChangeNotifier { - static final Map _tileCache = {}; - static Map get tileCache => _tileCache; - - bool _disposed = false; - int _disposeCount = 0; - VoidCallback? _onTilesCachedCallback; - - TileProviderWithCache(); - - /// Set a callback to be called when tiles are cached (used by MapView for refresh) - void setOnTilesCachedCallback(VoidCallback? callback) { - _onTilesCachedCallback = callback; - } - - /// Cancel ALL pending tile requests - delegates to OSM tile fetcher - void cancelAllTileRequests() { - clearOSMTileQueue(); // This handles all the cancellation logic - debugPrint('[TileProviderWithCache] Cancelled all tile requests'); - } - - @override - void dispose() { - _disposeCount++; - - // If already disposed, just silently return (common during FlutterMap rebuilds) - if (_disposed) { - debugPrint('[TileProviderWithCache] Already disposed (call #$_disposeCount) - ignoring'); - return; - } - - debugPrint('[TileProviderWithCache] Disposing (call #$_disposeCount)'); - _disposed = true; - - // Safely call super.dispose() with error handling - try { - super.dispose(); - } catch (e) { - debugPrint('[TileProviderWithCache] Error during disposal: $e'); - // Continue execution - disposal errors shouldn't crash the app - } - } - - @override - ImageProvider getImage(TileCoordinates coords, TileLayer options, {MapSource source = MapSource.auto}) { - final key = '${coords.z}/${coords.x}/${coords.y}'; - - if (_tileCache.containsKey(key)) { - final bytes = _tileCache[key]!; - return MemoryImage(bytes); - } else { - _fetchAndCacheTile(coords, key, source: source); - // Always return a placeholder until the real tile is cached - return const AssetImage('assets/transparent_1x1.png'); - } - } - - static void clearCache() { - _tileCache.clear(); - } - - void _fetchAndCacheTile(TileCoordinates coords, String key, {MapSource source = MapSource.auto}) async { - // Don't fire multiple fetches for the same tile simultaneously - if (_tileCache.containsKey(key)) return; - - try { - final bytes = await MapDataProvider().getTile( - z: coords.z, x: coords.x, y: coords.y, source: source, - ); - if (bytes.isNotEmpty) { - _tileCache[key] = Uint8List.fromList(bytes); - // Only notify listeners if not disposed and still mounted - if (!_disposed && hasListeners) { - notifyListeners(); // This updates any listening widgets - } - // Trigger map refresh callback to force tile re-rendering - debugPrint('[TileProviderWithCache] Tile cached: $key, calling refresh callback'); - _onTilesCachedCallback?.call(); - } - // If bytes were empty, don't cache (will re-attempt next time) - } catch (e) { - // Cancelled requests will throw exceptions from fetchOSMTile(), just ignore them - if (e.toString().contains('cancelled')) { - debugPrint('[TileProviderWithCache] Tile request was cancelled: $key'); - } - // Don't cache failed tiles regardless of reason - } - } -}