diff --git a/lib/app_state.dart b/lib/app_state.dart index a208d638..ea42dbea 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -16,6 +16,7 @@ import 'models/search_result.dart'; import 'services/offline_area_service.dart'; import 'services/map_data_provider.dart'; import 'services/node_data_manager.dart'; +import 'services/node_spatial_cache.dart'; import 'services/tile_preview_service.dart'; import 'services/changelog_service.dart'; import 'services/operator_profile_service.dart'; @@ -160,6 +161,7 @@ class AppState extends ChangeNotifier { bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled; int get proximityAlertDistance => _settingsState.proximityAlertDistance; bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled; + bool get showCoverageOverlay => _settingsState.showCoverageOverlay; int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance; // Messages state @@ -239,10 +241,13 @@ class AppState extends ChangeNotifier { // Note: Re-auth check will be triggered from home screen after init - // Initialize OfflineAreaService to ensure offline areas are loaded - await OfflineAreaService().ensureInitialized(); - - // Preload offline nodes into cache for immediate display + // Initialize offline areas and node cache in parallel (independent) + await Future.wait([ + OfflineAreaService().ensureInitialized(), + NodeSpatialCache().initPersistence(), + ]); + + // Overlay offline area nodes (depends on both above) await NodeDataManager().preloadOfflineNodes(); // Start uploader if conditions are met @@ -737,6 +742,11 @@ class AppState extends ChangeNotifier { await _settingsState.setNetworkStatusIndicatorEnabled(enabled); } + /// Set coverage overlay visibility + Future setShowCoverageOverlay(bool enabled) async { + await _settingsState.setShowCoverageOverlay(enabled); + } + /// Set suspected location minimum distance from real nodes diff --git a/lib/localizations/de.json b/lib/localizations/de.json index cd9bdae6..0ea34da8 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -435,7 +435,9 @@ "success": "Überwachungsdaten geladen", "nodeDataSlow": "Überwachungsdaten langsam", "rateLimited": "Server-Limitierung", - "networkError": "Netzwerkfehler" + "networkError": "Netzwerkfehler", + "showCoverageOverlay": "Datenabdeckung anzeigen", + "showCoverageOverlaySubtitle": "Zeigt an, welche Kartenbereiche Daten geladen haben" }, "nodeLimitIndicator": { "message": "Zeige {rendered} von {total} Geräten", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 7f7023b4..d341ee32 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -472,7 +472,9 @@ "success": "Surveillance data loaded", "nodeDataSlow": "Surveillance data slow", "rateLimited": "Rate limited by server", - "networkError": "Network error" + "networkError": "Network error", + "showCoverageOverlay": "Data coverage overlay", + "showCoverageOverlaySubtitle": "Show which map areas have loaded data" }, "nodeLimitIndicator": { "message": "Showing {rendered} of {total} devices", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 423ca6d8..41f0279d 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -472,7 +472,9 @@ "success": "Datos de vigilancia cargados", "nodeDataSlow": "Datos de vigilancia lentos", "rateLimited": "Limitado por el servidor", - "networkError": "Error de red" + "networkError": "Error de red", + "showCoverageOverlay": "Superposición de cobertura de datos", + "showCoverageOverlaySubtitle": "Mostrar qué áreas del mapa tienen datos cargados" }, "nodeLimitIndicator": { "message": "Mostrando {rendered} de {total} dispositivos", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index b314a5b0..e3def9f5 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -472,7 +472,9 @@ "success": "Données de surveillance chargées", "nodeDataSlow": "Données de surveillance lentes", "rateLimited": "Limité par le serveur", - "networkError": "Erreur réseau" + "networkError": "Erreur réseau", + "showCoverageOverlay": "Couverture des données", + "showCoverageOverlaySubtitle": "Afficher les zones de la carte dont les données sont chargées" }, "nodeLimitIndicator": { "message": "Affichage de {rendered} sur {total} appareils", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 6602d76c..d4b85693 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -472,7 +472,9 @@ "success": "Dati di sorveglianza caricati", "nodeDataSlow": "Dati di sorveglianza lenti", "rateLimited": "Limitato dal server", - "networkError": "Errore di rete" + "networkError": "Errore di rete", + "showCoverageOverlay": "Sovrapposizione copertura dati", + "showCoverageOverlaySubtitle": "Mostra quali aree della mappa hanno dati caricati" }, "nodeLimitIndicator": { "message": "Mostra {rendered} di {total} dispositivi", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index f9d0cd55..2d08a607 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -472,7 +472,9 @@ "success": "Surveillance data geladen", "nodeDataSlow": "Surveillance data traag", "rateLimited": "Snelheid beperkt door server", - "networkError": "Netwerk fout" + "networkError": "Netwerk fout", + "showCoverageOverlay": "Datadekking overlay", + "showCoverageOverlaySubtitle": "Toon welke kaartgebieden geladen data hebben" }, "nodeLimitIndicator": { "message": "{rendered} van {total} apparaten getoond", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 76fc22b4..d1b76a10 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -472,7 +472,9 @@ "success": "Dane nadzoru załadowane", "nodeDataSlow": "Dane nadzoru powolne", "rateLimited": "Ograniczone przez serwer", - "networkError": "Błąd sieci" + "networkError": "Błąd sieci", + "showCoverageOverlay": "Nakładka pokrycia danymi", + "showCoverageOverlaySubtitle": "Pokaż które obszary mapy mają załadowane dane" }, "nodeLimitIndicator": { "message": "Pokazuje {rendered} z {total} urządzeń", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 8366a549..638a3a9d 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -472,7 +472,9 @@ "success": "Dados de vigilância carregados", "nodeDataSlow": "Dados de vigilância lentos", "rateLimited": "Limitado pelo servidor", - "networkError": "Erro de rede" + "networkError": "Erro de rede", + "showCoverageOverlay": "Sobreposição de cobertura de dados", + "showCoverageOverlaySubtitle": "Mostrar quais áreas do mapa têm dados carregados" }, "nodeLimitIndicator": { "message": "Mostrando {rendered} de {total} dispositivos", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 06fc9adf..d18a7ffb 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -472,7 +472,9 @@ "success": "Gözetleme verisi yüklendi", "nodeDataSlow": "Gözetleme verisi yavaş", "rateLimited": "Sunucu tarafından hız sınırlandı", - "networkError": "Ağ hatası" + "networkError": "Ağ hatası", + "showCoverageOverlay": "Veri kapsama katmanı", + "showCoverageOverlaySubtitle": "Hangi harita alanlarının yüklenmiş veriye sahip olduğunu göster" }, "nodeLimitIndicator": { "message": "{total} cihazdan {rendered} tanesi gösteriliyor", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index 499f1a24..cbfa42c4 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -472,7 +472,9 @@ "success": "Дані спостереження завантажено", "nodeDataSlow": "Повільні дані спостереження", "rateLimited": "Обмежено швидкість сервером", - "networkError": "Помилка мережі" + "networkError": "Помилка мережі", + "showCoverageOverlay": "Накладення покриття даними", + "showCoverageOverlaySubtitle": "Показати, які області карти мають завантажені дані" }, "nodeLimitIndicator": { "message": "Показано {rendered} з {total} пристроїв", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 00695588..268d512a 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -472,7 +472,9 @@ "success": "监控数据已加载", "nodeDataSlow": "监控数据缓慢", "rateLimited": "服务器限流", - "networkError": "网络错误" + "networkError": "网络错误", + "showCoverageOverlay": "数据覆盖范围", + "showCoverageOverlaySubtitle": "显示哪些地图区域已加载数据" }, "nodeLimitIndicator": { "message": "显示 {rendered} / {total} 设备", diff --git a/lib/screens/advanced_settings_screen.dart b/lib/screens/advanced_settings_screen.dart index b5630a44..aef51c00 100644 --- a/lib/screens/advanced_settings_screen.dart +++ b/lib/screens/advanced_settings_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'settings/sections/max_nodes_section.dart'; +import 'settings/sections/network_status_section.dart'; import 'settings/sections/proximity_alerts_section.dart'; import 'settings/sections/suspected_locations_section.dart'; import 'settings/sections/tile_provider_section.dart'; @@ -32,8 +33,8 @@ class AdvancedSettingsScreen extends StatelessWidget { Divider(), SuspectedLocationsSection(), Divider(), - // NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled - // Divider(), + NetworkStatusSection(), + Divider(), TileProviderSection(), ], ), diff --git a/lib/screens/settings/sections/network_status_section.dart b/lib/screens/settings/sections/network_status_section.dart index 1c1dbe7d..6d2d6f73 100644 --- a/lib/screens/settings/sections/network_status_section.dart +++ b/lib/screens/settings/sections/network_status_section.dart @@ -34,6 +34,17 @@ class NetworkStatusSection extends StatelessWidget { }, contentPadding: EdgeInsets.zero, ), + + // Coverage overlay toggle + SwitchListTile( + title: Text(locService.t('networkStatus.showCoverageOverlay')), + subtitle: Text(locService.t('networkStatus.showCoverageOverlaySubtitle')), + value: appState.showCoverageOverlay, + onChanged: (enabled) { + appState.setShowCoverageOverlay(enabled); + }, + contentPadding: EdgeInsets.zero, + ), ], ); }, diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index 6f5e99b5..71bd84ee 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -60,8 +60,16 @@ class MapDataProvider { throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode."); } - // For downloads, always fetch fresh data (don't use cache) - return _nodeDataManager.fetchWithSplitting(bounds, profiles); + // For downloads, always fetch fresh data (don't use cache). + // Note: passes null generation, so downloads are never cancelled by stale-fetch + // detection and will hold semaphore slots until complete. This is intentional — + // offline downloads should run to completion — but means concurrent downloads + // can block foreground map fetches via the shared semaphore. + return _nodeDataManager.fetchWithSplitting( + bounds, + profiles, + isUserInitiated: true, + ); } /// Fetch tile image bytes. Default is to try local first, then remote if not offline. Honors explicit source. diff --git a/lib/services/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart index 5113134d..e9ae8432 100644 --- a/lib/services/map_data_submodules/tiles_from_local.dart +++ b/lib/services/map_data_submodules/tiles_from_local.dart @@ -1,11 +1,11 @@ import 'dart:io'; -import 'dart:math'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; import 'package:flutter/foundation.dart' show visibleForTesting; import '../offline_area_service.dart'; import '../offline_areas/offline_area_models.dart'; +import '../offline_areas/offline_tile_utils.dart' show latLonToTileRaw; import '../../app_state.dart'; /// Fetch a tile from the newest offline area that matches the given provider, or throw if not found. @@ -58,28 +58,21 @@ Future> fetchLocalTile({ /// O(1) check whether tile (z, x, y) falls within the given lat/lng bounds. /// -/// Uses the same Mercator projection math as [latLonToTile] in -/// offline_tile_utils.dart, but only computes the bounding tile range -/// instead of enumerating every tile at that zoom level. +/// Reuses [latLonToTileRaw] from offline_tile_utils.dart for the Mercator +/// projection, computing only the bounding tile range instead of enumerating +/// every tile at that zoom level. /// /// Note: Y axis is inverted in tile coordinates — north = lower Y. @visibleForTesting bool tileInBounds(LatLngBounds bounds, int z, int x, int y) { - final n = pow(2.0, z); - final west = bounds.west; - final east = bounds.east; - final north = bounds.north; - final south = bounds.south; + final swTile = latLonToTileRaw(bounds.south, bounds.west, z); + final neTile = latLonToTileRaw(bounds.north, bounds.east, z); - final minX = ((west + 180.0) / 360.0 * n).floor(); - final maxX = ((east + 180.0) / 360.0 * n).floor(); + final minX = swTile[0].floor(); + final maxX = neTile[0].floor(); // North → lower Y (Mercator projection inverts latitude) - final minY = ((1.0 - log(tan(north * pi / 180.0) + - 1.0 / cos(north * pi / 180.0)) / - pi) / 2.0 * n).floor(); - final maxY = ((1.0 - log(tan(south * pi / 180.0) + - 1.0 / cos(south * pi / 180.0)) / - pi) / 2.0 * n).floor(); + final minY = neTile[1].floor(); + final maxY = swTile[1].floor(); return x >= minX && x <= maxX && y >= minY && y <= maxY; } diff --git a/lib/services/network_status.dart b/lib/services/network_status.dart index 4dd3bb35..579e1cba 100644 --- a/lib/services/network_status.dart +++ b/lib/services/network_status.dart @@ -20,7 +20,12 @@ class NetworkStatus extends ChangeNotifier { NetworkRequestStatus _status = NetworkRequestStatus.idle; Timer? _autoResetTimer; - + + /// Rate limit countdown: seconds until slot should be free (from Overpass /api/status) + int _rateLimitWaitSeconds = 0; + + int get rateLimitWaitSeconds => _rateLimitWaitSeconds; + /// Current network status NetworkRequestStatus get status => _status; @@ -50,7 +55,8 @@ class NetworkStatus extends ChangeNotifier { }); break; case NetworkRequestStatus.rateLimited: - _autoResetTimer = Timer(const Duration(minutes: 2), () { + // Use actual wait time + 1s buffer so countdown finishes before reset + _autoResetTimer = Timer(Duration(seconds: _rateLimitWaitSeconds + 1), () { _setStatus(NetworkRequestStatus.idle); }); break; @@ -86,9 +92,16 @@ class NetworkStatus extends ChangeNotifier { _setStatus(NetworkRequestStatus.timeout); } - /// Rate limited by API - void setRateLimited() { - debugPrint('[NetworkStatus] Rate limited by API'); + /// Rate limited by API, with countdown duration from Overpass /api/status + void setRateLimited({int waitSeconds = 5}) { + debugPrint('[NetworkStatus] Rate limited by API (${waitSeconds}s)'); + final waitChanged = _rateLimitWaitSeconds != waitSeconds; + _rateLimitWaitSeconds = waitSeconds; + // If already rate-limited but wait time changed, force a status transition + // so the auto-reset timer and countdown UI are updated. + if (_status == NetworkRequestStatus.rateLimited && waitChanged) { + _status = NetworkRequestStatus.idle; // allow _setStatus to re-enter + } _setStatus(NetworkRequestStatus.rateLimited); } diff --git a/lib/services/node_cache_database.dart b/lib/services/node_cache_database.dart new file mode 100644 index 00000000..af235f58 --- /dev/null +++ b/lib/services/node_cache_database.dart @@ -0,0 +1,297 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart' as path; + +import '../models/osm_node.dart'; +import 'node_spatial_cache.dart'; + +/// SQLite write-behind persistence for the node spatial cache. +/// Persists Overpass-fetched nodes so they survive app restarts. +class NodeCacheDatabase { + static final NodeCacheDatabase _instance = NodeCacheDatabase._(); + factory NodeCacheDatabase() => _instance; + NodeCacheDatabase._(); + + @visibleForTesting + NodeCacheDatabase.forTesting(); + + Database? _database; + static const String _dbName = 'node_cache.db'; + static const int _dbVersion = 1; + + /// Initialize the database + Future init() async { + if (_database != null) return; + + try { + final dbPath = await getDatabasesPath(); + final fullPath = path.join(dbPath, _dbName); + + debugPrint('[NodeCacheDatabase] Initializing database at $fullPath'); + + _database = await openDatabase( + fullPath, + version: _dbVersion, + onCreate: _createTables, + ); + + debugPrint('[NodeCacheDatabase] Database initialized successfully'); + } catch (e) { + debugPrint('[NodeCacheDatabase] Error initializing database: $e'); + rethrow; + } + } + + /// Create database tables + Future _createTables(Database db, int version) async { + await db.execute(''' + CREATE TABLE nodes ( + id INTEGER PRIMARY KEY, + lat REAL NOT NULL, + lng REAL NOT NULL, + tags TEXT NOT NULL, + is_constrained INTEGER NOT NULL DEFAULT 0 + ) + '''); + await db.execute(''' + CREATE INDEX idx_node_lat_lng ON nodes (lat, lng) + '''); + + await db.execute(''' + CREATE TABLE cached_areas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + south REAL NOT NULL, + west REAL NOT NULL, + north REAL NOT NULL, + east REAL NOT NULL, + fetched_at INTEGER NOT NULL + ) + '''); + } + + /// Batch upsert nodes, filtering out negative IDs and underscore-prefixed tags. + Future insertNodes(List nodes) async { + final db = _database; + if (db == null) return; + + try { + const batchSize = 1000; + await db.transaction((txn) async { + var batch = txn.batch(); + var count = 0; + + for (final node in nodes) { + if (node.id <= 0) continue; + + // Strip underscore-prefixed tags (transient markers) + final persistTags = Map.fromEntries( + node.tags.entries.where((e) => !e.key.startsWith('_')), + ); + + batch.insert( + 'nodes', + { + 'id': node.id, + 'lat': node.coord.latitude, + 'lng': node.coord.longitude, + 'tags': jsonEncode(persistTags), + 'is_constrained': node.isConstrained ? 1 : 0, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + count++; + if (count % batchSize == 0) { + await batch.commit(noResult: true); + batch = txn.batch(); + } + } + + if (count % batchSize != 0) { + await batch.commit(noResult: true); + } + }); + } catch (e) { + debugPrint('[NodeCacheDatabase] Error inserting nodes: $e'); + } + } + + /// Insert a cached area record, removing any older entries it fully covers. + Future insertCachedArea(LatLngBounds bounds, DateTime fetchedAt) async { + final db = _database; + if (db == null) return; + + try { + await db.transaction((txn) async { + // Remove older areas fully contained by the new one + await txn.delete( + 'cached_areas', + where: 'south >= ? AND north <= ? AND west >= ? AND east <= ?', + whereArgs: [bounds.south, bounds.north, bounds.west, bounds.east], + ); + + await txn.insert('cached_areas', { + 'south': bounds.south, + 'west': bounds.west, + 'north': bounds.north, + 'east': bounds.east, + 'fetched_at': fetchedAt.millisecondsSinceEpoch, + }); + }); + } catch (e) { + debugPrint('[NodeCacheDatabase] Error inserting cached area: $e'); + } + } + + /// Load all nodes from the database for cache hydration. + Future> loadAllNodes() async { + final db = _database; + if (db == null) return []; + + try { + final rows = await db.query('nodes'); + return rows.map((row) { + final tagsJson = jsonDecode(row['tags'] as String) as Map; + final tags = tagsJson.map((k, v) => MapEntry(k, v.toString())); + + return OsmNode( + id: row['id'] as int, + coord: LatLng((row['lat'] as num).toDouble(), (row['lng'] as num).toDouble()), + tags: tags, + isConstrained: (row['is_constrained'] as int) == 1, + ); + }).toList(); + } catch (e) { + debugPrint('[NodeCacheDatabase] Error loading nodes: $e'); + return []; + } + } + + /// Load non-expired cached areas. + Future> loadCachedAreas({required Duration ttl}) async { + final db = _database; + if (db == null) return []; + + try { + final cutoff = DateTime.now().subtract(ttl).millisecondsSinceEpoch; + final rows = await db.query( + 'cached_areas', + where: 'fetched_at >= ?', + whereArgs: [cutoff], + ); + + return rows.map((row) { + return CachedArea( + LatLngBounds( + LatLng((row['south'] as num).toDouble(), (row['west'] as num).toDouble()), + LatLng((row['north'] as num).toDouble(), (row['east'] as num).toDouble()), + ), + DateTime.fromMillisecondsSinceEpoch(row['fetched_at'] as int), + ); + }).toList(); + } catch (e) { + debugPrint('[NodeCacheDatabase] Error loading cached areas: $e'); + return []; + } + } + + /// Delete expired areas and orphaned nodes (nodes not covered by any remaining area). + /// Uses a NOT EXISTS subquery to avoid SQLite bind-variable limits when many areas remain. + Future deleteExpiredData({required Duration ttl}) async { + final db = _database; + if (db == null) return; + + try { + final cutoff = DateTime.now().subtract(ttl).millisecondsSinceEpoch; + + await db.transaction((txn) async { + // Delete expired areas + final deletedCount = await txn.delete( + 'cached_areas', + where: 'fetched_at < ?', + whereArgs: [cutoff], + ); + + if (deletedCount == 0) return; + + debugPrint('[NodeCacheDatabase] Deleted $deletedCount expired areas'); + + // Check if any areas remain + final remaining = await txn.rawQuery('SELECT COUNT(*) as cnt FROM cached_areas'); + final count = Sqflite.firstIntValue(remaining) ?? 0; + + if (count == 0) { + // No areas left — delete all nodes + final nodeCount = await txn.delete('nodes'); + debugPrint('[NodeCacheDatabase] Deleted all $nodeCount orphaned nodes'); + return; + } + + // Use a subquery join to delete orphaned nodes without bind-variable limits + final orphanCount = await txn.rawDelete(''' + DELETE FROM nodes WHERE NOT EXISTS ( + SELECT 1 FROM cached_areas + WHERE nodes.lat >= cached_areas.south + AND nodes.lat <= cached_areas.north + AND nodes.lng >= cached_areas.west + AND nodes.lng <= cached_areas.east + ) + '''); + debugPrint('[NodeCacheDatabase] Deleted $orphanCount orphaned nodes'); + }); + } catch (e) { + debugPrint('[NodeCacheDatabase] Error deleting expired data: $e'); + } + } + + /// Delete cached areas that overlap with the given bounds (containment in either direction). + Future deleteOverlappingAreas(LatLngBounds bounds) async { + final db = _database; + if (db == null) return; + + try { + // Areas fully contained by bounds OR that fully contain bounds + final deleted = await db.delete( + 'cached_areas', + where: '(south >= ? AND north <= ? AND west >= ? AND east <= ?) OR ' + '(south <= ? AND north >= ? AND west <= ? AND east >= ?)', + whereArgs: [ + bounds.south, bounds.north, bounds.west, bounds.east, + bounds.south, bounds.north, bounds.west, bounds.east, + ], + ); + if (deleted > 0) { + debugPrint('[NodeCacheDatabase] Deleted $deleted overlapping areas'); + } + } catch (e) { + debugPrint('[NodeCacheDatabase] Error deleting overlapping areas: $e'); + } + } + + /// Clear all cached data. + Future clearAll() async { + final db = _database; + if (db == null) return; + + try { + await db.transaction((txn) async { + await txn.delete('nodes'); + await txn.delete('cached_areas'); + }); + debugPrint('[NodeCacheDatabase] All data cleared'); + } catch (e) { + debugPrint('[NodeCacheDatabase] Error clearing data: $e'); + } + } + + /// Close the database connection. + Future close() async { + if (_database != null) { + await _database!.close(); + _database = null; + } + } +} diff --git a/lib/services/node_data_manager.dart b/lib/services/node_data_manager.dart index 2dbaeeb3..3616ff09 100644 --- a/lib/services/node_data_manager.dart +++ b/lib/services/node_data_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'package:flutter/widgets.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; @@ -13,20 +14,198 @@ import 'map_data_submodules/nodes_from_osm_api.dart'; import 'map_data_submodules/nodes_from_local.dart'; import 'offline_area_service.dart'; import 'offline_areas/offline_area_models.dart'; +import 'offline_areas/offline_tile_utils.dart' show expandBounds; + +/// Resizable async semaphore with priority support. +/// +/// User-initiated (priority) requests jump to the front of the queue so they +/// are never blocked behind prefetch or background refresh work. Background +/// items stay queued and bail on staleness when they eventually wake. +class _AsyncSemaphore { + int _maxConcurrent; + int _current = 0; + final _waiters = Queue>(); + DateTime? _cooldownUntil; + + _AsyncSemaphore(int maxConcurrent) : _maxConcurrent = maxConcurrent < 1 ? 1 : maxConcurrent; + + int get maxConcurrent => _maxConcurrent; + int get activeCount => _current; + int get queueLength => _waiters.length; + bool get isCoolingDown => + _cooldownUntil != null && DateTime.now().isBefore(_cooldownUntil!); + + int get cooldownRemainingSeconds => _cooldownUntil == null + ? 0 + : _cooldownUntil!.difference(DateTime.now()).inSeconds.clamp(0, 30); + + /// Pause all requests for [duration]. Used when Overpass reports a rate + /// limit — no point sending requests we know will be rejected. + /// Also temporarily reduces to 1 slot to avoid burning both slots + /// immediately after cooldown expires. + void cooldown(Duration duration) { + final until = DateTime.now().add(duration); + // Only extend, never shorten an active cooldown + if (_cooldownUntil == null || until.isAfter(_cooldownUntil!)) { + _cooldownUntil = until; + // Reduce to 1 slot coming out of rate limit — prevents two concurrent + // 7s queries from consuming both slots and triggering another rate limit. + // Restored to full capacity after a successful request. + _maxConcurrent = 1; + debugPrint('[Semaphore] Cooldown set for ${duration.inSeconds}s, reduced to 1 slot'); + } + } + + /// Restore full slot capacity after a successful request post-cooldown. + void restoreCapacity(int slots) { + if (_maxConcurrent < slots) { + _maxConcurrent = slots; + debugPrint('[Semaphore] Restored to $slots slots'); + // Wake a queued waiter if one is waiting for the new slot + if (_waiters.isNotEmpty && _current < _maxConcurrent) { + _waiters.removeFirst().complete(); + } + } + } + + /// Resize the semaphore. If capacity increased, wake up queued waiters. + void resize(int newMax) { + _maxConcurrent = newMax < 1 ? 1 : newMax; + // Wake exactly the number of newly available slots. + // Can't use _current in the loop condition because woken waiters + // haven't incremented it yet (their continuations are microtasks). + var available = _maxConcurrent - _current; + while (available > 0 && _waiters.isNotEmpty) { + _waiters.removeFirst().complete(); + available--; + } + } + + Future run(Future Function() fn, {bool priority = false}) async { + // Wait for cooldown before competing for a slot. + // Re-check after waking — another caller may have already cleared it. + await _waitForCooldown(); + + while (_current >= _maxConcurrent) { + final completer = Completer(); + // Priority (user-initiated) requests go to the front so the most recent + // user pan is served first — it's the viewport they're actually looking at. + if (priority) { + _waiters.addFirst(completer); + } else { + _waiters.addLast(completer); + } + await completer.future; + // Re-check cooldown after waking from slot wait — cooldown() may have + // been called while we were queued. + await _waitForCooldown(); + } + _current++; + try { + return await fn(); + } finally { + _current--; + if (_waiters.isNotEmpty && _current < _maxConcurrent) { + _waiters.removeFirst().complete(); + } + } + } + + /// Block until cooldown expires. Re-checks in a loop because cooldown() + /// may extend the deadline while we're waiting. + Future _waitForCooldown() async { + while (_cooldownUntil != null) { + final remaining = _cooldownUntil!.difference(DateTime.now()); + if (remaining <= Duration.zero) { + _cooldownUntil = null; + break; + } + debugPrint('[Semaphore] Waiting ${remaining.inSeconds}s for cooldown'); + await Future.delayed(remaining); + } + } +} /// Coordinates node data fetching between cache, Overpass, and OSM API. /// Simple interface: give me nodes for this view with proper caching and error handling. class NodeDataManager extends ChangeNotifier { static final NodeDataManager _instance = NodeDataManager._(); factory NodeDataManager() => _instance; - NodeDataManager._(); - final OverpassService _overpassService = OverpassService(); - final NodeSpatialCache _cache = NodeSpatialCache(); - + NodeDataManager._({ + OverpassService? overpassService, + NodeSpatialCache? cache, + }) : _overpassService = overpassService ?? OverpassService(), + _cache = cache ?? NodeSpatialCache(); + + @visibleForTesting + factory NodeDataManager.forTesting({ + OverpassService? overpassService, + NodeSpatialCache? cache, + }) => NodeDataManager._(overpassService: overpassService, cache: cache); + + final OverpassService _overpassService; + final NodeSpatialCache _cache; + + // Limit concurrent Overpass requests to the known slot count (2 per IP). + _AsyncSemaphore? _overpassSemaphore; + + _AsyncSemaphore get _semaphore => + _overpassSemaphore ??= _AsyncSemaphore(OverpassService.defaultSlotCount); + + // Throttle progressive notifications to avoid repeated expensive marker rebuilds + Timer? _progressiveNotifyTimer; + + // Generation counter for cancelling stale fetch requests. + // Incremented only when the user pans to an area NOT covered by the current + // in-flight request's expanded bounds. This prevents rapid cancel/re-fetch + // cycles when the user pans slightly within the 1.2x fetch area. + int _fetchGeneration = 0; + int? _lastLoggedStaleGeneration; + + // Track the expanded bounds of the current in-flight user-initiated fetch. + // When a new request comes in, if the in-flight bounds already cover the + // new viewport, we skip cancelling and let it finish — its 1.2x expansion + // likely covers the new viewport anyway. + LatLngBounds? _inFlightBounds; + + bool _isStale(int? generation) { + if (generation == null || generation == _fetchGeneration) return false; + if (_lastLoggedStaleGeneration != generation) { + _lastLoggedStaleGeneration = generation; + debugPrint('[NodeDataManager] Fetch generation $generation is stale ' + '(current: $_fetchGeneration), cancelling remaining work'); + } + return true; + } + + @visibleForTesting + void advanceFetchGeneration() => _fetchGeneration++; + + /// Throttled notification for progressive rendering. + /// Batches rapid quadrant completions into one rebuild (~200ms window). + void _notifyProgressive() { + _progressiveNotifyTimer?.cancel(); + _progressiveNotifyTimer = Timer(const Duration(milliseconds: 200), () { + notifyListeners(); + }); + } + // Track ongoing user-initiated requests for status reporting final Set _userInitiatedRequests = {}; - + + // Track in-flight background refreshes to avoid duplicates + final Set _backgroundRefreshKeys = {}; + + // Separate generation for prefetch loops so a new cache-hit cancels + // the previous prefetch without requiring a fetch-generation bump. + int _prefetchGeneration = 0; + + // Reconciliation: auto-retry the current viewport after a failed fetch. + LatLngBounds? _pendingViewport; + List? _pendingProfiles; + Timer? _reconciliationTimer; + /// Get nodes for the given bounds and profiles. /// Returns cached data immediately if available, otherwise fetches from appropriate source. Future> getNodesFor({ @@ -43,7 +222,7 @@ class NodeDataManager extends ChangeNotifier { if (isUserInitiated) { NetworkStatus.instance.clear(); } - + if (uploadMode == UploadMode.sandbox) { // Offline + Sandbox = no nodes (local cache is production data) debugPrint('[NodeDataManager] Offline + Sandbox mode: returning no nodes'); @@ -51,7 +230,7 @@ class NodeDataManager extends ChangeNotifier { } else { // Offline + Production = use local offline areas (instant) final offlineNodes = await fetchLocalNodes(bounds: bounds, profiles: profiles); - + // Add offline nodes to cache so they integrate with the rest of the system if (offlineNodes.isNotEmpty) { _cache.addOrUpdateNodes(offlineNodes); @@ -59,7 +238,7 @@ class NodeDataManager extends ChangeNotifier { _cache.markAreaAsFetched(bounds, offlineNodes); notifyListeners(); } - + // Show brief success for user-initiated offline loads with data if (isUserInitiated && offlineNodes.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -71,7 +250,7 @@ class NodeDataManager extends ChangeNotifier { NetworkStatus.instance.setNoData(); }); } - + return offlineNodes; } } @@ -79,15 +258,15 @@ class NodeDataManager extends ChangeNotifier { // Handle sandbox mode (always fetch from OSM API, but integrate with cache system for UI) if (uploadMode == UploadMode.sandbox) { debugPrint('[NodeDataManager] Sandbox mode: fetching from OSM API'); - + // Track user-initiated requests for status reporting final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode'; - + if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) { debugPrint('[NodeDataManager] Sandbox request already in progress for this area'); return _cache.getNodesFor(bounds); } - + // Start status tracking for user-initiated requests if (isUserInitiated) { _userInitiatedRequests.add(requestKey); @@ -96,7 +275,7 @@ class NodeDataManager extends ChangeNotifier { } else { debugPrint('[NodeDataManager] Starting background sandbox request (no status reporting)'); } - + try { final nodes = await fetchOsmApiNodes( bounds: bounds, @@ -104,7 +283,7 @@ class NodeDataManager extends ChangeNotifier { uploadMode: uploadMode, maxResults: 0, ); - + // Add nodes to cache for UI integration (even though we don't rely on cache for subsequent fetches) if (nodes.isNotEmpty) { _cache.addOrUpdateNodes(nodes); @@ -113,10 +292,10 @@ class NodeDataManager extends ChangeNotifier { // Mark area as fetched even with no nodes so UI knows we've checked this area _cache.markAreaAsFetched(bounds, []); } - + // Update UI notifyListeners(); - + // Set success after the next frame renders, but only for user-initiated requests if (isUserInitiated) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -124,16 +303,16 @@ class NodeDataManager extends ChangeNotifier { }); debugPrint('[NodeDataManager] User-initiated sandbox request completed successfully: ${nodes.length} nodes'); } - + return nodes; - + } catch (e) { debugPrint('[NodeDataManager] Sandbox fetch failed: $e'); - + // Only report errors for user-initiated requests if (isUserInitiated) { if (e is RateLimitError) { - NetworkStatus.instance.setRateLimited(); + NetworkStatus.instance.setRateLimited(waitSeconds: e.waitSeconds); } else if (e.toString().contains('timeout')) { NetworkStatus.instance.setTimeout(); } else { @@ -141,7 +320,7 @@ class NodeDataManager extends ChangeNotifier { } debugPrint('[NodeDataManager] User-initiated sandbox request failed: $e'); } - + // Return whatever we have in cache for this area (likely empty for sandbox) return _cache.getNodesFor(bounds); } finally { @@ -153,152 +332,300 @@ class NodeDataManager extends ChangeNotifier { // Production mode: check cache first if (_cache.hasDataFor(bounds)) { - debugPrint('[NodeDataManager] Using cached data for bounds'); - return _cache.getNodesFor(bounds); + final staleBounds = _cache.staleAreaFor(bounds); + if (staleBounds != null) { + _backgroundRefresh(staleBounds, profiles); + } + // Only prefetch at higher zoom levels where areas are small enough + // to be worth speculative fetching. At low zoom (~10), each cell is + // ~1° × 0.67° — prefetching 48 of those wastes rate limit budget. + final latSpan = bounds.north - bounds.south; + if (latSpan < 0.2) { + _prefetchSurroundings(bounds, profiles); + } + final cachedNodes = _cache.getNodesFor(bounds); + debugPrint('[NodeDataManager] Cache hit: ${cachedNodes.length} nodes, ' + 'stale=${staleBounds != null}'); + return cachedNodes; } // Not cached - need to fetch final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode'; - + + // If the semaphore is cooling down (rate limited), don't queue more + // requests — they'll just wait and go stale. Reconciliation will + // retry with the correct viewport when the cooldown expires. + if (_semaphore.isCoolingDown) { + debugPrint('[NodeDataManager] Semaphore cooling down, deferring to reconciliation'); + if (isUserInitiated) { + _pendingViewport = bounds; + _pendingProfiles = profiles; + NetworkStatus.instance.setRateLimited(waitSeconds: _semaphore.cooldownRemainingSeconds); + } + return _cache.getNodesFor(bounds); + } + // 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); } - + + // If there's an in-flight request whose 1.2x expanded bounds already + // cover this viewport, don't cancel it — let it finish. The user panned + // slightly but the existing fetch will cover the new view. + if (_inFlightBounds != null && + _inFlightBounds!.containsBounds(bounds)) { + debugPrint('[NodeDataManager] In-flight request covers viewport, skipping new fetch ' + '(gen $_fetchGeneration)'); + if (isUserInitiated) { + NetworkStatus.instance.setLoading(); + } + return _cache.getNodesFor(bounds); + } + // Start status tracking for user-initiated requests only if (isUserInitiated) { _userInitiatedRequests.add(requestKey); NetworkStatus.instance.setLoading(); - debugPrint('[NodeDataManager] Starting user-initiated request'); + debugPrint('[NodeDataManager] Starting user-initiated request ' + '(semaphore: ${_semaphore.activeCount}/${_semaphore.maxConcurrent}, ' + 'queued: ${_semaphore.queueLength})'); } else { debugPrint('[NodeDataManager] Starting background request (no status reporting)'); } + _reconciliationTimer?.cancel(); // New request supersedes pending retry + _pendingViewport = null; + _pendingProfiles = null; + final stopwatch = Stopwatch()..start(); + final generation = ++_fetchGeneration; + // Use a larger expansion for in-flight tracking at low zoom so that + // small pans during the multi-second Overpass response don't cancel + // the request. At zoom 10 (latSpan ~1°), 1.5x adds ~0.25° per side; + // at zoom 14 (latSpan ~0.06°), 1.2x is sufficient. + final latSpan = bounds.north - bounds.south; + final inFlightExpansion = latSpan > 0.5 ? 1.5 : 1.2; + _inFlightBounds = expandBounds(bounds, inFlightExpansion); try { - final nodes = await fetchWithSplitting(bounds, profiles, isUserInitiated: isUserInitiated); - - // Update cache and notify listeners + final nodes = await fetchWithSplitting(bounds, profiles, + isUserInitiated: isUserInitiated, generation: generation); + + // If this fetch became stale (user panned away), skip UI updates + // but clear rate-limited status since a new request will take over + if (_isStale(generation)) { + if (NetworkStatus.instance.status == NetworkRequestStatus.rateLimited) { + NetworkStatus.instance.clear(); + } + return _cache.getNodesFor(bounds); + } + + // If the fetch returned empty (e.g. rate limited), fall back to + // whatever the cache has from prior fetches of overlapping areas. + final result = nodes.isEmpty ? _cache.getNodesFor(bounds) : nodes; + + // Progressive notifications already covered UI updates per quadrant; + // flush any pending throttled notification so the final state renders. + _progressiveNotifyTimer?.cancel(); + _progressiveNotifyTimer = null; notifyListeners(); - + + // Success — clear any pending reconciliation + _reconciliationTimer?.cancel(); + _pendingViewport = null; + _pendingProfiles = null; + // Set success after the next frame renders, but only for user-initiated requests if (isUserInitiated) { WidgetsBinding.instance.addPostFrameCallback((_) { NetworkStatus.instance.setSuccess(); }); - debugPrint('[NodeDataManager] User-initiated request completed successfully'); + debugPrint('[NodeDataManager] User-initiated request completed: ${result.length} nodes (${stopwatch.elapsedMilliseconds}ms)'); } - - return nodes; - + + if (latSpan < 0.2) { + _prefetchSurroundings(bounds, profiles); + } + return result; + } catch (e) { debugPrint('[NodeDataManager] Fetch failed: $e'); - - // Only report errors for user-initiated requests - if (isUserInitiated) { + + // Skip error reporting for stale requests + if (isUserInitiated && !_isStale(generation)) { if (e is RateLimitError) { - NetworkStatus.instance.setRateLimited(); + NetworkStatus.instance.setRateLimited(waitSeconds: e.waitSeconds); } else if (e.toString().contains('timeout')) { NetworkStatus.instance.setTimeout(); } else { NetworkStatus.instance.setError(); } - debugPrint('[NodeDataManager] User-initiated request failed: $e'); + debugPrint('[NodeDataManager] User-initiated request failed (${stopwatch.elapsedMilliseconds}ms): $e'); + + // Schedule auto-retry for the current viewport, using the actual + // wait time from Overpass /api/status when available. + _pendingViewport = bounds; + _pendingProfiles = profiles; + final retryDelay = e is RateLimitError ? e.waitSeconds : 5; + _scheduleReconciliation(delaySeconds: retryDelay); } - + // Return whatever we have in cache for this area return _cache.getNodesFor(bounds); } finally { + // Clear in-flight tracking when this generation's fetch finishes. + // Only clear if we're still the current in-flight request (not staled). + if (!_isStale(generation)) { + _inFlightBounds = null; + } if (isUserInitiated) { _userInitiatedRequests.remove(requestKey); } } } - /// Fetch nodes with automatic area splitting if needed + /// Fetch nodes with automatic area splitting on NodeLimitError. + /// + /// RateLimitError is rethrown to the caller — reconciliation handles retry. + /// + /// Cancellation via [generation]: + /// - null: request is never cancelled (used by offline area downloads) + /// - non-null: cancelled at checkpoints when _fetchGeneration advances + /// + /// In-flight HTTP calls can't be cancelled (Dart limitation). When a + /// response arrives for a stale generation, the data is still cached + /// (nodes.isNotEmpty guard) — the work is done, don't throw it away. Future> fetchWithSplitting( - LatLngBounds bounds, + LatLngBounds bounds, List profiles, { int splitDepth = 0, bool isUserInitiated = false, + int? generation, }) async { const maxSplitDepth = 3; // 4^3 = 64 max sub-areas - + + // Checkpoint 1: bail before entering semaphore queue + if (_isStale(generation)) return []; + try { // Expand bounds slightly to reduce edge effects - final expandedBounds = _expandBounds(bounds, 1.2); - - final nodes = await _overpassService.fetchNodes( - bounds: expandedBounds, - profiles: profiles, + final expandedBounds = expandBounds(bounds, 1.2); + + // User-initiated requests jump to the front of the semaphore queue + // so they're never blocked behind prefetch/background work. + // Checkpoint 2: request woke from semaphore queue — check before HTTP call + final semaphoreWait = Stopwatch()..start(); + var didFetch = false; + final nodes = await _semaphore.run>( + () async { + final waitMs = semaphoreWait.elapsedMilliseconds; + if (waitMs > 100) { + debugPrint('[NodeDataManager] Semaphore wait: ${waitMs}ms'); + } + if (_isStale(generation)) return []; + didFetch = true; + return _overpassService.fetchNodes( + bounds: expandedBounds, + profiles: profiles, + ); + }, + priority: isUserInitiated, ); - - // Success - cache the data for the expanded area - _cache.markAreaAsFetched(expandedBounds, nodes); + + // Only restore capacity and cache results when we actually made an + // HTTP call. The stale short-circuit (checkpoint 2) returns empty + // without fetching — restoring capacity there would defeat the + // single-slot recovery after rate-limit cooldown. + if (didFetch) { + _semaphore.restoreCapacity(OverpassService.defaultSlotCount); + _cache.markAreaAsFetched(expandedBounds, nodes); + if (nodes.isNotEmpty) { + _notifyProgressive(); // Throttled: batches rapid quadrant completions + } + } return nodes; - + } on NodeLimitError { - // Hit node limit or timeout - split area if not too deep if (splitDepth >= maxSplitDepth) { debugPrint('[NodeDataManager] Max split depth reached, giving up'); return []; } - + + // Checkpoint 3: don't spawn 4 new sub-requests for stale fetch + if (_isStale(generation)) return []; + debugPrint('[NodeDataManager] Splitting area (depth: $splitDepth)'); - - // 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 - debugPrint('[NodeDataManager] Rate limited, backing off'); - await Future.delayed(const Duration(seconds: 30)); - return []; + + // Each sub-request re-enters the semaphore independently (sequential, + // not parallel) so it competes fairly with other callers. + return _fetchSplitAreas(bounds, profiles, splitDepth + 1, + isUserInitiated: isUserInitiated, generation: generation); + + } on RateLimitError catch (e) { + // Pause the semaphore so queued requests don't each independently + // hit the pre-flight and fail. When cooldown expires, requests flow. + _semaphore.cooldown(Duration(seconds: e.waitSeconds)); + // Kill any in-progress prefetch so those queued cells don't wake up + // after cooldown and consume slots before user-initiated requests. + _prefetchGeneration++; + debugPrint('[NodeDataManager] Rate limited (${e.waitSeconds}s), deferring to reconciliation'); + rethrow; } } - /// Fetch data by splitting area into quadrants + /// Fetch data by splitting area into quadrants (sequential). + /// Sequential avoids flooding Overpass — parallel requests just queue + /// behind the semaphore and trigger 429s. Future> _fetchSplitAreas( - LatLngBounds bounds, + LatLngBounds bounds, List profiles, int splitDepth, { bool isUserInitiated = false, + int? generation, }) async { - final quadrants = _splitBounds(bounds); + // Checkpoint 6: don't spawn quadrants for stale tree + if (_isStale(generation)) return []; + + final quadrants = splitBounds(bounds); final allNodes = []; - + for (final quadrant in quadrants) { + if (_isStale(generation)) break; try { final nodes = await fetchWithSplitting( - quadrant, - profiles, - splitDepth: splitDepth, + quadrant, profiles, + splitDepth: splitDepth, isUserInitiated: isUserInitiated, + generation: generation, ); allNodes.addAll(nodes); + } on RateLimitError { + // If one quadrant is rate-limited, the rest will be too. + // Rethrow so reconciliation handles retry with the right delay. + rethrow; } catch (e) { debugPrint('[NodeDataManager] Quadrant fetch failed: $e'); - // Continue with other quadrants } } - + debugPrint('[NodeDataManager] Split fetch complete: ${allNodes.length} total nodes'); return allNodes; } /// Split bounds into 4 quadrants - List _splitBounds(LatLngBounds bounds) { + @visibleForTesting + static List splitBounds(LatLngBounds bounds) { final centerLat = (bounds.north + bounds.south) / 2; final centerLng = (bounds.east + bounds.west) / 2; - + return [ // Southwest LatLngBounds(LatLng(bounds.south, bounds.west), LatLng(centerLat, centerLng)), - // Southeast + // Southeast LatLngBounds(LatLng(bounds.south, centerLng), LatLng(centerLat, bounds.east)), // Northwest LatLngBounds(LatLng(centerLat, bounds.west), LatLng(bounds.north, centerLng)), @@ -307,20 +634,6 @@ class NodeDataManager extends ChangeNotifier { ]; } - /// Expand bounds by given factor around center point - LatLngBounds _expandBounds(LatLngBounds bounds, double factor) { - final centerLat = (bounds.north + bounds.south) / 2; - final centerLng = (bounds.east + bounds.west) / 2; - - final latSpan = (bounds.north - bounds.south) * factor / 2; - final lngSpan = (bounds.east - bounds.west) * factor / 2; - - return LatLngBounds( - LatLng(centerLat - latSpan, centerLng - lngSpan), - LatLng(centerLat + latSpan, centerLng + lngSpan), - ); - } - /// Add or update nodes in cache (for upload queue integration) void addOrUpdateNodes(List nodes) { _cache.addOrUpdateNodes(nodes); @@ -345,9 +658,9 @@ class NodeDataManager extends ChangeNotifier { required List profiles, UploadMode uploadMode = UploadMode.production, }) async { - // Clear any cached data for this area - _cache.clear(); - + // Invalidate only the areas covering these bounds so they're re-fetched + _cache.invalidateArea(bounds); + // Re-fetch as user-initiated request await getNodesFor( bounds: bounds, @@ -374,16 +687,16 @@ class NodeDataManager extends ChangeNotifier { Future preloadOfflineNodes() async { try { final offlineAreaService = OfflineAreaService(); - + for (final area in offlineAreaService.offlineAreas) { if (area.status != OfflineAreaStatus.complete) continue; - + // Load nodes from this offline area final nodes = await fetchLocalNodes( bounds: area.bounds, profiles: [], // Empty profiles = load all nodes ); - + if (nodes.isNotEmpty) { _cache.addOrUpdateNodes(nodes); // Mark the offline area as having coverage so submit buttons work @@ -391,13 +704,169 @@ class NodeDataManager extends ChangeNotifier { debugPrint('[NodeDataManager] Preloaded ${nodes.length} offline nodes from area ${area.name}'); } } - + notifyListeners(); } catch (e) { debugPrint('[NodeDataManager] Error preloading offline nodes: $e'); } } + /// Fire-and-forget background refresh for stale cached areas. + /// Snapshots _fetchGeneration so user pans cancel queued work, but in-flight + /// HTTP responses are still cached (see markAreaAsFetched nodesNotEmpty guard). + void _backgroundRefresh(LatLngBounds bounds, List profiles) { + final key = '${bounds.south},${bounds.west},${bounds.north},${bounds.east}'; + if (_backgroundRefreshKeys.contains(key)) return; + + _backgroundRefreshKeys.add(key); + + final effectiveProfiles = profiles.isNotEmpty + ? profiles + : AppState.instance.enabledProfiles; + + final generation = _fetchGeneration; + debugPrint('[NodeDataManager] Starting background refresh (gen $generation)'); + + () async { + try { + await fetchWithSplitting(bounds, effectiveProfiles, + isUserInitiated: false, generation: generation); + _progressiveNotifyTimer?.cancel(); + _progressiveNotifyTimer = null; + notifyListeners(); + } catch (e) { + debugPrint('[NodeDataManager] Background refresh failed (silently): $e'); + } finally { + _backgroundRefreshKeys.remove(key); + } + }(); + } + + /// Start prefetching surrounding areas in expanding rings around the viewport. + /// + /// Cancellation has two layers: + /// - _prefetchGeneration: bumped on each call, stops the loop from queuing + /// new cells when the user pans to a different cached area. + /// - _fetchGeneration (via fetchWithSplitting): bumped when the user pans to + /// an uncached area, cancels queued semaphore work and prevents new splits + /// or rate-limit retries. In-flight HTTP responses are still cached thanks + /// to the `nodes.isNotEmpty` guard in fetchWithSplitting. + void _prefetchSurroundings(LatLngBounds viewport, List profiles) { + // Bump prefetch generation to stop the previous loop from queuing new cells. + final prefetchGen = ++_prefetchGeneration; + // Snapshot fetch generation so user pans to uncached areas cancel queued work. + final fetchGen = _fetchGeneration; + debugPrint('[NodeDataManager] Starting prefetch (prefetchGen $prefetchGen, fetchGen $fetchGen)'); + + () async { + // After rate-limit recovery, delay prefetch so user-initiated requests + // get slot priority. Skip delay when semaphore is at full capacity. + if (_semaphore.maxConcurrent < OverpassService.defaultSlotCount) { + await Future.delayed(const Duration(seconds: 3)); + if (prefetchGen != _prefetchGeneration || _isStale(fetchGen)) return; + } + + final effectiveProfiles = profiles.isNotEmpty + ? profiles + : AppState.instance.enabledProfiles; + if (effectiveProfiles.isEmpty) return; + + final cells = _generateRingCells(viewport, 3); // 3 rings max + debugPrint('[NodeDataManager] Prefetch: ${cells.length} cells'); + for (final cell in cells) { + // Stop queuing new cells if a newer prefetch started or user panned + if (prefetchGen != _prefetchGeneration || _isStale(fetchGen)) return; + + // Skip already-cached cells + if (_cache.hasDataFor(cell)) continue; + + try { + await fetchWithSplitting(cell, effectiveProfiles, + isUserInitiated: false, generation: fetchGen); + _progressiveNotifyTimer?.cancel(); + _progressiveNotifyTimer = null; + notifyListeners(); + } catch (e) { + debugPrint('[NodeDataManager] Prefetch cell failed (silently): $e'); + } + + // Delay between requests to stay well under rate limit + if (prefetchGen != _prefetchGeneration || _isStale(fetchGen)) return; + await Future.delayed(const Duration(seconds: 5)); + } + }(); + } + + /// Generate ring cells around the viewport in expanding rings. + /// Ring 1 = 8 cells adjacent, Ring 2 = 16 cells, Ring 3 = 24 cells, etc. + @visibleForTesting + static List generateRingCells(LatLngBounds viewport, int maxRings) { + return _generateRingCells(viewport, maxRings); + } + + static List _generateRingCells(LatLngBounds viewport, int maxRings) { + final latSpan = viewport.north - viewport.south; + final lngSpan = viewport.east - viewport.west; + final cells = []; + + for (int ring = 1; ring <= maxRings; ring++) { + // Walk the perimeter of the ring + for (int dx = -ring; dx <= ring; dx++) { + for (int dy = -ring; dy <= ring; dy++) { + // Only include cells on the perimeter of this ring + if (dx.abs() != ring && dy.abs() != ring) continue; + + final south = viewport.south + dy * latSpan; + final west = viewport.west + dx * lngSpan; + cells.add(LatLngBounds( + LatLng(south, west), + LatLng(south + latSpan, west + lngSpan), + )); + } + } + } + + return cells; + } + + /// Schedule a reconciliation retry for a failed viewport fetch. + /// [delaySeconds] defaults to 5 but is set from the Overpass /api/status + /// wait time when available, so we retry right when a slot opens. + void _scheduleReconciliation({int delaySeconds = 5}) { + _reconciliationTimer?.cancel(); + debugPrint('[NodeDataManager] Reconciliation scheduled in ${delaySeconds}s'); + // Schedule after cooldown expires — if reconciliation fires while the + // semaphore is still cooling down, it'll hit the guard and get dropped. + final cooldownRemaining = _semaphore.cooldownRemainingSeconds; + final effectiveDelay = delaySeconds < cooldownRemaining + ? cooldownRemaining + 1 // Wait for cooldown + 1s buffer + : delaySeconds; + if (effectiveDelay != delaySeconds) { + debugPrint('[NodeDataManager] Reconciliation adjusted to ${effectiveDelay}s (cooldown: ${cooldownRemaining}s)'); + } + _reconciliationTimer = Timer(Duration(seconds: effectiveDelay), () { + final viewport = _pendingViewport; + final profiles = _pendingProfiles; + _pendingViewport = null; + _pendingProfiles = null; + if (viewport == null || profiles == null) return; + if (_cache.hasFreshDataFor(viewport)) return; // Already filled with fresh data + + debugPrint('[NodeDataManager] Reconciliation: retrying viewport fetch'); + getNodesFor(bounds: viewport, profiles: profiles, isUserInitiated: true); + }); + } + + @override + void dispose() { + _reconciliationTimer?.cancel(); + _progressiveNotifyTimer?.cancel(); + super.dispose(); + } + + /// Get fetched areas with timestamps (for coverage overlay). + List get fetchedAreas => _cache.fetchedAreas; + /// Get cache statistics String get cacheStats => _cache.stats.toString(); -} \ No newline at end of file +} diff --git a/lib/services/node_spatial_cache.dart b/lib/services/node_spatial_cache.dart index 35d90442..a7d28061 100644 --- a/lib/services/node_spatial_cache.dart +++ b/lib/services/node_spatial_cache.dart @@ -3,36 +3,109 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; import '../models/osm_node.dart'; +import 'node_cache_database.dart'; const Distance _distance = Distance(); /// Simple spatial cache that tracks which areas have been successfully fetched. -/// No temporal expiration - data stays cached until app restart or explicit clear. +/// Backed by SQLite for persistence across app restarts. class NodeSpatialCache { static final NodeSpatialCache _instance = NodeSpatialCache._(); factory NodeSpatialCache() => _instance; NodeSpatialCache._(); + @visibleForTesting + NodeSpatialCache.forTesting(); + final List _fetchedAreas = []; final Map _nodes = {}; // nodeId -> node + NodeCacheDatabase? _database; + + /// How old cached data can be before it's considered stale (serve + background refresh). + static const Duration freshThreshold = Duration(hours: 1); + + /// How old cached data can be before it's expired and pruned from SQLite. + static const Duration expiryTtl = Duration(days: 7); + + /// Initialize SQLite persistence: prune expired data, then hydrate in-memory cache. + /// No-ops if already initialized to avoid wiping in-memory data accumulated + /// during the session. + Future initPersistence() async { + if (_database != null) return; + + final db = NodeCacheDatabase(); + await db.init(); + _database = db; + + // Prune expired areas and orphaned nodes + await db.deleteExpiredData(ttl: expiryTtl); + + // Load surviving data into memory + final areas = await db.loadCachedAreas(ttl: expiryTtl); + final nodes = await db.loadAllNodes(); + + _fetchedAreas.addAll(areas); + _fetchedAreasView = null; + for (final node in nodes) { + _nodes[node.id] = node; + } + + debugPrint('[NodeSpatialCache] Hydrated from SQLite: ${areas.length} areas, ${nodes.length} nodes'); + } + /// Check if we have cached data covering the given bounds bool hasDataFor(LatLngBounds bounds) { return _fetchedAreas.any((area) => area.bounds.containsBounds(bounds)); } - /// Record that we successfully fetched data for this area + /// Whether a cached area's data is still within [freshThreshold]. + bool _isFreshAt(CachedArea area, DateTime now) => + now.difference(area.fetchedAt) <= freshThreshold; + + /// Check if we have fresh (non-stale) cached data covering the given bounds. + bool hasFreshDataFor(LatLngBounds bounds) { + final now = DateTime.now(); + return _fetchedAreas.any((area) => + area.bounds.containsBounds(bounds) && _isFreshAt(area, now)); + } + + /// Return the original cached bounds of the first stale area covering [bounds], + /// or null if no stale coverage exists. The returned bounds include the + /// original 1.2x expansion so the background refresh targets the same area. + LatLngBounds? staleAreaFor(LatLngBounds bounds) { + final now = DateTime.now(); + for (final area in _fetchedAreas) { + if (area.bounds.containsBounds(bounds) && !_isFreshAt(area, now)) { + return area.bounds; + } + } + return null; + } + + /// Record that we successfully fetched data for this area. + /// Removes older entries that the new area fully subsumes to bound list growth. void markAreaAsFetched(LatLngBounds bounds, List nodes) { + final now = DateTime.now(); + + // Remove existing entries that the new area fully covers (dedup/compact) + _fetchedAreas.removeWhere((existing) => bounds.containsBounds(existing.bounds)); + // Add the fetched area - _fetchedAreas.add(CachedArea(bounds, DateTime.now())); - + _fetchedAreas.add(CachedArea(bounds, now)); + _fetchedAreasView = null; // Invalidate cached view + // Update nodes in cache for (final node in nodes) { _nodes[node.id] = node; } - + debugPrint('[NodeSpatialCache] Cached ${nodes.length} nodes for area ${bounds.south.toStringAsFixed(3)},${bounds.west.toStringAsFixed(3)} to ${bounds.north.toStringAsFixed(3)},${bounds.east.toStringAsFixed(3)}'); debugPrint('[NodeSpatialCache] Total areas cached: ${_fetchedAreas.length}, total nodes: ${_nodes.length}'); + + // Write-through to SQLite (fire-and-forget) + _database?.insertNodes(nodes); + _database?.insertCachedArea(bounds, now); } /// Get all cached nodes within the given bounds @@ -84,7 +157,7 @@ class NodeSpatialCache { if (node != null && node.tags.containsKey('_pending_edit')) { final cleanTags = Map.from(node.tags); cleanTags.remove('_pending_edit'); - + _nodes[nodeId] = OsmNode( id: node.id, coord: node.coord, @@ -100,7 +173,7 @@ class NodeSpatialCache { if (node != null && node.tags.containsKey('_pending_deletion')) { final cleanTags = Map.from(node.tags); cleanTags.remove('_pending_deletion'); - + _nodes[nodeId] = OsmNode( id: node.id, coord: node.coord, @@ -116,7 +189,7 @@ class NodeSpatialCache { debugPrint('[NodeSpatialCache] Warning: Attempted to remove non-temp node ID $tempNodeId'); return; } - + if (_nodes.remove(tempNodeId) != null) { debugPrint('[NodeSpatialCache] Removed temp node $tempNodeId from cache'); } @@ -125,34 +198,53 @@ class NodeSpatialCache { /// Find nodes within distance of a coordinate (for proximity warnings) List findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) { final nearbyNodes = []; - + for (final node in _nodes.values) { // Skip the excluded node if (excludeNodeId != null && node.id == excludeNodeId) { continue; } - + // Skip nodes marked for deletion if (node.tags.containsKey('_pending_deletion')) { continue; } - + final distanceInMeters = _distance.as(LengthUnit.Meter, coord, node.coord); if (distanceInMeters <= distanceMeters) { nearbyNodes.add(node); } } - + return nearbyNodes; } + /// Invalidate cached areas where one fully contains the other. + /// Nodes are kept (they'll be refreshed by the subsequent fetch). + void invalidateArea(LatLngBounds bounds) { + final before = _fetchedAreas.length; + _fetchedAreas.removeWhere((area) => + area.bounds.containsBounds(bounds) || bounds.containsBounds(area.bounds)); + _fetchedAreasView = null; + _database?.deleteOverlappingAreas(bounds); + debugPrint('[NodeSpatialCache] Invalidated ${before - _fetchedAreas.length} areas overlapping ${bounds.south.toStringAsFixed(3)},${bounds.west.toStringAsFixed(3)} to ${bounds.north.toStringAsFixed(3)},${bounds.east.toStringAsFixed(3)}'); + } + /// Clear all cached data void clear() { _fetchedAreas.clear(); + _fetchedAreasView = null; _nodes.clear(); + _database?.clearAll(); debugPrint('[NodeSpatialCache] Cache cleared'); } + /// Get fetched areas with timestamps (for coverage overlay visualization). + /// Cached to avoid allocating a new wrapper on every map rebuild. + List? _fetchedAreasView; + List get fetchedAreas => + _fetchedAreasView ??= List.unmodifiable(_fetchedAreas); + /// Get cache statistics for debugging CacheStats get stats => CacheStats( areasCount: _fetchedAreas.length, @@ -187,4 +279,4 @@ extension LatLngBoundsExtension on LatLngBounds { east >= other.east && west <= other.west; } -} \ No newline at end of file +} diff --git a/lib/services/offline_areas/offline_tile_utils.dart b/lib/services/offline_areas/offline_tile_utils.dart index 7b283f47..5a5e130c 100644 --- a/lib/services/offline_areas/offline_tile_utils.dart +++ b/lib/services/offline_areas/offline_tile_utils.dart @@ -93,6 +93,19 @@ double sinh(double x) { return (exp(x) - exp(-x)) / 2; } +/// Expand bounds by [factor] around its center point. +/// A factor of 1.0 returns the original bounds; 2.0 doubles the span. +LatLngBounds expandBounds(LatLngBounds bounds, double factor) { + final centerLat = (bounds.north + bounds.south) / 2; + final centerLng = (bounds.east + bounds.west) / 2; + final latSpan = (bounds.north - bounds.south) * factor / 2; + final lngSpan = (bounds.east - bounds.west) * factor / 2; + return LatLngBounds( + LatLng(centerLat - latSpan, centerLng - lngSpan), + LatLng(centerLat + latSpan, centerLng + lngSpan), + ); +} + LatLngBounds globalWorldBounds() { // Use slightly shrunken bounds to avoid tile index overflow at extreme coordinates return LatLngBounds(LatLng(-85.0, -179.9), LatLng(85.0, 179.9)); diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 0d0f7ca5..2c6b4854 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -13,11 +13,14 @@ import 'http_client.dart'; /// Single responsibility: Make requests, handle network errors, return data. class OverpassService { static const String _endpoint = 'https://overpass-api.de/api/interpreter'; + static const String _statusEndpoint = 'https://overpass-api.de/api/status'; + static const int defaultSlotCount = 2; // Overpass public instance consistently has 2 slots per IP + static final _waitSecondsRegex = RegExp(r'in (\d+) seconds'); + final http.Client _client; OverpassService({http.Client? client}) : _client = client ?? UserAgentClient(); - /// Fetch surveillance nodes from Overpass API with proper retry logic. /// Throws NetworkError for retryable failures, NodeLimitError for area splitting. Future> fetchNodes({ @@ -26,20 +29,33 @@ class OverpassService { int maxRetries = 3, }) async { if (profiles.isEmpty) return []; - + + // Pre-flight: check /api/status before sending the expensive POST. + // Avoids burning 2-5s on a request that Overpass queues then rejects. + final waitSeconds = await secondsUntilSlotAvailable(); + if (waitSeconds > 0) { + debugPrint('[OverpassService] No slot available (pre-flight), next in ${waitSeconds}s'); + throw RateLimitError('No slot available (pre-flight check)', waitSeconds: waitSeconds); + } + // waitSeconds == -1 means status check failed — proceed optimistically + final query = _buildQuery(bounds, profiles); - + for (int attempt = 0; attempt <= maxRetries; attempt++) { try { debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles'); - + + final sw = Stopwatch()..start(); final response = await _client.post( Uri.parse(_endpoint), body: {'data': query}, ).timeout(kOverpassQueryTimeout); - + final httpMs = sw.elapsedMilliseconds; + if (response.statusCode == 200) { - return _parseResponse(response.body); + final nodes = _parseResponse(response.body); + debugPrint('[OverpassService] ${nodes.length} nodes (http: ${httpMs}ms, total: ${sw.elapsedMilliseconds}ms)'); + return nodes; } // Check for specific error types @@ -60,12 +76,18 @@ class OverpassService { throw NodeLimitError('Query timed out - area too complex'); } - // Rate limit - throw immediately, don't retry + // Rate limit - throw immediately, don't retry. + // Check /api/status for the actual wait time so reconciliation + // can schedule at the right moment. if (response.statusCode == 429 || errorBody.contains('rate limited') || errorBody.contains('too many requests')) { - debugPrint('[OverpassService] Rate limited by Overpass'); - throw RateLimitError('Rate limited by Overpass API'); + final rawWait = await secondsUntilSlotAvailable(); + // If wait is unknown/non-positive (e.g. -1 on error), fall back to + // a safer default instead of retrying after just 1s. + final wait = (rawWait <= 0 ? 10 : rawWait).clamp(1, 30); + debugPrint('[OverpassService] Rate limited by Overpass (wait: ${wait}s, raw: ${rawWait}s)'); + throw RateLimitError('Rate limited by Overpass API', waitSeconds: wait); } // Other HTTP errors - retry with backoff @@ -99,6 +121,33 @@ class OverpassService { throw NetworkError('Max retries exceeded'); } + /// Check Overpass slot availability. Returns seconds until a slot is free, + /// or 0 if a slot is available now. Returns -1 on error (proceed optimistically). + Future secondsUntilSlotAvailable() async { + try { + final response = await _client.get(Uri.parse(_statusEndpoint)) + .timeout(const Duration(seconds: 3)); + if (response.statusCode == 200) { + if (response.body.contains('slots available now')) return 0; + return _parseWaitSeconds(response.body); + } + return -1; + } catch (_) { + return -1; // On error, proceed optimistically + } + } + + /// Parse "in N seconds" from Overpass status response body. + /// Adds 2s buffer — Overpass timing is approximate and arriving right + /// at the boundary often results in another rate limit. + static int _parseWaitSeconds(String body) { + final match = _waitSecondsRegex.firstMatch(body); + if (match != null) { + return (int.parse(match.group(1)!) + 2).clamp(3, 32); + } + return 5; // Can't parse, assume 5s + } + /// Build Overpass QL query for given bounds and profiles String _buildQuery(LatLngBounds bounds, List profiles) { final nodeClauses = profiles.map((profile) { @@ -175,12 +224,15 @@ class NodeLimitError extends Error { String toString() => 'NodeLimitError: $message'; } -/// Error thrown when rate limited - should not retry immediately +/// Error thrown when rate limited - should not retry immediately. +/// [waitSeconds] carries the delay from /api/status so callers can schedule +/// a retry at the right time instead of using a fixed delay. class RateLimitError extends Error { final String message; - RateLimitError(this.message); + final int waitSeconds; + RateLimitError(this.message, {this.waitSeconds = 5}); @override - String toString() => 'RateLimitError: $message'; + String toString() => 'RateLimitError: $message (wait: ${waitSeconds}s)'; } /// Error thrown for network/HTTP issues - retryable diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 7fbc6d09..a3e4630d 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -38,6 +38,7 @@ class SettingsState extends ChangeNotifier { static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing'; static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance'; static const String _distanceUnitPrefsKey = 'distance_unit'; + static const String _showCoverageOverlayPrefsKey = 'show_coverage_overlay'; bool _offlineMode = false; bool _pauseQueueProcessing = false; @@ -53,6 +54,7 @@ class SettingsState extends ChangeNotifier { String _selectedTileTypeId = ''; int _navigationAvoidanceDistance = 250; // meters DistanceUnit _distanceUnit = DistanceUnit.metric; + bool _showCoverageOverlay = kEnableDevelopmentModes; // Getters bool get offlineMode => _offlineMode; @@ -68,6 +70,7 @@ class SettingsState extends ChangeNotifier { String get selectedTileTypeId => _selectedTileTypeId; int get navigationAvoidanceDistance => _navigationAvoidanceDistance; DistanceUnit get distanceUnit => _distanceUnit; + bool get showCoverageOverlay => _showCoverageOverlay; /// Get the currently selected tile type TileType? get selectedTileType { @@ -137,6 +140,9 @@ class SettingsState extends ChangeNotifier { // Load suspected location minimum distance _suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100; + + // Load coverage overlay setting (defaults to on in debug, off in prod) + _showCoverageOverlay = prefs.getBool(_showCoverageOverlayPrefsKey) ?? kEnableDevelopmentModes; // Load upload mode (including migration from old test_mode bool) if (prefs.containsKey(_uploadModePrefsKey)) { @@ -395,6 +401,16 @@ class SettingsState extends ChangeNotifier { } } + /// Set coverage overlay visibility + Future setShowCoverageOverlay(bool enabled) async { + if (_showCoverageOverlay != enabled) { + _showCoverageOverlay = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_showCoverageOverlayPrefsKey, enabled); + notifyListeners(); + } + } + /// Set distance unit (metric or imperial) Future setDistanceUnit(DistanceUnit unit) async { if (_distanceUnit != unit) { diff --git a/lib/widgets/map/coverage_overlay.dart b/lib/widgets/map/coverage_overlay.dart new file mode 100644 index 00000000..e0cadc55 --- /dev/null +++ b/lib/widgets/map/coverage_overlay.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../dev_config.dart'; +import '../../services/node_spatial_cache.dart'; + +/// Builds a "fog of war" polygon layer showing which map areas have +/// surveillance data loaded. Unfetched areas are dimmed; fetched areas +/// are clear (with optional debug tinting). +/// +/// Uses a world-spanning polygon with holes punched for each cached area. +class CoverageOverlay { + CoverageOverlay._(); + + static const _freshAge = Duration(minutes: 10); + static const _staleAge = Duration(hours: 1); + + /// Build the coverage overlay layer. Returns null when the overlay is + /// disabled or there are no fetched areas to visualize. + static PolygonLayer? build({ + required List fetchedAreas, + required bool show, + }) { + if (!show || fetchedAreas.isEmpty) return null; + + // World-spanning polygon (the "fog") + const worldPoints = [ + LatLng(-85, -180), + LatLng(-85, 180), + LatLng(85, 180), + LatLng(85, -180), + ]; + + // Convert each fetched area bounds into a hole (clockwise winding) + final holes = fetchedAreas + .map((a) => _boundsToCorners(a.bounds)) + .toList(); + + // Debug mode: visible orange fog + age-colored fetched areas + // Production: very subtle grey fog, no border + final fogColor = kEnableDevelopmentModes + ? const Color(0x30FF6D00) // orange, ~19% opacity + : const Color(0x18000000); // black, ~9% opacity + + final polygons = [ + Polygon( + points: worldPoints, + holePointsList: holes, + color: fogColor, + borderStrokeWidth: 0, + ), + ]; + + // In debug mode, draw age-colored borders around each fetched area + if (kEnableDevelopmentModes) { + final now = DateTime.now(); + for (final area in fetchedAreas) { + final age = now.difference(area.fetchedAt); + final Color fillColor; + final Color borderColor; + if (age < _freshAge) { + fillColor = const Color(0x1800C853); // green ~9% + borderColor = const Color(0x6000C853); // green ~38% + } else if (age < _staleAge) { + fillColor = const Color(0x18FFD600); // yellow ~9% + borderColor = const Color(0x60FFD600); // yellow ~38% + } else { + fillColor = const Color(0x18FF6D00); // orange ~9% + borderColor = const Color(0x60FF6D00); // orange ~38% + } + + polygons.add(Polygon( + points: _boundsToCorners(area.bounds), + color: fillColor, + borderColor: borderColor, + borderStrokeWidth: 1.5, + )); + } + } + + return PolygonLayer(polygons: polygons); + } + + static List _boundsToCorners(LatLngBounds b) => [ + LatLng(b.south, b.west), + LatLng(b.north, b.west), + LatLng(b.north, b.east), + LatLng(b.south, b.east), + ]; +} diff --git a/lib/widgets/map/map_data_manager.dart b/lib/widgets/map/map_data_manager.dart index a74c6da0..8ed36b91 100644 --- a/lib/widgets/map/map_data_manager.dart +++ b/lib/widgets/map/map_data_manager.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; import '../../models/osm_node.dart'; import '../../app_state.dart'; import '../node_provider_with_cache.dart'; import '../../dev_config.dart'; +import '../../services/offline_areas/offline_tile_utils.dart' show expandBounds; /// Manages data fetching, filtering, and node limit logic for the map. /// Handles profile changes, zoom level restrictions, and node rendering limits. @@ -23,20 +23,6 @@ class MapDataManager { } } - /// Expand bounds by the given multiplier, maintaining center point. - /// Used to expand rendering bounds to prevent nodes blinking at screen edges. - LatLngBounds _expandBounds(LatLngBounds bounds, double multiplier) { - final centerLat = (bounds.north + bounds.south) / 2; - final centerLng = (bounds.east + bounds.west) / 2; - - final latSpan = (bounds.north - bounds.south) * multiplier / 2; - final lngSpan = (bounds.east - bounds.west) * multiplier / 2; - - return LatLngBounds( - LatLng(centerLat - latSpan, centerLng - lngSpan), - LatLng(centerLat + latSpan, centerLng + lngSpan), - ); - } /// Get nodes to render based on current map state /// Returns a MapDataResult containing all relevant node data and limit state @@ -55,7 +41,7 @@ class MapDataManager { if (currentZoom >= minZoom) { // Above minimum zoom - get cached nodes with expanded bounds to prevent edge blinking if (mapBounds != null) { - final expandedBounds = _expandBounds(mapBounds, kNodeRenderingBoundsExpansion); + final expandedBounds = expandBounds(mapBounds, kNodeRenderingBoundsExpansion); allNodes = NodeProviderWithCache.instance.getCachedNodesForBounds(expandedBounds); } else { allNodes = []; @@ -68,11 +54,21 @@ class MapDataManager { node.coord.longitude.abs() <= 180; }).toList(); - // Apply rendering limit to prevent UI lag + // Apply rendering limit to prevent UI lag. + // Sort by distance from viewport center so the most visible nodes + // always make the cut, preventing gaps that shift as you pan. if (validNodes.length > maxNodes) { + final centerLat = (mapBounds!.north + mapBounds.south) / 2; + final centerLng = (mapBounds.east + mapBounds.west) / 2; + validNodes.sort((a, b) { + final distA = (a.coord.latitude - centerLat) * (a.coord.latitude - centerLat) + + (a.coord.longitude - centerLng) * (a.coord.longitude - centerLng); + final distB = (b.coord.latitude - centerLat) * (b.coord.latitude - centerLat) + + (b.coord.longitude - centerLng) * (b.coord.longitude - centerLng); + return distA.compareTo(distB); + }); nodesToRender = validNodes.take(maxNodes).toList(); isLimitActive = true; - debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of ${validNodes.length} devices'); } else { nodesToRender = validNodes; isLimitActive = false; @@ -87,6 +83,9 @@ class MapDataManager { // Notify parent if limit state changed (for button disabling) if (isLimitActive != _lastNodeLimitState) { _lastNodeLimitState = isLimitActive; + if (isLimitActive) { + debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of ${allNodes.length} devices'); + } // Schedule callback after build completes to avoid setState during build WidgetsBinding.instance.addPostFrameCallback((_) { onNodeLimitChanged?.call(isLimitActive); diff --git a/lib/widgets/map/node_refresh_controller.dart b/lib/widgets/map/node_refresh_controller.dart index 5fce1b38..d729dd10 100644 --- a/lib/widgets/map/node_refresh_controller.dart +++ b/lib/widgets/map/node_refresh_controller.dart @@ -35,20 +35,30 @@ class NodeRefreshController { required List currentEnabledProfiles, required VoidCallback onProfilesChanged, }) { - if (_lastEnabledProfiles == null || + if (_lastEnabledProfiles == null || !_profileListsEqual(_lastEnabledProfiles!, currentEnabledProfiles)) { + final isInitialSet = _lastEnabledProfiles == null; _lastEnabledProfiles = List.from(currentEnabledProfiles); - - // Handle profile change with cache clearing and refresh + + // Handle profile change with cache clearing and refresh. + // Skip cache clear on initial set — we may have just hydrated from SQLite. WidgetsBinding.instance.addPostFrameCallback((_) { - // Clear node cache to ensure fresh data for new profile combination - _nodeProvider.clearCache(); - // Force display refresh first (for immediate UI update) - _nodeProvider.refreshDisplay(); - // Notify that profiles changed (triggers node refresh) - onProfilesChanged(); + if (!isInitialSet) { + // Clear node cache to ensure fresh data for new profile combination + _nodeProvider.clearCache(); + // Force display refresh first (for immediate UI update) + _nodeProvider.refreshDisplay(); + // Notify that profiles changed (triggers node refresh) + onProfilesChanged(); + } else { + // Initial set: just refresh display with whatever is in cache + // (may include SQLite-hydrated data). Don't trigger a fetch — + // the camera move handler will do that when GPS or saved position + // moves the map. + _nodeProvider.refreshDisplay(); + } }); - + return true; } return false; diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 1c817a46..bd6e4ef7 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -27,6 +27,8 @@ import '../dev_config.dart'; import '../services/proximity_alert_service.dart'; import 'sheet_aware_map.dart'; import 'custom_scale_bar.dart'; +import 'map/coverage_overlay.dart'; +import '../services/node_data_manager.dart'; class MapView extends StatefulWidget { final AnimatedMapController controller; @@ -195,10 +197,9 @@ class MapViewState extends State { isUserInteracting: () => _activePointers > 0, ); - // Fetch initial cameras - WidgetsBinding.instance.addPostFrameCallback((_) { - _refreshNodesFromProvider(); - }); + // Don't eagerly fetch on startup — wait for GPS fix or user interaction. + // The map's onCameraMove handler will trigger a fetch when the camera moves + // (either from GPS, saved position restore, or user panning). } @@ -210,6 +211,7 @@ class MapViewState extends State { _cameraDebounce.dispose(); _tileDebounce.dispose(); _mapPositionDebounce.dispose(); + _constrainedNodeSnapBack.dispose(); _nodeController.dispose(); _tileManager.dispose(); _gpsController.dispose(); @@ -386,9 +388,17 @@ class MapViewState extends State { }, onPointerUp: (_) { if (_activePointers > 0) _activePointers--; + // When all fingers lift, schedule a fetch in case the debounce + // callback was suppressed while fingers were down. + if (_activePointers == 0) { + _cameraDebounce(_refreshNodesFromProvider); + } }, onPointerCancel: (_) { if (_activePointers > 0) _activePointers--; + if (_activePointers == 0) { + _cameraDebounce(_refreshNodesFromProvider); + } }, child: FlutterMap( key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'), @@ -486,10 +496,18 @@ class MapViewState extends State { _positionManager.saveMapPosition(pos.center, pos.zoom); }); - // Request more nodes on any map movement/zoom at valid zoom level (slower debounce) + // Request more nodes on any map movement/zoom at valid zoom level. + // Wait for the map to settle (no fingers down, fling animation done) + // before fetching, to avoid burning Overpass slots on requests that + // go stale immediately when the user keeps panning. final minZoom = _dataManager.getMinZoomForNodes(appState.uploadMode); if (pos.zoom >= minZoom) { - _cameraDebounce(_refreshNodesFromProvider); + _cameraDebounce(() { + // If fingers are still on screen, defer — the next pointer-up + // will trigger another camera move which re-enters this debounce. + if (_activePointers > 0) return; + _refreshNodesFromProvider(); + }); } else { // Skip nodes at low zoom - no loading state needed // Show zoom warning if needed @@ -502,6 +520,10 @@ class MapViewState extends State { selectedProvider: appState.selectedTileProvider, selectedTileType: appState.selectedTileType, ), + ?CoverageOverlay.build( + fetchedAreas: NodeDataManager().fetchedAreas, + show: appState.showCoverageOverlay, + ), cameraLayers, // Custom scale bar that respects user's distance unit preference Builder( diff --git a/lib/widgets/network_status_indicator.dart b/lib/widgets/network_status_indicator.dart index a14fa756..730960c9 100644 --- a/lib/widgets/network_status_indicator.dart +++ b/lib/widgets/network_status_indicator.dart @@ -3,16 +3,72 @@ import 'package:provider/provider.dart'; import '../services/network_status.dart'; import '../services/localization_service.dart'; -class NetworkStatusIndicator extends StatelessWidget { +class NetworkStatusIndicator extends StatefulWidget { final double top; final double left; - + const NetworkStatusIndicator({ super.key, this.top = 56.0, this.left = 8.0, }); + @override + State createState() => _NetworkStatusIndicatorState(); +} + +class _NetworkStatusIndicatorState extends State + with SingleTickerProviderStateMixin { + AnimationController? _countdownController; + int _countdownTotal = 0; // Original total from first rate limit in this cooldown + + void _onStatusChanged() { + if (!mounted) return; + final status = NetworkStatus.instance; + if (status.status == NetworkRequestStatus.rateLimited && + status.rateLimitWaitSeconds > 0) { + setState(() => _updateCountdown(status.rateLimitWaitSeconds)); + } else { + setState(() { + _countdownController?.dispose(); + _countdownController = null; + _countdownTotal = 0; + }); + } + } + + void _updateCountdown(int waitSeconds) { + if (_countdownController == null || !_countdownController!.isAnimating) { + // First rate limit in this cooldown — start fresh + _countdownTotal = waitSeconds; + _countdownController?.dispose(); + _countdownController = AnimationController( + vsync: this, + duration: Duration(seconds: waitSeconds), + )..forward(); + } else { + // Update from server — jump forward to match, keep draining + // e.g. started at 14s, server now says 8s → progress = 1 - 8/14 ≈ 0.43 + final progress = 1.0 - (waitSeconds / _countdownTotal).clamp(0.0, 1.0); + _countdownController!.duration = Duration(seconds: _countdownTotal); + _countdownController!.forward(from: progress); + } + } + + @override + void initState() { + super.initState(); + NetworkStatus.instance.addListener(_onStatusChanged); + _onStatusChanged(); + } + + @override + void dispose() { + NetworkStatus.instance.removeListener(_onStatusChanged); + _countdownController?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -32,50 +88,88 @@ class NetworkStatusIndicator extends StatelessWidget { icon = Icons.hourglass_empty; color = Colors.blue; break; - + case NetworkRequestStatus.splitting: message = locService.t('networkStatus.nodeDataSlow'); icon = Icons.camera_alt_outlined; color = Colors.orange; break; - + case NetworkRequestStatus.success: message = locService.t('networkStatus.success'); icon = Icons.check_circle; color = Colors.green; break; - + case NetworkRequestStatus.timeout: message = locService.t('networkStatus.timedOut'); icon = Icons.hourglass_disabled; color = Colors.orange; break; - + 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(); } + // For rate limited: show countdown circle instead of static icon + Widget iconWidget; + if (networkStatus.status == NetworkRequestStatus.rateLimited && + _countdownController != null) { + iconWidget = AnimatedBuilder( + animation: _countdownController!, + builder: (context, child) { + final remaining = _countdownTotal * + (1.0 - _countdownController!.value); + return SizedBox( + width: 18, + height: 18, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + value: 1.0 - _countdownController!.value, + strokeWidth: 2, + color: color, + backgroundColor: color.withValues(alpha: 0.2), + ), + Text( + '${remaining.ceil()}', + style: TextStyle( + color: color, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + }, + ); + } else { + iconWidget = Icon(icon, size: 16, color: color); + } + return Positioned( - top: top, - left: left, + top: widget.top, + left: widget.left, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -86,11 +180,7 @@ class NetworkStatusIndicator extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - icon, - size: 16, - color: color, - ), + iconWidget, const SizedBox(width: 4), Text( message, @@ -109,4 +199,4 @@ class NetworkStatusIndicator extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/node_provider_with_cache.dart b/lib/widgets/node_provider_with_cache.dart index 09ff7cef..09272764 100644 --- a/lib/widgets/node_provider_with_cache.dart +++ b/lib/widgets/node_provider_with_cache.dart @@ -13,7 +13,22 @@ import '../app_state.dart'; class NodeProviderWithCache extends ChangeNotifier { static final NodeProviderWithCache instance = NodeProviderWithCache._internal(); factory NodeProviderWithCache() => instance; - NodeProviderWithCache._internal(); + NodeProviderWithCache._internal() { + _nodeDataManager.addListener(_onNodeDataManagerUpdate); + } + + void _onNodeDataManagerUpdate() { + notifyListeners(); + } + + /// Singleton — dispose is only safe at app shutdown; included for completeness. + @override + void dispose() { + _debounceTimer?.cancel(); + _debounceTimer = null; + _nodeDataManager.removeListener(_onNodeDataManagerUpdate); + super.dispose(); + } final NodeDataManager _nodeDataManager = NodeDataManager(); Timer? _debounceTimer; @@ -52,10 +67,7 @@ class NodeProviderWithCache extends ChangeNotifier { uploadMode: uploadMode, isUserInitiated: true, ); - - // Notify UI of new data - notifyListeners(); - + // NodeDataManager.notifyListeners() propagates via _onNodeDataManagerUpdate } catch (e) { debugPrint('[NodeProviderWithCache] Node fetch failed: $e'); // Cache already holds whatever is available for the view diff --git a/pubspec.lock b/pubspec.lock index b61873af..8559a396 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -624,14 +624,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.3" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" package_info_plus: dependency: "direct main" description: @@ -684,10 +676,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.5.1" path_provider_linux: dependency: transitive description: @@ -869,6 +861,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.6" + sqflite_common_ffi: + dependency: "direct dev" + description: + name: sqflite_common_ffi + sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff + url: "https://pub.dev" + source: hosted + version: "2.4.0+2" sqflite_darwin: dependency: transitive description: @@ -885,6 +885,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: b7cf6b37667f6a921281797d2499ffc60fb878b161058d422064f0ddc78f6aa6 + url: "https://pub.dev" + source: hosted + version: "3.1.6" stack_trace: dependency: transitive description: @@ -1134,5 +1142,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index 6269a4b4..ba3efc90 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: "none" version: 2.9.0+51 # The thing after the + is the version code, incremented with each release environment: - sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+) + sdk: ">=3.10.0 <4.0.0" # Resolved dependency floor (Dart 3.10 = Flutter 3.38+) dependencies: flutter: @@ -42,8 +42,9 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + fake_async: ^1.3.3 mocktail: ^1.0.4 - fake_async: ^1.3.0 + sqflite_common_ffi: ^2.3.4+4 flutter_launcher_icons: ^0.14.4 flutter_lints: ^6.0.0 flutter_native_splash: ^2.4.6 diff --git a/test/services/node_cache_database_test.dart b/test/services/node_cache_database_test.dart new file mode 100644 index 00000000..2e5e996f --- /dev/null +++ b/test/services/node_cache_database_test.dart @@ -0,0 +1,277 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:deflockapp/models/osm_node.dart'; +import 'package:deflockapp/services/node_cache_database.dart'; +import 'package:deflockapp/services/node_data_manager.dart'; +import 'package:deflockapp/services/node_spatial_cache.dart'; + +void main() { + // Use FFI for headless SQLite testing + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + + late NodeCacheDatabase db; + + setUp(() async { + db = NodeCacheDatabase.forTesting(); + await db.init(); + await db.clearAll(); + }); + + tearDown(() async { + await db.close(); + }); + + final testBounds = LatLngBounds( + const LatLng(38.0, -78.0), + const LatLng(39.0, -77.0), + ); + + OsmNode makeNode(int id, { + double lat = 38.5, + double lng = -77.5, + Map tags = const {'man_made': 'surveillance'}, + bool isConstrained = false, + }) => OsmNode( + id: id, + coord: LatLng(lat, lng), + tags: tags, + isConstrained: isConstrained, + ); + + group('node insert/load round-trip', () { + test('inserts and loads nodes with correct data', () async { + final nodes = [ + makeNode(1, tags: {'man_made': 'surveillance', 'operator': 'city'}), + makeNode(2, lat: 38.6, lng: -77.6, isConstrained: true), + ]; + + await db.insertNodes(nodes); + final loaded = await db.loadAllNodes(); + + expect(loaded, hasLength(2)); + + final node1 = loaded.firstWhere((n) => n.id == 1); + expect(node1.coord.latitude, 38.5); + expect(node1.coord.longitude, -77.5); + expect(node1.tags['man_made'], 'surveillance'); + expect(node1.tags['operator'], 'city'); + expect(node1.isConstrained, false); + + final node2 = loaded.firstWhere((n) => n.id == 2); + expect(node2.coord.latitude, 38.6); + expect(node2.coord.longitude, -77.6); + expect(node2.isConstrained, true); + }); + + test('upserts replace existing nodes', () async { + await db.insertNodes([makeNode(1, tags: {'version': '1'})]); + await db.insertNodes([makeNode(1, tags: {'version': '2'})]); + + final loaded = await db.loadAllNodes(); + expect(loaded, hasLength(1)); + expect(loaded.first.tags['version'], '2'); + }); + }); + + group('negative ID filtering', () { + test('does not persist negative-ID nodes', () async { + final nodes = [ + makeNode(-1), + makeNode(-999), + makeNode(1), + makeNode(42), + ]; + + await db.insertNodes(nodes); + final loaded = await db.loadAllNodes(); + + expect(loaded, hasLength(2)); + expect(loaded.map((n) => n.id).toSet(), {1, 42}); + }); + }); + + group('underscore tag stripping', () { + test('strips underscore-prefixed tags before persisting', () async { + final node = makeNode(1, tags: { + 'man_made': 'surveillance', + '_pending_edit': 'true', + '_pending_deletion': 'true', + 'operator': 'city', + }); + + await db.insertNodes([node]); + final loaded = await db.loadAllNodes(); + + expect(loaded, hasLength(1)); + expect(loaded.first.tags.containsKey('_pending_edit'), false); + expect(loaded.first.tags.containsKey('_pending_deletion'), false); + expect(loaded.first.tags['man_made'], 'surveillance'); + expect(loaded.first.tags['operator'], 'city'); + }); + }); + + group('cached area insert/load round-trip', () { + test('inserts and loads cached areas within TTL', () async { + final now = DateTime.now(); + await db.insertCachedArea(testBounds, now); + + final loaded = await db.loadCachedAreas(ttl: const Duration(days: 7)); + + expect(loaded, hasLength(1)); + expect(loaded.first.bounds.south, testBounds.south); + expect(loaded.first.bounds.west, testBounds.west); + expect(loaded.first.bounds.north, testBounds.north); + expect(loaded.first.bounds.east, testBounds.east); + }); + + test('does not load areas past TTL', () async { + final old = DateTime.now().subtract(const Duration(days: 8)); + await db.insertCachedArea(testBounds, old); + + final loaded = await db.loadCachedAreas(ttl: const Duration(days: 7)); + expect(loaded, isEmpty); + }); + }); + + group('TTL expiration pruning', () { + test('deleteExpiredData removes expired areas', () async { + final old = DateTime.now().subtract(const Duration(days: 8)); + final fresh = DateTime.now(); + + final bounds2 = LatLngBounds( + const LatLng(40.0, -76.0), + const LatLng(41.0, -75.0), + ); + + await db.insertCachedArea(testBounds, old); + await db.insertCachedArea(bounds2, fresh); + + await db.deleteExpiredData(ttl: const Duration(days: 7)); + + final loaded = await db.loadCachedAreas(ttl: const Duration(days: 7)); + expect(loaded, hasLength(1)); + expect(loaded.first.bounds.south, bounds2.south); + }); + }); + + group('orphaned node cleanup', () { + test('deletes nodes not covered by any remaining area', () async { + final old = DateTime.now().subtract(const Duration(days: 8)); + final fresh = DateTime.now(); + + final freshBounds = LatLngBounds( + const LatLng(40.0, -76.0), + const LatLng(41.0, -75.0), + ); + + // Node in expired area only + await db.insertNodes([makeNode(1, lat: 38.5, lng: -77.5)]); + await db.insertCachedArea(testBounds, old); + + // Node in fresh area + await db.insertNodes([makeNode(2, lat: 40.5, lng: -75.5)]); + await db.insertCachedArea(freshBounds, fresh); + + await db.deleteExpiredData(ttl: const Duration(days: 7)); + + final loaded = await db.loadAllNodes(); + expect(loaded, hasLength(1)); + expect(loaded.first.id, 2); + }); + + test('deletes all nodes when all areas expire', () async { + final old = DateTime.now().subtract(const Duration(days: 8)); + + await db.insertNodes([makeNode(1), makeNode(2)]); + await db.insertCachedArea(testBounds, old); + + await db.deleteExpiredData(ttl: const Duration(days: 7)); + + final loaded = await db.loadAllNodes(); + expect(loaded, isEmpty); + }); + }); + + group('clearAll', () { + test('wipes all nodes and areas', () async { + await db.insertNodes([makeNode(1), makeNode(2)]); + await db.insertCachedArea(testBounds, DateTime.now()); + + await db.clearAll(); + + expect(await db.loadAllNodes(), isEmpty); + expect( + await db.loadCachedAreas(ttl: const Duration(days: 7)), + isEmpty, + ); + }); + }); + + group('staleAreaFor', () { + test('returns null when no coverage', () { + final cache = NodeSpatialCache.forTesting(); + expect(cache.staleAreaFor(testBounds), isNull); + }); + + test('returns null for fresh data', () { + final cache = NodeSpatialCache.forTesting(); + cache.markAreaAsFetched(testBounds, [makeNode(1)]); + expect(cache.staleAreaFor(testBounds), isNull); + }); + + test('freshThreshold is 1 hour', () { + // Verify the threshold constant so stale logic is correct + expect(NodeSpatialCache.freshThreshold, const Duration(hours: 1)); + }); + }); + + group('ring cell generation', () { + test('ring 1 produces 8 cells', () { + final viewport = LatLngBounds( + const LatLng(0.0, 0.0), + const LatLng(1.0, 1.0), + ); + + final cells = NodeDataManager.generateRingCells(viewport, 1); + expect(cells, hasLength(8)); + }); + + test('ring 2 produces 8 + 16 = 24 cells', () { + final viewport = LatLngBounds( + const LatLng(0.0, 0.0), + const LatLng(1.0, 1.0), + ); + + final cells = NodeDataManager.generateRingCells(viewport, 2); + expect(cells, hasLength(24)); // 8 + 16 + }); + + test('ring 3 produces 8 + 16 + 24 = 48 cells', () { + final viewport = LatLngBounds( + const LatLng(0.0, 0.0), + const LatLng(1.0, 1.0), + ); + + final cells = NodeDataManager.generateRingCells(viewport, 3); + expect(cells, hasLength(48)); + }); + + test('cells are viewport-sized', () { + final viewport = LatLngBounds( + const LatLng(10.0, 20.0), + const LatLng(12.0, 23.0), + ); + + final cells = NodeDataManager.generateRingCells(viewport, 1); + + for (final cell in cells) { + expect(cell.north - cell.south, closeTo(2.0, 1e-10)); + expect(cell.east - cell.west, closeTo(3.0, 1e-10)); + } + }); + }); +} diff --git a/test/services/node_data_manager_test.dart b/test/services/node_data_manager_test.dart new file mode 100644 index 00000000..116e7592 --- /dev/null +++ b/test/services/node_data_manager_test.dart @@ -0,0 +1,668 @@ +import 'dart:async'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/osm_node.dart'; +import 'package:deflockapp/services/overpass_service.dart'; +import 'package:deflockapp/services/node_data_manager.dart'; +import 'package:deflockapp/services/node_spatial_cache.dart'; + +class MockOverpassService extends Mock implements OverpassService {} + +class MockNodeSpatialCache extends Mock implements NodeSpatialCache {} + + +void main() { + final testBounds = LatLngBounds( + const LatLng(38.0, -78.0), + const LatLng(39.0, -77.0), + ); + + final testProfiles = [ + NodeProfile( + id: 'test', + name: 'Test Profile', + tags: const {'man_made': 'surveillance'}, + ), + ]; + + OsmNode makeNode(int id, {double lat = 38.5, double lng = -77.5}) => OsmNode( + id: id, + coord: LatLng(lat, lng), + tags: const {'man_made': 'surveillance'}, + ); + + setUpAll(() { + registerFallbackValue(testBounds); + registerFallbackValue([]); + registerFallbackValue([]); + registerFallbackValue(Uri.parse('https://example.com')); + registerFallbackValue(const Duration(seconds: 1)); + }); + + group('splitBounds', () { + test('splits into 4 correct quadrants with center at midpoint', () { + final bounds = LatLngBounds( + const LatLng(0.0, 0.0), + const LatLng(10.0, 10.0), + ); + + final quadrants = NodeDataManager.splitBounds(bounds); + + expect(quadrants, hasLength(4)); + + // Southwest + expect(quadrants[0].south, 0.0); + expect(quadrants[0].west, 0.0); + expect(quadrants[0].north, 5.0); + expect(quadrants[0].east, 5.0); + + // Southeast + expect(quadrants[1].south, 0.0); + expect(quadrants[1].west, 5.0); + expect(quadrants[1].north, 5.0); + expect(quadrants[1].east, 10.0); + + // Northwest + expect(quadrants[2].south, 5.0); + expect(quadrants[2].west, 0.0); + expect(quadrants[2].north, 10.0); + expect(quadrants[2].east, 5.0); + + // Northeast + expect(quadrants[3].south, 5.0); + expect(quadrants[3].west, 5.0); + expect(quadrants[3].north, 10.0); + expect(quadrants[3].east, 10.0); + }); + + test('quadrants tile exactly - no gaps or overlaps', () { + final bounds = LatLngBounds( + const LatLng(10.0, 20.0), + const LatLng(30.0, 40.0), + ); + + final quadrants = NodeDataManager.splitBounds(bounds); + + // Summing all quadrant spans gives 2x original (2 rows + 2 columns of half-spans) + final totalLatSpan = quadrants.map((q) => q.north - q.south).reduce((a, b) => a + b); + final totalLngSpan = quadrants.map((q) => q.east - q.west).reduce((a, b) => a + b); + expect(totalLatSpan, closeTo((bounds.north - bounds.south) * 2, 1e-10)); + expect(totalLngSpan, closeTo((bounds.east - bounds.west) * 2, 1e-10)); + + // Verify edges align at center + final centerLat = (bounds.north + bounds.south) / 2; + final centerLng = (bounds.east + bounds.west) / 2; + + for (final q in quadrants) { + // Every quadrant edge should be either an original edge or the center + expect( + q.south == bounds.south || q.south == centerLat, + isTrue, + reason: 'south edge ${q.south} should be original south or center', + ); + expect( + q.north == bounds.north || q.north == centerLat, + isTrue, + reason: 'north edge ${q.north} should be original north or center', + ); + expect( + q.west == bounds.west || q.west == centerLng, + isTrue, + reason: 'west edge ${q.west} should be original west or center', + ); + expect( + q.east == bounds.east || q.east == centerLng, + isTrue, + reason: 'east edge ${q.east} should be original east or center', + ); + } + }); + }); + + group('fetchWithSplitting', () { + late MockOverpassService mockOverpass; + late MockNodeSpatialCache mockCache; + late NodeDataManager manager; + + setUp(() { + mockOverpass = MockOverpassService(); + mockCache = MockNodeSpatialCache(); + manager = NodeDataManager.forTesting( + overpassService: mockOverpass, + cache: mockCache, + ); + + // Default: cache operations are no-ops + when(() => mockCache.markAreaAsFetched(any(), any())).thenReturn(null); + }); + + test('happy path - returns nodes and caches them', () async { + final nodes = [makeNode(1), makeNode(2)]; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async => nodes); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + expect(result, hasLength(2)); + verify(() => mockCache.markAreaAsFetched(any(), any())).called(1); + }); + + test('NodeLimitError splits into 4 and combines results', () async { + var callCount = 0; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw NodeLimitError('too many nodes'); + } + return [makeNode(callCount)]; + }); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + // First call throws, then 4 quadrant calls succeed + expect(result, hasLength(4)); + verify(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).called(5); // 1 initial + 4 quadrants + }); + + test('max depth + NodeLimitError returns empty', () async { + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenThrow(NodeLimitError('too many nodes')); + + final result = await manager.fetchWithSplitting( + testBounds, testProfiles, + splitDepth: 3, + ); + + expect(result, isEmpty); + }); + + test('RateLimitError is rethrown for reconciliation to handle', () async { + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenThrow(RateLimitError('rate limited')); + + expect( + () => manager.fetchWithSplitting(testBounds, testProfiles), + throwsA(isA()), + ); + }); + + test('RateLimitError carries waitSeconds from pre-flight', () async { + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenThrow(RateLimitError('rate limited', waitSeconds: 14)); + + try { + await manager.fetchWithSplitting(testBounds, testProfiles); + fail('should have thrown'); + } on RateLimitError catch (e) { + expect(e.waitSeconds, 14); + } + }); + + test('RateLimitError in quadrant fetch propagates instead of being swallowed', () async { + var callCount = 0; + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw NodeLimitError('too many nodes'); + } + // First quadrant succeeds, second hits rate limit + if (callCount == 3) { + throw RateLimitError('rate limited', waitSeconds: 10); + } + return [makeNode(callCount)]; + }); + + expect( + () => manager.fetchWithSplitting(testBounds, testProfiles), + throwsA(isA()), + ); + }); + }); + + group('_fetchSplitAreas (via fetchWithSplitting)', () { + late MockOverpassService mockOverpass; + late MockNodeSpatialCache mockCache; + late NodeDataManager manager; + + setUp(() { + mockOverpass = MockOverpassService(); + mockCache = MockNodeSpatialCache(); + manager = NodeDataManager.forTesting( + overpassService: mockOverpass, + cache: mockCache, + ); + + when(() => mockCache.markAreaAsFetched(any(), any())).thenReturn(null); + }); + + test('partial failure - 1 quadrant throws, other 3 return nodes', () async { + var callCount = 0; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + // Initial call: trigger split + throw NodeLimitError('too many nodes'); + } + if (callCount == 2) { + // First quadrant: network error + throw NetworkError('connection failed'); + } + // Other 3 quadrants succeed + return [makeNode(callCount)]; + }); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + // 3 of 4 quadrants returned 1 node each + expect(result, hasLength(3)); + }); + + test('all quadrants fail returns empty', () async { + var callCount = 0; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw NodeLimitError('too many nodes'); + } + throw NetworkError('connection failed'); + }); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + expect(result, isEmpty); + }); + + test('recursive splitting - depth-1 NodeLimitError, depth-2 success', () async { + // Track split depth via bounds size to determine behavior + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((invocation) async { + final bounds = invocation.namedArguments[#bounds] as LatLngBounds; + final latSpan = bounds.north - bounds.south; + // Original expanded bounds have ~1.2x span; depth-1 quadrants are ~half + // Depth-0 and depth-1 hit node limit; depth-2 succeeds + if (latSpan > 0.3) { + throw NodeLimitError('too many nodes'); + } + return [makeNode(bounds.hashCode)]; + }); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + // 4 quadrants at depth 1 each split into 4 = 16 depth-2 fetches + expect(result, hasLength(16)); + }); + }); + + group('stale fetch cancellation', () { + late MockOverpassService mockOverpass; + late MockNodeSpatialCache mockCache; + late NodeDataManager manager; + + setUp(() { + mockOverpass = MockOverpassService(); + mockCache = MockNodeSpatialCache(); + manager = NodeDataManager.forTesting( + overpassService: mockOverpass, + cache: mockCache, + ); + + when(() => mockCache.markAreaAsFetched(any(), any())).thenReturn(null); + }); + + test('stale generation skips fetch entirely', () async { + manager.advanceFetchGeneration(); + + final result = await manager.fetchWithSplitting( + testBounds, testProfiles, + generation: 0, + ); + + expect(result, isEmpty); + verifyNever(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )); + }); + + test('stale generation inside semaphore lambda prevents HTTP call', () async { + // Saturate the semaphore (2 slots) so the third request waits + final completer1 = Completer>(); + final completer2 = Completer>(); + var fetchCallCount = 0; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) { + fetchCallCount++; + if (fetchCallCount == 1) return completer1.future; + if (fetchCallCount == 2) return completer2.future; + return Future.value([makeNode(fetchCallCount)]); + }); + + // Launch two requests to fill both semaphore slots + final future1 = manager.fetchWithSplitting(testBounds, testProfiles); + final future2 = manager.fetchWithSplitting(testBounds, testProfiles); + await Future.delayed(Duration.zero); // Let them enter the semaphore + + // Third request will queue in semaphore + final future3 = manager.fetchWithSplitting( + testBounds, testProfiles, + generation: 0, + ); + + // Advance generation while third request is queued + manager.advanceFetchGeneration(); + + // Release first two + completer1.complete([makeNode(1)]); + completer2.complete([makeNode(2)]); + await future1; + await future2; + + // Third request wakes up but is stale — should skip HTTP call + final result3 = await future3; + expect(result3, isEmpty); + // Only 2 HTTP calls (from the first two), not 3 + expect(fetchCallCount, 2); + }); + + test('stale generation prevents recursive splitting', () async { + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + manager.advanceFetchGeneration(); + throw NodeLimitError('too many nodes'); + }); + + final result = await manager.fetchWithSplitting( + testBounds, testProfiles, + generation: 0, + ); + + expect(result, isEmpty); + // Only the initial call, no quadrant fetches + verify(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).called(1); + }); + + test('null generation is never stale (backward compat)', () async { + final nodes = [makeNode(1), makeNode(2)]; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async => nodes); + + // Advance generation many times + for (var i = 0; i < 10; i++) { + manager.advanceFetchGeneration(); + } + + // Call without generation parameter — null generation is never stale + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + expect(result, hasLength(2)); + verify(() => mockCache.markAreaAsFetched(any(), any())).called(1); + }); + }); + + group('progressive rendering (throttled)', () { + late MockOverpassService mockOverpass; + late MockNodeSpatialCache mockCache; + late NodeDataManager manager; + + setUp(() { + mockOverpass = MockOverpassService(); + mockCache = MockNodeSpatialCache(); + manager = NodeDataManager.forTesting( + overpassService: mockOverpass, + cache: mockCache, + ); + + when(() => mockCache.markAreaAsFetched(any(), any())).thenReturn(null); + }); + + test('rapid quadrant completions are batched into one notification', () { + FakeAsync().run((fake) { + var notifyCount = 0; + manager.addListener(() => notifyCount++); + + var callCount = 0; + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw NodeLimitError('too many nodes'); + } + return [makeNode(callCount)]; + }); + + late List result; + manager.fetchWithSplitting(testBounds, testProfiles).then((r) => result = r); + + // Let all futures complete + fake.elapse(Duration.zero); + expect(result, hasLength(4)); + + // No notifications yet — throttle timer is pending + expect(notifyCount, 0); + + // After 200ms throttle window, one batched notification fires + fake.elapse(const Duration(milliseconds: 200)); + expect(notifyCount, 1); + }); + }); + + test('empty quadrant results do not trigger notification', () { + FakeAsync().run((fake) { + var notifyCount = 0; + manager.addListener(() => notifyCount++); + + var callCount = 0; + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw NodeLimitError('too many nodes'); + } + // All quadrants return empty + return []; + }); + + late List result; + manager.fetchWithSplitting(testBounds, testProfiles).then((r) => result = r); + + fake.elapse(Duration.zero); + expect(result, isEmpty); + + // No notifications even after throttle window — no nodes to render + fake.elapse(const Duration(milliseconds: 200)); + expect(notifyCount, 0); + }); + }); + }); + + group('semaphore concurrency', () { + late MockOverpassService mockOverpass; + late MockNodeSpatialCache mockCache; + late NodeDataManager manager; + + setUp(() { + mockOverpass = MockOverpassService(); + mockCache = MockNodeSpatialCache(); + manager = NodeDataManager.forTesting( + overpassService: mockOverpass, + cache: mockCache, + ); + + when(() => mockCache.markAreaAsFetched(any(), any())).thenReturn(null); + }); + + test('semaphore allows up to 2 concurrent Overpass requests', () async { + var concurrentCount = 0; + var maxConcurrent = 0; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + concurrentCount++; + if (concurrentCount > maxConcurrent) maxConcurrent = concurrentCount; + await Future.delayed(const Duration(milliseconds: 10)); + concurrentCount--; + return [makeNode(1)]; + }); + + // Three concurrent user-initiated fetches — semaphore limits to 2 (Overpass slot count) + await Future.wait([ + manager.fetchWithSplitting(testBounds, testProfiles, isUserInitiated: true), + manager.fetchWithSplitting(testBounds, testProfiles, isUserInitiated: true), + manager.fetchWithSplitting(testBounds, testProfiles, isUserInitiated: true), + ]); + + expect(maxConcurrent, 2); + // All three completed (3 HTTP calls total) + verify(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).called(3); + }); + + test('priority request jumps ahead of background requests in queue', () async { + // Use completers so we control exactly when slot-fillers finish + final slotBlockers = [Completer(), Completer()]; + final completionOrder = []; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + return [makeNode(1)]; + }); + + // Wrap fetchNodes to block slot-fillers + var callCount = 0; + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + final idx = callCount++; + if (idx < 2) { + await slotBlockers[idx].future; + } + return [makeNode(idx)]; + }); + + // Fill both semaphore slots + final bg1 = manager.fetchWithSplitting(testBounds, testProfiles, isUserInitiated: true); + final bg2 = manager.fetchWithSplitting(testBounds, testProfiles, isUserInitiated: true); + + // Queue background FIRST, then priority — priority should still run first + final bg3 = manager.fetchWithSplitting(testBounds, testProfiles) + .then((_) => completionOrder.add('background')); + final priority = manager.fetchWithSplitting(testBounds, testProfiles, isUserInitiated: true) + .then((_) => completionOrder.add('priority')); + + // Release both slots + slotBlockers[0].complete(); + slotBlockers[1].complete(); + + await Future.wait([bg1, bg2, bg3, priority]); + + // Priority was queued second but should have completed first + expect(completionOrder.indexOf('priority'), lessThan(completionOrder.indexOf('background')), + reason: 'Priority request should complete before background request'); + }); + }); + + group('hasFreshDataFor', () { + test('returns true for recently cached area', () { + final cache = NodeSpatialCache.forTesting(); + final bounds = LatLngBounds(const LatLng(38, -78), const LatLng(39, -77)); + cache.markAreaAsFetched(bounds, [makeNode(1)]); + expect(cache.hasFreshDataFor(bounds), isTrue); + }); + + test('returns false for uncached area', () { + final cache = NodeSpatialCache.forTesting(); + final bounds = LatLngBounds(const LatLng(38, -78), const LatLng(39, -77)); + expect(cache.hasFreshDataFor(bounds), isFalse); + }); + + test('returns true for sub-bounds of cached area', () { + final cache = NodeSpatialCache.forTesting(); + final outer = LatLngBounds(const LatLng(37, -79), const LatLng(40, -76)); + final inner = LatLngBounds(const LatLng(38, -78), const LatLng(39, -77)); + cache.markAreaAsFetched(outer, [makeNode(1)]); + expect(cache.hasFreshDataFor(inner), isTrue); + }); + }); + + group('fetchedAreas', () { + test('returns empty list for fresh cache', () { + final cache = NodeSpatialCache.forTesting(); + final manager = NodeDataManager.forTesting(cache: cache); + expect(manager.fetchedAreas, isEmpty); + }); + + test('returns areas with timestamps after marking as fetched', () { + final cache = NodeSpatialCache.forTesting(); + final manager = NodeDataManager.forTesting(cache: cache); + + final bounds1 = LatLngBounds(const LatLng(38, -78), const LatLng(39, -77)); + final bounds2 = LatLngBounds(const LatLng(40, -76), const LatLng(41, -75)); + cache.markAreaAsFetched(bounds1, [makeNode(1)]); + cache.markAreaAsFetched(bounds2, [makeNode(2)]); + + final areas = manager.fetchedAreas; + expect(areas, hasLength(2)); + expect(areas[0].bounds, bounds1); + expect(areas[1].bounds, bounds2); + // Timestamps should be recent + final now = DateTime.now(); + expect(now.difference(areas[0].fetchedAt).inSeconds, lessThan(5)); + expect(now.difference(areas[1].fetchedAt).inSeconds, lessThan(5)); + }); + }); +} diff --git a/test/widgets/coverage_overlay_test.dart b/test/widgets/coverage_overlay_test.dart new file mode 100644 index 00000000..679dd42c --- /dev/null +++ b/test/widgets/coverage_overlay_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:deflockapp/widgets/map/coverage_overlay.dart'; +import 'package:deflockapp/services/node_spatial_cache.dart'; + +void main() { + CachedArea makeArea(double south, double west, double north, double east, + {DateTime? fetchedAt}) { + return CachedArea( + LatLngBounds(LatLng(south, west), LatLng(north, east)), + fetchedAt ?? DateTime.now(), + ); + } + + group('CoverageOverlay.build', () { + test('returns null when show is false', () { + final result = CoverageOverlay.build( + fetchedAreas: [makeArea(38, -78, 39, -77)], + show: false, + ); + expect(result, isNull); + }); + + test('returns null when fetchedAreas is empty', () { + final result = CoverageOverlay.build(fetchedAreas: [], show: true); + expect(result, isNull); + }); + + test('builds correct fog polygon with holes when enabled', () { + final areas = [ + makeArea(38, -78, 39, -77), + makeArea(40, -76, 41, -75), + ]; + + final result = CoverageOverlay.build(fetchedAreas: areas, show: true); + expect(result, isNotNull); + expect(result, isA()); + + final layer = result!; + expect(layer.polygons.isNotEmpty, isTrue); + + // First polygon is the fog (world with holes) + final fog = layer.polygons.first; + expect(fog.points, hasLength(4)); // world corners + expect(fog.holePointsList, hasLength(2)); // one hole per fetched area + expect(fog.holePointsList![0], hasLength(4)); // rectangle = 4 points + expect(fog.holePointsList![1], hasLength(4)); + }); + }); +}