Network status indicator should only respect the latest / current request. Others finish in background. Replace stupid bools with an enum to track state. Be smarter about split requests.

This commit is contained in:
stopflock
2026-01-31 17:21:31 -06:00
parent d095736078
commit 83d7814fb6
14 changed files with 371 additions and 284 deletions

118
NETWORK_STATUS_REFACTOR.md Normal file
View File

@@ -0,0 +1,118 @@
# Network Status Refactor - v2.6.1
## Overview
Completely rewrote the network status indicator system to use a simple enum-based approach instead of multiple boolean flags and complex timer management.
## Key Changes
### Before (Complex)
- Multiple boolean flags: `_overpassHasIssues`, `_isWaitingForData`, `_isTimedOut`, etc.
- Complex state reconciliation logic in `currentStatus` getter
- Multiple independent timers that could conflict
- Both foreground and background requests reporting status
- Inconsistent state transitions
### After (Brutalist)
- Single enum: `NetworkRequestStatus` with 8 clear states
- Simple `_setStatus()` method handles all transitions and timers
- Only user-initiated requests report status (background requests ignored)
- Clear state ownership and no conflicting updates
## New Status States
```dart
enum NetworkRequestStatus {
idle, // No active requests (default, no indicator shown)
loading, // Initial request in progress
splitting, // Request being split due to limits/timeouts
success, // Data loaded successfully (auto-clears in 2s)
timeout, // Request timed out (auto-clears in 5s)
rateLimited, // API rate limited (auto-clears in 2min)
noData, // No offline data available (auto-clears in 3s)
error, // Other network errors (auto-clears in 5s)
}
```
## Behavior Changes
### Request Splitting
- When a request needs to be split due to node limits or timeouts:
- Status transitions: `loading``splitting``success`
- Only the top-level (original) request manages status
- Sub-requests (quadrants) complete silently
### Background vs User-Initiated Requests
- **User-initiated**: Pan/zoom actions, manual refresh - report status
- **Background**: Pre-fetch, cache warming - no status reporting
- Only one user-initiated request per area allowed at a time
### Error Handling
- Rate limits: Clear red "Rate limited by server" with 2min timeout
- Network errors: Clear red "Network error" with 5s timeout
- Timeouts: Orange "Request timed out" with 5s timeout
- No data: Grey "No offline data" with 3s timeout
## Files Modified
### Core Implementation
- `lib/services/network_status.dart` - Complete rewrite (100+ lines → 60 lines)
- `lib/widgets/network_status_indicator.dart` - Updated to use new enum
- `lib/services/node_data_manager.dart` - Simplified status reporting logic
### Status Reporting Cleanup
- `lib/services/map_data_submodules/nodes_from_osm_api.dart` - Removed independent status reporting
### Localization
- Added new strings to all language files:
- `networkStatus.rateLimited`
- `networkStatus.networkError`
### Documentation
- `assets/changelog.json` - Added v2.6.1 entry
- `test/services/network_status_test.dart` - Basic unit tests
## Testing Checklist
### Basic Functionality
- [ ] Initial app load shows no network indicator
- [ ] Pan/zoom to new area shows "Loading surveillance data..."
- [ ] Successful load shows "Surveillance data loaded" briefly (2s)
- [ ] Switch to offline mode, then pan - should show no indicator (instant data)
### Error States
- [ ] Poor network → should show "Network error" (red, 5s timeout)
- [ ] Dense area that requires splitting → "Surveillance data slow" (orange)
- [ ] Offline area with no surveillance data → "No offline data" (grey, 3s)
### Background vs Foreground
- [ ] Background requests (pre-fetch) should not affect indicator
- [ ] Only user pan/zoom actions should trigger status updates
- [ ] Multiple quick pan actions should not create conflicting status
### Split Requests
- [ ] In very dense areas (SF, NYC), splitting should work correctly
- [ ] Status should transition: loading → splitting → success
- [ ] All quadrants must complete before showing success
### Mode Switches
- [ ] Online → Offline: Any ongoing requests should not interfere
- [ ] Production → Sandbox: Should work with new request logic
- [ ] Manual refresh should reset and restart status properly
## Potential Issues to Watch
1. **Timer disposal**: Ensure timers are properly cancelled when app backgrounds
2. **Rapid status changes**: Quick pan actions shouldn't create flicker
3. **Split request coordination**: Verify all sub-requests complete properly
4. **Offline mode integration**: Status should be silent for instant offline data
## Code Quality Improvements
- **Reduced complexity**: 8 enum states vs 5+ boolean combinations
- **Single responsibility**: Each method does one clear thing
- **Brutalist approach**: Simple, explicit, easy to understand
- **Better debugging**: Clear state transitions with logging
- **Fewer race conditions**: Single timer per status type
This refactor dramatically simplifies the network status system while maintaining all existing functionality and improving reliability.

View File

@@ -1,4 +1,11 @@
{
"2.6.1": {
"content": [
"• Simplified network status indicator - cleaner state management",
"• Improved error handling for surveillance data requests",
"• Better status reporting for background vs. user-initiated requests"
]
},
"2.6.0": {
"content": [
"• Fix slow node loading, offline node loading",

View File

@@ -418,7 +418,9 @@
"timedOut": "Anfrage Zeitüberschreitung",
"noData": "Keine Offline-Daten",
"success": "Überwachungsdaten geladen",
"nodeDataSlow": "Überwachungsdaten langsam"
"nodeDataSlow": "Überwachungsdaten langsam",
"rateLimited": "Server-Limitierung",
"networkError": "Netzwerkfehler"
},
"nodeLimitIndicator": {
"message": "Zeige {rendered} von {total} Geräten",

View File

@@ -455,7 +455,9 @@
"timedOut": "Request timed out",
"noData": "No offline data",
"success": "Surveillance data loaded",
"nodeDataSlow": "Surveillance data slow"
"nodeDataSlow": "Surveillance data slow",
"rateLimited": "Rate limited by server",
"networkError": "Network error"
},
"nodeLimitIndicator": {
"message": "Showing {rendered} of {total} devices",

View File

@@ -455,7 +455,9 @@
"timedOut": "Solicitud agotada",
"noData": "Sin datos sin conexión",
"success": "Datos de vigilancia cargados",
"nodeDataSlow": "Datos de vigilancia lentos"
"nodeDataSlow": "Datos de vigilancia lentos",
"rateLimited": "Limitado por el servidor",
"networkError": "Error de red"
},
"nodeLimitIndicator": {
"message": "Mostrando {rendered} de {total} dispositivos",

View File

@@ -455,7 +455,9 @@
"timedOut": "Demande expirée",
"noData": "Aucune donnée hors ligne",
"success": "Données de surveillance chargées",
"nodeDataSlow": "Données de surveillance lentes"
"nodeDataSlow": "Données de surveillance lentes",
"rateLimited": "Limité par le serveur",
"networkError": "Erreur réseau"
},
"nodeLimitIndicator": {
"message": "Affichage de {rendered} sur {total} appareils",

View File

@@ -455,7 +455,9 @@
"timedOut": "Richiesta scaduta",
"noData": "Nessun dato offline",
"success": "Dati di sorveglianza caricati",
"nodeDataSlow": "Dati di sorveglianza lenti"
"nodeDataSlow": "Dati di sorveglianza lenti",
"rateLimited": "Limitato dal server",
"networkError": "Errore di rete"
},
"nodeLimitIndicator": {
"message": "Mostra {rendered} di {total} dispositivi",

View File

@@ -455,7 +455,9 @@
"timedOut": "Solicitação expirada",
"noData": "Nenhum dado offline",
"success": "Dados de vigilância carregados",
"nodeDataSlow": "Dados de vigilância lentos"
"nodeDataSlow": "Dados de vigilância lentos",
"rateLimited": "Limitado pelo servidor",
"networkError": "Erro de rede"
},
"nodeLimitIndicator": {
"message": "Mostrando {rendered} de {total} dispositivos",

View File

@@ -455,7 +455,9 @@
"timedOut": "请求超时",
"noData": "无离线数据",
"success": "监控数据已加载",
"nodeDataSlow": "监控数据缓慢"
"nodeDataSlow": "监控数据缓慢",
"rateLimited": "服务器限流",
"networkError": "网络错误"
},
"nodeLimitIndicator": {
"message": "显示 {rendered} / {total} 设备",

View File

@@ -20,9 +20,6 @@ Future<List<OsmNode>> fetchOsmApiNodes({
}) async {
if (profiles.isEmpty) return [];
// Check if this is a user-initiated fetch (indicated by loading state)
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
try {
final nodes = await _fetchFromOsmApi(
bounds: bounds,
@@ -31,22 +28,8 @@ Future<List<OsmNode>> fetchOsmApiNodes({
maxResults: maxResults,
);
// Only report success at the top level if this was user-initiated
if (wasUserInitiated) {
NetworkStatus.instance.setSuccess();
}
return nodes;
} catch (e) {
// Only report errors at the top level if this was user-initiated
if (wasUserInitiated) {
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
NetworkStatus.instance.setTimeoutError();
} else {
NetworkStatus.instance.setNetworkError();
}
}
debugPrint('[fetchOsmApiNodes] OSM API operation failed: $e');
return [];
}

View File

@@ -1,225 +1,117 @@
import 'package:flutter/material.dart';
import 'dart:async';
import '../app_state.dart';
enum NetworkIssueType { overpassApi }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
/// Simple enum-based network status for surveillance data requests.
/// Only tracks the latest user-initiated request - background requests are ignored.
enum NetworkRequestStatus {
idle, // No active requests
loading, // Request in progress
splitting, // Request being split due to limits/timeouts
success, // Data loaded successfully
timeout, // Request timed out
rateLimited, // API rate limited
noData, // No offline data available
error, // Other network errors
}
class NetworkStatus extends ChangeNotifier {
static final NetworkStatus instance = NetworkStatus._();
NetworkStatus._();
bool _overpassHasIssues = false;
bool _isWaitingForData = false;
bool _isTimedOut = false;
bool _hasNoData = false;
bool _hasSuccess = false;
int _recentOfflineMisses = 0;
Timer? _overpassRecoveryTimer;
Timer? _noDataResetTimer;
Timer? _successResetTimer;
// Getters
bool get hasAnyIssues => _overpassHasIssues;
bool get overpassHasIssues => _overpassHasIssues;
bool get isWaitingForData => _isWaitingForData;
bool get isTimedOut => _isTimedOut;
bool get hasNoData => _hasNoData;
bool get hasSuccess => _hasSuccess;
NetworkRequestStatus _status = NetworkRequestStatus.idle;
Timer? _autoResetTimer;
NetworkStatusType get currentStatus {
// Simple single-path status logic
if (hasAnyIssues) return NetworkStatusType.issues;
if (_isWaitingForData) return NetworkStatusType.waiting;
if (_isTimedOut) return NetworkStatusType.timedOut;
if (_hasNoData) return NetworkStatusType.noData;
if (_hasSuccess) return NetworkStatusType.success;
return NetworkStatusType.ready;
}
NetworkIssueType? get currentIssueType {
if (_overpassHasIssues) return NetworkIssueType.overpassApi;
return null;
}
/// Report Overpass API issues
void reportOverpassIssue() {
if (!_overpassHasIssues) {
_overpassHasIssues = true;
notifyListeners();
debugPrint('[NetworkStatus] Overpass API issues detected');
/// Current network status
NetworkRequestStatus get status => _status;
/// Set status and handle auto-reset timers
void _setStatus(NetworkRequestStatus newStatus) {
if (_status == newStatus) return;
_status = newStatus;
_autoResetTimer?.cancel();
// Auto-reset certain statuses after a delay
switch (newStatus) {
case NetworkRequestStatus.success:
_autoResetTimer = Timer(const Duration(seconds: 2), () {
_setStatus(NetworkRequestStatus.idle);
});
break;
case NetworkRequestStatus.timeout:
case NetworkRequestStatus.error:
_autoResetTimer = Timer(const Duration(seconds: 5), () {
_setStatus(NetworkRequestStatus.idle);
});
break;
case NetworkRequestStatus.noData:
_autoResetTimer = Timer(const Duration(seconds: 3), () {
_setStatus(NetworkRequestStatus.idle);
});
break;
case NetworkRequestStatus.rateLimited:
_autoResetTimer = Timer(const Duration(minutes: 2), () {
_setStatus(NetworkRequestStatus.idle);
});
break;
default:
// No auto-reset for idle, loading, splitting
break;
}
// Reset recovery timer
_overpassRecoveryTimer?.cancel();
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
_overpassHasIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] Overpass API issues cleared');
});
notifyListeners();
}
/// Report successful operations to potentially clear issues faster
void reportOverpassSuccess() {
if (_overpassHasIssues) {
// Quietly clear - don't log routine success
_overpassHasIssues = false;
_overpassRecoveryTimer?.cancel();
notifyListeners();
}
}
/// Report that requests are taking longer than usual (splitting, backoffs, etc.)
void reportSlowProgress() {
if (!_overpassHasIssues) {
_overpassHasIssues = true;
_isWaitingForData = false; // Transition from waiting to slow progress
notifyListeners();
debugPrint('[NetworkStatus] Surveillance data requests taking longer than usual');
}
// Reset recovery timer - we'll clear this when the operation actually completes
_overpassRecoveryTimer?.cancel();
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
_overpassHasIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] Slow progress status cleared');
});
}
/// Set waiting status (show when loading surveillance data)
void setWaiting() {
// Clear any previous timeout/no-data state when starting new wait
_isTimedOut = false;
_hasNoData = false;
_recentOfflineMisses = 0;
_noDataResetTimer?.cancel();
if (!_isWaitingForData) {
_isWaitingForData = true;
notifyListeners();
}
/// Start loading surveillance data
void setLoading() {
debugPrint('[NetworkStatus] Loading surveillance data');
_setStatus(NetworkRequestStatus.loading);
}
/// Show success status briefly when data loads
/// Request is being split due to complexity/limits
void setSplitting() {
debugPrint('[NetworkStatus] Splitting request due to complexity');
_setStatus(NetworkRequestStatus.splitting);
}
/// Data loaded successfully
void setSuccess() {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = true;
_recentOfflineMisses = 0;
_noDataResetTimer?.cancel();
notifyListeners();
// Auto-clear success status after 2 seconds
_successResetTimer?.cancel();
_successResetTimer = Timer(const Duration(seconds: 2), () {
if (_hasSuccess) {
_hasSuccess = false;
notifyListeners();
}
});
}
/// Show no-data status briefly when tiles aren't available
void setNoData() {
_isWaitingForData = false;
_isTimedOut = false;
_hasSuccess = false;
_hasNoData = true;
_successResetTimer?.cancel();
notifyListeners();
// Auto-clear no-data status after 2 seconds
_noDataResetTimer?.cancel();
_noDataResetTimer = Timer(const Duration(seconds: 2), () {
if (_hasNoData) {
_hasNoData = false;
notifyListeners();
}
});
}
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
void clearWaiting() {
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = false;
_recentOfflineMisses = 0;
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
}
debugPrint('[NetworkStatus] Surveillance data loaded successfully');
_setStatus(NetworkRequestStatus.success);
}
/// Set timeout error state
void setTimeoutError() {
_isWaitingForData = false;
_isTimedOut = true;
_hasNoData = false;
_hasSuccess = false;
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
/// Request timed out
void setTimeout() {
debugPrint('[NetworkStatus] Request timed out');
// Auto-clear timeout after 5 seconds
Timer(const Duration(seconds: 5), () {
if (_isTimedOut) {
_isTimedOut = false;
notifyListeners();
}
});
_setStatus(NetworkRequestStatus.timeout);
}
/// Set network error state (rate limits, connection issues, etc.)
void setNetworkError() {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = false;
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
// Use existing issue reporting system
reportOverpassIssue();
/// Rate limited by API
void setRateLimited() {
debugPrint('[NetworkStatus] Rate limited by API');
_setStatus(NetworkRequestStatus.rateLimited);
}
/// No offline data available
void setNoData() {
debugPrint('[NetworkStatus] No offline data available');
_setStatus(NetworkRequestStatus.noData);
}
/// Network or other error
void setError() {
debugPrint('[NetworkStatus] Network error occurred');
_setStatus(NetworkRequestStatus.error);
}
/// Report that a tile was not available offline
void reportOfflineMiss() {
_recentOfflineMisses++;
debugPrint('[NetworkStatus] Offline miss #$_recentOfflineMisses');
// If we get several misses in a short time, show "no data" status
if (_recentOfflineMisses >= 3 && !_hasNoData) {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = true;
notifyListeners();
debugPrint('[NetworkStatus] No offline data available for this area');
}
// Reset the miss counter after some time
_noDataResetTimer?.cancel();
_noDataResetTimer = Timer(const Duration(seconds: 5), () {
_recentOfflineMisses = 0;
});
/// Clear status (force to idle)
void clear() {
_setStatus(NetworkRequestStatus.idle);
}
@override
void dispose() {
_overpassRecoveryTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
_autoResetTimer?.cancel();
super.dispose();
}
}

View File

@@ -26,9 +26,8 @@ class NodeDataManager extends ChangeNotifier {
final OverpassService _overpassService = OverpassService();
final NodeSpatialCache _cache = NodeSpatialCache();
// Track ongoing requests and which one is "primary" for status reporting
final Set<String> _activeRequests = <String>{};
String? _primaryRequestKey; // The latest request that should drive NetworkStatus
// Track ongoing user-initiated requests for status reporting
final Set<String> _userInitiatedRequests = <String>{};
/// Get nodes for the given bounds and profiles.
/// Returns cached data immediately if available, otherwise fetches from appropriate source.
@@ -44,7 +43,7 @@ class NodeDataManager extends ChangeNotifier {
if (AppState.instance.offlineMode) {
// Clear any existing loading states since offline data is instant
if (isUserInitiated) {
NetworkStatus.instance.clearWaiting();
NetworkStatus.instance.clear();
}
if (uploadMode == UploadMode.sandbox) {
@@ -63,11 +62,16 @@ class NodeDataManager extends ChangeNotifier {
notifyListeners();
}
// Show brief success for user-initiated offline loads
// Show brief success for user-initiated offline loads with data
if (isUserInitiated && offlineNodes.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setSuccess();
});
} else if (isUserInitiated && offlineNodes.isEmpty) {
// Show no data briefly for offline areas with no surveillance devices
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setNoData();
});
}
return offlineNodes;
@@ -92,40 +96,35 @@ class NodeDataManager extends ChangeNotifier {
}
// Not cached - need to fetch
// Create a unique key for this request to prevent duplicates
final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode';
final bool isDuplicate = _activeRequests.contains(requestKey);
final bool isPrimaryRequest = isUserInitiated;
if (isDuplicate && !isPrimaryRequest) {
debugPrint('[NodeDataManager] Background request already in progress for this area, returning cached data');
// Only allow one user-initiated request per area at a time
if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) {
debugPrint('[NodeDataManager] User request already in progress for this area');
return _cache.getNodesFor(bounds);
}
// Set this as primary request if user-initiated (most recent)
if (isPrimaryRequest) {
_primaryRequestKey = requestKey;
NetworkStatus.instance.setWaiting();
debugPrint('[NodeDataManager] Starting PRIMARY request (${_activeRequests.length + 1} total active)');
// Start status tracking for user-initiated requests only
if (isUserInitiated) {
_userInitiatedRequests.add(requestKey);
NetworkStatus.instance.setLoading();
debugPrint('[NodeDataManager] Starting user-initiated request');
} else {
debugPrint('[NodeDataManager] Starting background request (${_activeRequests.length + 1} total active)');
debugPrint('[NodeDataManager] Starting background request (no status reporting)');
}
_activeRequests.add(requestKey);
try {
final nodes = await fetchWithSplitting(bounds, profiles);
final nodes = await fetchWithSplitting(bounds, profiles, isUserInitiated: isUserInitiated);
// Don't set success immediately - wait for UI to render the nodes
// Update cache and notify listeners
notifyListeners();
// Set success after the next frame renders, but only for primary requests
if (isPrimaryRequest && _primaryRequestKey == requestKey) {
// Set success after the next frame renders, but only for user-initiated requests
if (isUserInitiated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
NetworkStatus.instance.setSuccess();
});
debugPrint('[NodeDataManager] PRIMARY request completed successfully');
} else {
debugPrint('[NodeDataManager] Background request completed successfully');
debugPrint('[NodeDataManager] User-initiated request completed successfully');
}
return nodes;
@@ -133,25 +132,23 @@ class NodeDataManager extends ChangeNotifier {
} catch (e) {
debugPrint('[NodeDataManager] Fetch failed: $e');
// Only report errors for primary requests to avoid status confusion
if (isPrimaryRequest && _primaryRequestKey == requestKey) {
// Only report errors for user-initiated requests
if (isUserInitiated) {
if (e is RateLimitError) {
NetworkStatus.instance.reportOverpassIssue();
NetworkStatus.instance.setRateLimited();
} else if (e.toString().contains('timeout')) {
NetworkStatus.instance.setTimeout();
} else {
NetworkStatus.instance.setNetworkError();
NetworkStatus.instance.setError();
}
debugPrint('[NodeDataManager] PRIMARY request failed: $e');
} else {
debugPrint('[NodeDataManager] Background request failed: $e');
debugPrint('[NodeDataManager] User-initiated request failed: $e');
}
// Return whatever we have in cache for this area
return _cache.getNodesFor(bounds);
} finally {
_activeRequests.remove(requestKey);
// Clear primary key if this was the primary request
if (_primaryRequestKey == requestKey) {
_primaryRequestKey = null;
if (isUserInitiated) {
_userInitiatedRequests.remove(requestKey);
}
}
}
@@ -161,6 +158,7 @@ class NodeDataManager extends ChangeNotifier {
LatLngBounds bounds,
List<NodeProfile> profiles, {
int splitDepth = 0,
bool isUserInitiated = false,
}) async {
const maxSplitDepth = 3; // 4^3 = 64 max sub-areas
@@ -185,9 +183,13 @@ class NodeDataManager extends ChangeNotifier {
}
debugPrint('[NodeDataManager] Splitting area (depth: $splitDepth)');
NetworkStatus.instance.reportSlowProgress();
return _fetchSplitAreas(bounds, profiles, splitDepth + 1);
// Only report splitting status for user-initiated requests
if (isUserInitiated && splitDepth == 0) {
NetworkStatus.instance.setSplitting();
}
return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated);
} on RateLimitError {
// Rate limited - wait and return empty
@@ -201,14 +203,20 @@ class NodeDataManager extends ChangeNotifier {
Future<List<OsmNode>> _fetchSplitAreas(
LatLngBounds bounds,
List<NodeProfile> profiles,
int splitDepth,
) async {
int splitDepth, {
bool isUserInitiated = false,
}) async {
final quadrants = _splitBounds(bounds);
final allNodes = <OsmNode>[];
for (final quadrant in quadrants) {
try {
final nodes = await fetchWithSplitting(quadrant, profiles, splitDepth: splitDepth);
final nodes = await fetchWithSplitting(
quadrant,
profiles,
splitDepth: splitDepth,
isUserInitiated: isUserInitiated,
);
allNodes.addAll(nodes);
} catch (e) {
debugPrint('[NodeDataManager] Quadrant fetch failed: $e');
@@ -276,9 +284,9 @@ class NodeDataManager extends ChangeNotifier {
UploadMode uploadMode = UploadMode.production,
}) async {
// Clear any cached data for this area
_cache.clear(); // Simple: clear everything for now
_cache.clear();
// Re-fetch
// Re-fetch as user-initiated request
await getNodesFor(
bounds: bounds,
profiles: profiles,

View File

@@ -26,49 +26,55 @@ class NetworkStatusIndicator extends StatelessWidget {
IconData icon;
Color color;
switch (networkStatus.currentStatus) {
case NetworkStatusType.waiting:
switch (networkStatus.status) {
case NetworkRequestStatus.loading:
message = locService.t('networkStatus.loading');
icon = Icons.hourglass_empty;
color = Colors.blue;
break;
case NetworkStatusType.timedOut:
message = locService.t('networkStatus.timedOut');
icon = Icons.hourglass_disabled;
case NetworkRequestStatus.splitting:
message = locService.t('networkStatus.nodeDataSlow');
icon = Icons.camera_alt_outlined;
color = Colors.orange;
break;
case NetworkStatusType.noData:
message = locService.t('networkStatus.noData');
icon = Icons.cloud_off;
color = Colors.grey;
break;
case NetworkStatusType.success:
case NetworkRequestStatus.success:
message = locService.t('networkStatus.success');
icon = Icons.check_circle;
color = Colors.green;
break;
case NetworkStatusType.issues:
switch (networkStatus.currentIssueType) {
case NetworkIssueType.overpassApi:
message = locService.t('networkStatus.nodeDataSlow');
icon = Icons.camera_alt_outlined;
color = Colors.orange;
break;
default:
return const SizedBox.shrink();
}
case NetworkRequestStatus.timeout:
message = locService.t('networkStatus.timedOut');
icon = Icons.hourglass_disabled;
color = Colors.orange;
break;
case NetworkStatusType.ready:
case NetworkRequestStatus.rateLimited:
message = locService.t('networkStatus.rateLimited');
icon = Icons.speed;
color = Colors.red;
break;
case NetworkRequestStatus.noData:
message = locService.t('networkStatus.noData');
icon = Icons.cloud_off;
color = Colors.grey;
break;
case NetworkRequestStatus.error:
message = locService.t('networkStatus.networkError');
icon = Icons.error_outline;
color = Colors.red;
break;
case NetworkRequestStatus.idle:
return const SizedBox.shrink();
}
return Positioned(
top: top, // Position dynamically based on other indicators
top: top,
left: left,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),

View File

@@ -0,0 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import '../../lib/services/network_status.dart';
void main() {
group('NetworkStatus', () {
late NetworkStatus networkStatus;
setUp(() {
networkStatus = NetworkStatus.instance;
networkStatus.clear(); // Start clean for each test
});
test('starts with idle status', () {
expect(networkStatus.status, NetworkRequestStatus.idle);
});
test('transitions through loading states correctly', () {
networkStatus.setLoading();
expect(networkStatus.status, NetworkRequestStatus.loading);
networkStatus.setSplitting();
expect(networkStatus.status, NetworkRequestStatus.splitting);
networkStatus.setSuccess();
expect(networkStatus.status, NetworkRequestStatus.success);
});
test('handles error states correctly', () {
networkStatus.setTimeout();
expect(networkStatus.status, NetworkRequestStatus.timeout);
networkStatus.setRateLimited();
expect(networkStatus.status, NetworkRequestStatus.rateLimited);
networkStatus.setError();
expect(networkStatus.status, NetworkRequestStatus.error);
networkStatus.setNoData();
expect(networkStatus.status, NetworkRequestStatus.noData);
});
test('clear() resets to idle', () {
networkStatus.setError();
expect(networkStatus.status, NetworkRequestStatus.error);
networkStatus.clear();
expect(networkStatus.status, NetworkRequestStatus.idle);
});
test('auto-reset timers work (success)', () async {
networkStatus.setSuccess();
expect(networkStatus.status, NetworkRequestStatus.success);
// Wait for auto-reset (2 seconds + buffer)
await Future.delayed(const Duration(milliseconds: 2100));
expect(networkStatus.status, NetworkRequestStatus.idle);
});
});
}