From ca68bd60597ad38b73613b8784397bd76f354cc7 Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 22 Oct 2025 11:56:01 -0500 Subject: [PATCH] fix network indicator, simplify overpass fetching --- lib/dev_config.dart | 9 +- lib/services/map_data_provider.dart | 19 ++- .../nodes_from_overpass.dart | 17 +- lib/services/network_status.dart | 146 ++++++------------ lib/services/prefetch_area_service.dart | 42 ++++- lib/services/simple_tile_service.dart | 5 +- lib/widgets/map_view.dart | 7 +- 7 files changed, 115 insertions(+), 130 deletions(-) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 64fb368..a2fc346 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -68,6 +68,7 @@ const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit +const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds // Follow-me mode smooth transitions const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600); @@ -80,11 +81,11 @@ const int kProximityAlertMaxDistance = 1000; // meters const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node // Tile fetch retry parameters (configurable backoff system) -const int kTileFetchMaxAttempts = 6; // Number of retry attempts before giving up -const int kTileFetchInitialDelayMs = 1000; // Base delay for first retry (1 second) +const int kTileFetchMaxAttempts = 16; // Number of retry attempts before giving up +const int kTileFetchInitialDelayMs = 500; // Base delay for first retry (1 second) const double kTileFetchBackoffMultiplier = 1.5; // Multiply delay by this each attempt -const int kTileFetchMaxDelayMs = 8000; // Cap delays at this value (8 seconds max) -const int kTileFetchRandomJitterMs = 500; // Random fuzz to add (0 to 500ms) +const int kTileFetchMaxDelayMs = 10000; // Cap delays at this value (8 seconds max) +const int kTileFetchRandomJitterMs = 250; // Random fuzz to add (0 to 500ms) // User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min) const int kMaxUserDownloadZoomSpan = 7; diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index b0cb654..cc31361 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -42,7 +42,6 @@ class MapDataProvider { UploadMode uploadMode = UploadMode.production, MapSource source = MapSource.auto, }) async { - try { final offline = AppState.instance.offlineMode; // Explicit remote request: error if offline, else always remote @@ -100,17 +99,21 @@ class MapDataProvider { maxNodes: AppState.instance.maxCameras, ); - // Check if we need to trigger a new pre-fetch - if (!preFetchService.isWithinPreFetchedArea(bounds, profiles, uploadMode)) { - // Outside pre-fetched area - trigger new pre-fetch but don't wait for it - debugPrint('[MapDataProvider] Outside pre-fetched area, triggering new pre-fetch'); + // Check if we need to trigger a new pre-fetch (spatial or temporal) + final needsFetch = !preFetchService.isWithinPreFetchedArea(bounds, profiles, uploadMode) || + preFetchService.isDataStale(); + + if (needsFetch) { + // Outside area OR data stale - start pre-fetch with loading state + debugPrint('[MapDataProvider] Starting pre-fetch with loading state'); + NetworkStatus.instance.setWaiting(); preFetchService.requestPreFetchIfNeeded( viewBounds: bounds, profiles: profiles, uploadMode: uploadMode, ); } else { - debugPrint('[MapDataProvider] Using existing pre-fetched area cache'); + debugPrint('[MapDataProvider] Using existing fresh pre-fetched area cache'); } // Apply rendering limit and warn if nodes are being excluded @@ -121,10 +124,6 @@ class MapDataProvider { return localNodes.take(maxNodes).toList(); } - } finally { - // Always report node completion, regardless of success or failure - NetworkStatus.instance.reportNodeComplete(); - } } /// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries) diff --git a/lib/services/map_data_submodules/nodes_from_overpass.dart b/lib/services/map_data_submodules/nodes_from_overpass.dart index 5fc7bcf..d14eacc 100644 --- a/lib/services/map_data_submodules/nodes_from_overpass.dart +++ b/lib/services/map_data_submodules/nodes_from_overpass.dart @@ -20,12 +20,16 @@ Future> fetchOverpassNodes({ UploadMode uploadMode = UploadMode.production, required int maxResults, }) async { + // Check if this is a user-initiated fetch (indicated by loading state) + final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting; + return _fetchOverpassNodesWithSplitting( bounds: bounds, profiles: profiles, uploadMode: uploadMode, maxResults: maxResults, splitDepth: 0, + wasUserInitiated: wasUserInitiated, ); } @@ -36,6 +40,7 @@ Future> _fetchOverpassNodesWithSplitting({ UploadMode uploadMode = UploadMode.production, required int maxResults, required int splitDepth, + required bool wasUserInitiated, }) async { if (profiles.isEmpty) return []; @@ -51,13 +56,22 @@ Future> _fetchOverpassNodesWithSplitting({ // Rate limits should NOT be split - just fail with extended backoff debugPrint('[fetchOverpassNodes] Rate limited - using extended backoff, not splitting'); + // Report error if user was waiting + if (wasUserInitiated) { + NetworkStatus.instance.setNetworkError(); + } + // Wait longer for rate limits before giving up entirely await Future.delayed(const Duration(seconds: 30)); - rethrow; // Let caller handle as a regular failure + return []; // Return empty rather than rethrowing } on OverpassNodeLimitException { // If we've hit max split depth, give up to avoid infinite recursion if (splitDepth >= maxSplitDepth) { debugPrint('[fetchOverpassNodes] Max split depth reached, giving up on area: $bounds'); + // Report timeout if this was user-initiated (can't split further) + if (wasUserInitiated) { + NetworkStatus.instance.setTimeoutError(); + } return []; } @@ -73,6 +87,7 @@ Future> _fetchOverpassNodesWithSplitting({ uploadMode: uploadMode, maxResults: 0, // No limit on individual quadrants to avoid double-limiting splitDepth: splitDepth + 1, + wasUserInitiated: wasUserInitiated, ); allNodes.addAll(nodes); } diff --git a/lib/services/network_status.dart b/lib/services/network_status.dart index 28397d8..329215b 100644 --- a/lib/services/network_status.dart +++ b/lib/services/network_status.dart @@ -6,8 +6,7 @@ import '../app_state.dart'; enum NetworkIssueType { osmTiles, overpassApi, both } enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success, nodeLimitReached } -/// Simple loading state for dual-source async operations (brutalist approach) -enum LoadingState { ready, waiting, success, timeout } + class NetworkStatus extends ChangeNotifier { static final NetworkStatus instance = NetworkStatus._(); @@ -28,13 +27,6 @@ class NetworkStatus extends ChangeNotifier { bool _nodeLimitReached = false; Timer? _nodeLimitResetTimer; - // New dual-source loading state (brutalist approach) - LoadingState _tileLoadingState = LoadingState.ready; - LoadingState _nodeLoadingState = LoadingState.ready; - Timer? _tileTimeoutTimer; - Timer? _nodeTimeoutTimer; - Timer? _successDisplayTimer; - // Getters bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues; bool get osmTilesHaveIssues => _osmTilesHaveIssues; @@ -45,22 +37,8 @@ class NetworkStatus extends ChangeNotifier { bool get hasSuccess => _hasSuccess; bool get nodeLimitReached => _nodeLimitReached; - // New dual-source getters (brutalist approach) - LoadingState get tileLoadingState => _tileLoadingState; - LoadingState get nodeLoadingState => _nodeLoadingState; - - /// Derive overall loading status from dual sources - bool get isDualSourceLoading => _tileLoadingState == LoadingState.waiting || _nodeLoadingState == LoadingState.waiting; - bool get isDualSourceTimeout => _tileLoadingState == LoadingState.timeout || _nodeLoadingState == LoadingState.timeout; - bool get isDualSourceSuccess => _tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success; - NetworkStatusType get currentStatus { - // Check new dual-source states first - if (isDualSourceTimeout) return NetworkStatusType.timedOut; - if (isDualSourceLoading) return NetworkStatusType.waiting; - if (isDualSourceSuccess) return NetworkStatusType.success; - - // Fall back to legacy states for compatibility + // Simple single-path status logic if (hasAnyIssues) return NetworkStatusType.issues; if (_isWaitingForData) return NetworkStatusType.waiting; if (_isTimedOut) return NetworkStatusType.timedOut; @@ -198,19 +176,57 @@ class NetworkStatus extends ChangeNotifier { /// Clear waiting/timeout/no-data status (legacy method for compatibility) void clearWaiting() { - if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) { + if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess || _nodeLimitReached) { _isWaitingForData = false; _isTimedOut = false; _hasNoData = false; _hasSuccess = false; + _nodeLimitReached = false; _recentOfflineMisses = 0; _waitingTimer?.cancel(); _noDataResetTimer?.cancel(); _successResetTimer?.cancel(); + _nodeLimitResetTimer?.cancel(); notifyListeners(); } } + /// Set timeout error state + void setTimeoutError() { + _isWaitingForData = false; + _isTimedOut = true; + _hasNoData = false; + _hasSuccess = false; + _waitingTimer?.cancel(); + _noDataResetTimer?.cancel(); + _successResetTimer?.cancel(); + notifyListeners(); + debugPrint('[NetworkStatus] Request timed out'); + + // Auto-clear timeout after 5 seconds + Timer(const Duration(seconds: 5), () { + if (_isTimedOut) { + _isTimedOut = false; + notifyListeners(); + } + }); + } + + /// Set network error state (rate limits, connection issues, etc.) + void setNetworkError() { + _isWaitingForData = false; + _isTimedOut = false; + _hasNoData = false; + _hasSuccess = false; + _waitingTimer?.cancel(); + _noDataResetTimer?.cancel(); + _successResetTimer?.cancel(); + + // Use existing issue reporting system + reportOverpassIssue(); + debugPrint('[NetworkStatus] Network error occurred'); + } + /// Show notification that node display limit was reached void reportNodeLimitReached(int totalNodes, int maxNodes) { _nodeLimitReached = true; @@ -251,81 +267,7 @@ class NetworkStatus extends ChangeNotifier { }); } - // New dual-source loading methods (brutalist approach) - - /// Start waiting for both tiles and nodes - void setDualSourceWaiting() { - _tileLoadingState = LoadingState.waiting; - _nodeLoadingState = LoadingState.waiting; - - // Set timeout timers for both - _tileTimeoutTimer?.cancel(); - _tileTimeoutTimer = Timer(const Duration(seconds: 8), () { - if (_tileLoadingState == LoadingState.waiting) { - _tileLoadingState = LoadingState.timeout; - debugPrint('[NetworkStatus] Tile loading timed out'); - notifyListeners(); - } - }); - - _nodeTimeoutTimer?.cancel(); - _nodeTimeoutTimer = Timer(const Duration(seconds: 8), () { - if (_nodeLoadingState == LoadingState.waiting) { - _nodeLoadingState = LoadingState.timeout; - debugPrint('[NetworkStatus] Node loading timed out'); - notifyListeners(); - } - }); - - notifyListeners(); - } - - /// Report tile loading completion - void reportTileComplete() { - if (_tileLoadingState == LoadingState.waiting) { - _tileLoadingState = LoadingState.success; - _tileTimeoutTimer?.cancel(); - _checkDualSourceComplete(); - } - } - - /// Report node loading completion - void reportNodeComplete() { - if (_nodeLoadingState == LoadingState.waiting) { - _nodeLoadingState = LoadingState.success; - _nodeTimeoutTimer?.cancel(); - _checkDualSourceComplete(); - } - } - - /// Check if both sources are complete and show success briefly - void _checkDualSourceComplete() { - if (_tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success) { - debugPrint('[NetworkStatus] Both tiles and nodes loaded successfully'); - notifyListeners(); - - // Auto-reset to ready after showing success briefly - _successDisplayTimer?.cancel(); - _successDisplayTimer = Timer(const Duration(seconds: 2), () { - _tileLoadingState = LoadingState.ready; - _nodeLoadingState = LoadingState.ready; - notifyListeners(); - }); - } else { - // Just notify if one completed but not both yet - notifyListeners(); - } - } - - /// Reset dual-source state to ready - void resetDualSourceState() { - _tileLoadingState = LoadingState.ready; - _nodeLoadingState = LoadingState.ready; - _tileTimeoutTimer?.cancel(); - _nodeTimeoutTimer?.cancel(); - _successDisplayTimer?.cancel(); - notifyListeners(); - } + @override void dispose() { @@ -333,9 +275,7 @@ class NetworkStatus extends ChangeNotifier { _overpassRecoveryTimer?.cancel(); _waitingTimer?.cancel(); _noDataResetTimer?.cancel(); - _tileTimeoutTimer?.cancel(); - _nodeTimeoutTimer?.cancel(); - _successDisplayTimer?.cancel(); + _successResetTimer?.cancel(); _nodeLimitResetTimer?.cancel(); super.dispose(); } diff --git a/lib/services/prefetch_area_service.dart b/lib/services/prefetch_area_service.dart index dfe701d..440cfbb 100644 --- a/lib/services/prefetch_area_service.dart +++ b/lib/services/prefetch_area_service.dart @@ -9,6 +9,8 @@ import '../app_state.dart'; import '../dev_config.dart'; import 'map_data_submodules/nodes_from_overpass.dart'; import 'node_cache.dart'; +import 'network_status.dart'; +import '../widgets/camera_provider_with_cache.dart'; /// Manages pre-fetching larger areas to reduce Overpass API calls. /// Uses zoom level 10 areas and automatically splits if hitting node limits. @@ -21,6 +23,7 @@ class PrefetchAreaService { LatLngBounds? _preFetchedArea; List? _preFetchedProfiles; UploadMode? _preFetchedUploadMode; + DateTime? _lastFetchTime; bool _preFetchInProgress = false; // Debounce timer to avoid rapid requests while user is panning @@ -52,7 +55,13 @@ class PrefetchAreaService { bounds.west >= _preFetchedArea!.west; } - /// Request pre-fetch for the given view bounds if not already covered. + /// Check if cached data is stale (older than configured refresh interval). + bool isDataStale() { + if (_lastFetchTime == null) return true; + return DateTime.now().difference(_lastFetchTime!).inSeconds > kDataRefreshIntervalSeconds; + } + + /// Request pre-fetch for the given view bounds if not already covered or if data is stale. /// Uses debouncing to avoid rapid requests while user is panning. void requestPreFetchIfNeeded({ required LatLngBounds viewBounds, @@ -65,12 +74,21 @@ class PrefetchAreaService { return; } - // Skip if current view is within pre-fetched area - if (isWithinPreFetchedArea(viewBounds, profiles, uploadMode)) { - debugPrint('[PrefetchAreaService] Current view within pre-fetched area, no fetch needed'); + // Check both spatial and temporal conditions + final isWithinArea = isWithinPreFetchedArea(viewBounds, profiles, uploadMode); + final isStale = isDataStale(); + + if (isWithinArea && !isStale) { + debugPrint('[PrefetchAreaService] Current view within fresh pre-fetched area, no fetch needed'); return; } + if (isStale) { + debugPrint('[PrefetchAreaService] Data is stale (>${kDataRefreshIntervalSeconds}s), refreshing'); + } else { + debugPrint('[PrefetchAreaService] Current view outside pre-fetched area, fetching larger area'); + } + // Cancel any pending debounced request _debounceTimer?.cancel(); @@ -115,13 +133,26 @@ class PrefetchAreaService { NodeCache.instance.addOrUpdate(nodes); } - // Store the pre-fetched area info + // Store the pre-fetched area info and timestamp _preFetchedArea = preFetchArea; _preFetchedProfiles = List.from(profiles); _preFetchedUploadMode = uploadMode; + _lastFetchTime = DateTime.now(); + + // Report completion to network status (only if user was waiting) + NetworkStatus.instance.setSuccess(); + + // Notify UI that cache has been updated with fresh data + CameraProviderWithCache.instance.refreshDisplay(); } catch (e) { debugPrint('[PrefetchAreaService] Pre-fetch failed: $e'); + // Report failure to network status (only if user was waiting) + if (e.toString().contains('timeout') || e.toString().contains('timed out')) { + NetworkStatus.instance.setTimeoutError(); + } else { + NetworkStatus.instance.setNetworkError(); + } // Don't update pre-fetched area info on failure } finally { _preFetchInProgress = false; @@ -155,6 +186,7 @@ class PrefetchAreaService { _preFetchedArea = null; _preFetchedProfiles = null; _preFetchedUploadMode = null; + _lastFetchTime = null; debugPrint('[PrefetchAreaService] Pre-fetched area cleared'); } diff --git a/lib/services/simple_tile_service.dart b/lib/services/simple_tile_service.dart index 2323759..9d926fa 100644 --- a/lib/services/simple_tile_service.dart +++ b/lib/services/simple_tile_service.dart @@ -86,7 +86,10 @@ class SimpleTileHttpClient extends http.BaseClient { // Decrement pending counter and report completion when all done _pendingTileRequests--; if (_pendingTileRequests == 0) { - NetworkStatus.instance.reportTileComplete(); + // Only report tile completion if we were in loading state (user-initiated) + if (NetworkStatus.instance.currentStatus == NetworkStatusType.waiting) { + NetworkStatus.instance.setSuccess(); + } } } } diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 1015767..b8ce682 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -558,9 +558,6 @@ class MapViewState extends State { appState.updateProvisionalPinLocation(pos.center); } - // Start dual-source waiting when map moves (user is expecting new tiles AND nodes) - NetworkStatus.instance.setDualSourceWaiting(); - // Clear tile queue on tile level changes OR significant panning final currentZoom = pos.zoom; final currentCenter = pos.center; @@ -595,9 +592,7 @@ class MapViewState extends State { if (pos.zoom >= minZoom) { _cameraDebounce(_refreshNodesFromProvider); } else { - // Skip nodes at low zoom - report immediate completion (brutalist approach) - NetworkStatus.instance.reportNodeComplete(); - + // Skip nodes at low zoom - no loading state needed // Show zoom warning if needed _showZoomWarningIfNeeded(context, pos.zoom, minZoom); }