From f6adffc84e69109df59ee80873da3ea211bbbc6f Mon Sep 17 00:00:00 2001 From: stopflock Date: Fri, 22 Aug 2025 23:44:49 -0500 Subject: [PATCH] fixes tile re-rendering after long rate limit periods without having to zoom/pan --- .../cameras_from_overpass.dart | 11 +++ .../map_data_submodules/tiles_from_osm.dart | 11 +++ lib/services/network_status.dart | 91 +++++++++++++++++++ lib/widgets/map_view.dart | 23 +++++ lib/widgets/network_status_indicator.dart | 77 ++++++++++++++++ lib/widgets/tile_provider_with_cache.dart | 8 ++ 6 files changed, 221 insertions(+) create mode 100644 lib/services/network_status.dart create mode 100644 lib/widgets/network_status_indicator.dart diff --git a/lib/services/map_data_submodules/cameras_from_overpass.dart b/lib/services/map_data_submodules/cameras_from_overpass.dart index 489ef2c..1cfe70a 100644 --- a/lib/services/map_data_submodules/cameras_from_overpass.dart +++ b/lib/services/map_data_submodules/cameras_from_overpass.dart @@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart'; import '../../models/camera_profile.dart'; import '../../models/osm_camera_node.dart'; import '../../app_state.dart'; +import '../network_status.dart'; /// Fetches cameras from the Overpass OSM API for the given bounds and profiles. /// If fetchAllPages is true, returns all possible cameras using multiple API calls (paging with pageSize). @@ -45,11 +46,13 @@ Future> camerasFromOverpass({ print('[camerasFromOverpass] Status: ${resp.statusCode}, Length: ${resp.body.length}'); if (resp.statusCode != 200) { print('[camerasFromOverpass] Overpass failed: ${resp.body}'); + NetworkStatus.instance.reportOverpassIssue(); return []; } final data = jsonDecode(resp.body) as Map; final elements = data['elements'] as List; print('[camerasFromOverpass] Retrieved elements: ${elements.length}'); + NetworkStatus.instance.reportOverpassSuccess(); return elements.whereType>().map((e) { return OsmCameraNode( id: e['id'], @@ -59,6 +62,14 @@ Future> camerasFromOverpass({ }).toList(); } catch (e) { print('[camerasFromOverpass] Overpass exception: $e'); + + // Report network issues on connection errors + if (e.toString().contains('Connection refused') || + e.toString().contains('Connection timed out') || + e.toString().contains('Connection reset')) { + NetworkStatus.instance.reportOverpassIssue(); + } + return []; } } diff --git a/lib/services/map_data_submodules/tiles_from_osm.dart b/lib/services/map_data_submodules/tiles_from_osm.dart index d5870a1..983fd8d 100644 --- a/lib/services/map_data_submodules/tiles_from_osm.dart +++ b/lib/services/map_data_submodules/tiles_from_osm.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; import 'package:flock_map_app/dev_config.dart'; +import '../network_status.dart'; /// Global semaphore to limit simultaneous tile fetches final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent @@ -33,13 +34,23 @@ Future> fetchOSMTile({ print('[fetchOSMTile] HTTP ${resp.statusCode} for $z/$x/$y, length=${resp.bodyBytes.length}'); if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) { print('[fetchOSMTile] SUCCESS $z/$x/$y'); + NetworkStatus.instance.reportOsmTileSuccess(); return resp.bodyBytes; } else { print('[fetchOSMTile] FAIL $z/$x/$y: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}'); + NetworkStatus.instance.reportOsmTileIssue(); throw HttpException('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}'); } } catch (e) { print('[fetchOSMTile] Exception $z/$x/$y: $e'); + + // Report network issues on connection errors + if (e.toString().contains('Connection refused') || + e.toString().contains('Connection timed out') || + e.toString().contains('Connection reset')) { + NetworkStatus.instance.reportOsmTileIssue(); + } + if (attempt >= maxAttempts) { print("[fetchOSMTile] Failed for $z/$x/$y after $attempt attempts: $e"); rethrow; diff --git a/lib/services/network_status.dart b/lib/services/network_status.dart new file mode 100644 index 0000000..78088d7 --- /dev/null +++ b/lib/services/network_status.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; + +enum NetworkIssueType { osmTiles, overpassApi, both } + +class NetworkStatus extends ChangeNotifier { + static final NetworkStatus instance = NetworkStatus._(); + NetworkStatus._(); + + bool _osmTilesHaveIssues = false; + bool _overpassHasIssues = false; + Timer? _osmRecoveryTimer; + Timer? _overpassRecoveryTimer; + + // Getters + bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues; + bool get osmTilesHaveIssues => _osmTilesHaveIssues; + bool get overpassHasIssues => _overpassHasIssues; + + NetworkIssueType? get currentIssueType { + if (_osmTilesHaveIssues && _overpassHasIssues) return NetworkIssueType.both; + if (_osmTilesHaveIssues) return NetworkIssueType.osmTiles; + if (_overpassHasIssues) return NetworkIssueType.overpassApi; + return null; + } + + /// Report OSM tile server issues + void reportOsmTileIssue() { + if (!_osmTilesHaveIssues) { + _osmTilesHaveIssues = true; + notifyListeners(); + debugPrint('[NetworkStatus] OSM tile server issues detected'); + } + + // Reset recovery timer - if we keep getting errors, keep showing indicator + _osmRecoveryTimer?.cancel(); + _osmRecoveryTimer = Timer(const Duration(minutes: 2), () { + _osmTilesHaveIssues = false; + notifyListeners(); + debugPrint('[NetworkStatus] OSM tile server issues cleared'); + }); + } + + /// Report Overpass API issues + void reportOverpassIssue() { + if (!_overpassHasIssues) { + _overpassHasIssues = true; + notifyListeners(); + debugPrint('[NetworkStatus] Overpass API issues detected'); + } + + // Reset recovery timer + _overpassRecoveryTimer?.cancel(); + _overpassRecoveryTimer = Timer(const Duration(minutes: 2), () { + _overpassHasIssues = false; + notifyListeners(); + debugPrint('[NetworkStatus] Overpass API issues cleared'); + }); + } + + /// Report successful operations to potentially clear issues faster + void reportOsmTileSuccess() { + // Don't immediately clear on single success, but reduce recovery time + if (_osmTilesHaveIssues) { + _osmRecoveryTimer?.cancel(); + _osmRecoveryTimer = Timer(const Duration(seconds: 30), () { + _osmTilesHaveIssues = false; + notifyListeners(); + debugPrint('[NetworkStatus] OSM tile server issues cleared after success'); + }); + } + } + + void reportOverpassSuccess() { + if (_overpassHasIssues) { + _overpassRecoveryTimer?.cancel(); + _overpassRecoveryTimer = Timer(const Duration(seconds: 30), () { + _overpassHasIssues = false; + notifyListeners(); + debugPrint('[NetworkStatus] Overpass API issues cleared after success'); + }); + } + } + + @override + void dispose() { + _osmRecoveryTimer?.cancel(); + _overpassRecoveryTimer?.cancel(); + super.dispose(); + } +} \ No newline at end of file diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index e76d686..c19a101 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -63,6 +63,10 @@ class _MapViewState extends State { // Ensure initial overlays are fetched WidgetsBinding.instance.addPostFrameCallback((_) { + // Set up tile refresh callback + final tileProvider = Provider.of(context, listen: false); + tileProvider.setOnTilesCachedCallback(_onTilesCached); + _refreshCamerasFromProvider(); }); } @@ -72,6 +76,15 @@ class _MapViewState extends State { _positionSub?.cancel(); _debounce.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 + } + super.dispose(); } @@ -79,6 +92,14 @@ 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 + if (mounted) { + setState(() {}); + } + } + void _refreshCamerasFromProvider() { final appState = context.read(); LatLngBounds? bounds; @@ -159,6 +180,8 @@ class _MapViewState extends State { + + @override Widget build(BuildContext context) { final appState = context.watch(); diff --git a/lib/widgets/network_status_indicator.dart b/lib/widgets/network_status_indicator.dart new file mode 100644 index 0000000..55b4675 --- /dev/null +++ b/lib/widgets/network_status_indicator.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/network_status.dart'; + +class NetworkStatusIndicator extends StatelessWidget { + const NetworkStatusIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: NetworkStatus.instance, + child: Consumer( + builder: (context, networkStatus, child) { + if (!networkStatus.hasAnyIssues) { + return const SizedBox.shrink(); + } + + String message; + IconData icon; + Color color; + + switch (networkStatus.currentIssueType) { + case NetworkIssueType.osmTiles: + message = 'OSM tiles slow'; + icon = Icons.map_outlined; + color = Colors.orange; + break; + case NetworkIssueType.overpassApi: + message = 'Camera data slow'; + icon = Icons.camera_alt_outlined; + color = Colors.orange; + break; + case NetworkIssueType.both: + message = 'Network issues'; + icon = Icons.cloud_off_outlined; + color = Colors.red; + break; + default: + return const SizedBox.shrink(); + } + + return Positioned( + top: MediaQuery.of(context).padding.top + 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: color, + ), + const SizedBox(width: 4), + Text( + message, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/tile_provider_with_cache.dart b/lib/widgets/tile_provider_with_cache.dart index 4f3afcb..0b304d7 100644 --- a/lib/widgets/tile_provider_with_cache.dart +++ b/lib/widgets/tile_provider_with_cache.dart @@ -10,9 +10,15 @@ class TileProviderWithCache extends TileProvider with ChangeNotifier { 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; + } + @override void dispose() { _disposeCount++; @@ -67,6 +73,8 @@ class TileProviderWithCache extends TileProvider with ChangeNotifier { if (!_disposed && hasListeners) { notifyListeners(); // This updates any listening widgets } + // Trigger map refresh callback to force tile re-rendering + _onTilesCachedCallback?.call(); } // If bytes were empty, don't cache (will re-attempt next time) } catch (e) {