From 20bd04e338dfc85f0fde70bdf2031cadd54db6c5 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sun, 10 Aug 2025 16:42:31 -0500 Subject: [PATCH] first attempts to pull local data from offline areas --- lib/services/map_data_provider.dart | 87 +++++++++++++++---- .../cameras_from_local.dart | 70 +++++++++++++++ .../map_data_submodules/tiles_from_local.dart | 42 +++++++++ lib/widgets/tile_provider_with_cache.dart | 5 -- 4 files changed, 181 insertions(+), 23 deletions(-) diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index 6f93549..10eba77 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -6,6 +6,8 @@ import '../models/osm_camera_node.dart'; import '../app_state.dart'; import 'map_data_submodules/cameras_from_overpass.dart'; import 'map_data_submodules/tiles_from_osm.dart'; +import 'map_data_submodules/cameras_from_local.dart'; +import 'map_data_submodules/tiles_from_local.dart'; enum MapSource { local, remote, auto } // For future use @@ -28,7 +30,8 @@ class MapDataProvider { AppState.instance.setOfflineMode(enabled); } - /// Fetch cameras from OSM/Overpass or local storage, depending on source/offline mode. + /// Fetch cameras from OSM/Overpass or local storage. + /// Remote is default. If source is MapSource.auto, remote is tried first unless offline. Future> getCameras({ required LatLngBounds bounds, required List profiles, @@ -37,16 +40,13 @@ class MapDataProvider { }) async { final offline = AppState.instance.offlineMode; print('[MapDataProvider] getCameras called, source=$source, offlineMode=$offline'); - // Resolve source: - if (offline && source != MapSource.local) { - print('[MapDataProvider] BLOCKED by offlineMode for getCameras'); - throw OfflineModeException("Cannot fetch remote cameras in offline mode."); - } - if (source == MapSource.local) { - // TODO: implement local camera loading - throw UnimplementedError('Local camera loading not yet implemented.'); - } else { - // Use Overpass remote fetch, from submodule: + + // Explicit remote request: error if offline, else always remote + if (source == MapSource.remote) { + if (offline) { + print('[MapDataProvider] BLOCKED by offlineMode for remote camera fetch'); + throw OfflineModeException("Cannot fetch remote cameras in offline mode."); + } return camerasFromOverpass( bounds: bounds, profiles: profiles, @@ -54,21 +54,72 @@ class MapDataProvider { maxCameras: AppState.instance.maxCameras, ); } + + // Explicit local request: always use local + if (source == MapSource.local) { + return fetchLocalCameras( + bounds: bounds, + profiles: profiles, + ); + } + + // AUTO: default = remote first, fallback to local only if offline + if (offline) { + return fetchLocalCameras( + bounds: bounds, + profiles: profiles, + ); + } else { + // Try remote, fallback to local ONLY if remote throws (optional, could be removed for stricter behavior) + try { + return await camerasFromOverpass( + bounds: bounds, + profiles: profiles, + uploadMode: uploadMode, + maxCameras: AppState.instance.maxCameras, + ); + } catch (e) { + print('[MapDataProvider] Remote camera fetch failed, error: $e. Falling back to local.'); + return fetchLocalCameras( + bounds: bounds, + profiles: profiles, + ); + } + } } - /// Fetch tile image bytes from OSM or local (future). Only fetches, does not save! + /// Fetch tile image bytes. Default is to try local first, then remote if not offline. Honors explicit source. Future> getTile({ required int z, required int x, required int y, MapSource source = MapSource.auto, }) async { - print('[MapDataProvider] getTile called for $z/$x/$y, source=$source'); - if (source == MapSource.local) { - // TODO: implement local tile loading - throw UnimplementedError('Local tile loading not yet implemented.'); - } else { - // Use OSM remote fetch from submodule: + final offline = AppState.instance.offlineMode; + print('[MapDataProvider] getTile called for $z/$x/$y, source=$source, offlineMode=$offline'); + + // Explicitly remote + if (source == MapSource.remote) { + if (offline) { + print('[MapDataProvider] BLOCKED by offlineMode for remote tile fetch'); + throw OfflineModeException("Cannot fetch remote tiles in offline mode."); + } return fetchOSMTile(z: z, x: x, y: y); } + + // Explicitly local + if (source == MapSource.local) { + return fetchLocalTile(z: z, x: x, y: y); + } + + // AUTO (default): try local first, then remote if not offline + try { + return await fetchLocalTile(z: z, x: x, y: y); + } catch (_) { + if (!offline) { + return fetchOSMTile(z: z, x: x, y: y); + } else { + throw OfflineModeException("Tile $z/$x/$y not found in offline areas and offline mode is enabled."); + } + } } } \ No newline at end of file diff --git a/lib/services/map_data_submodules/cameras_from_local.dart b/lib/services/map_data_submodules/cameras_from_local.dart index e69de29..b4a4434 100644 --- a/lib/services/map_data_submodules/cameras_from_local.dart +++ b/lib/services/map_data_submodules/cameras_from_local.dart @@ -0,0 +1,70 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import '../../models/osm_camera_node.dart'; +import '../../models/camera_profile.dart'; +import '../offline_area_service.dart'; +import '../offline_areas/offline_area_models.dart'; + +/// Fetch camera nodes from all offline areas intersecting the bounds/profile list. +Future> fetchLocalCameras({ + required LatLngBounds bounds, + required List profiles, +}) async { + final areas = OfflineAreaService().offlineAreas; + final List result = []; + final seenIds = {}; + + for (final area in areas) { + if (area.status != OfflineAreaStatus.complete) continue; + if (!area.bounds.isOverlapping(bounds)) continue; + + final nodes = await _loadAreaCameras(area); + for (final cam in nodes) { + if (seenIds.contains(cam.id)) continue; + // Check geo bounds + if (!_pointInBounds(cam.coord, bounds)) continue; + // Check profiles + if (profiles.isNotEmpty && !_matchesAnyProfile(cam, profiles)) continue; + result.add(cam); + seenIds.add(cam.id); + } + } + return result; +} + +// Try in-memory first, else load from disk +Future> _loadAreaCameras(OfflineArea area) async { + if (area.cameras.isNotEmpty) { + return area.cameras; + } + final file = File('${area.directory}/cameras.json'); + if (await file.exists()) { + final str = await file.readAsString(); + final jsonList = jsonDecode(str) as List; + return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList(); + } + return []; +} + +bool _pointInBounds(LatLng pt, LatLngBounds bounds) { + return pt.latitude >= bounds.southWest.latitude && + pt.latitude <= bounds.northEast.latitude && + pt.longitude >= bounds.southWest.longitude && + pt.longitude <= bounds.northEast.longitude; +} + +bool _matchesAnyProfile(OsmCameraNode cam, List profiles) { + for (final prof in profiles) { + if (_cameraMatchesProfile(cam, prof)) return true; + } + return false; +} + +bool _cameraMatchesProfile(OsmCameraNode cam, CameraProfile profile) { + for (final e in profile.tags.entries) { + if (cam.tags[e.key] != e.value) return false; // All profile tags must match + } + return true; +} diff --git a/lib/services/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart index e69de29..1d24dbe 100644 --- a/lib/services/map_data_submodules/tiles_from_local.dart +++ b/lib/services/map_data_submodules/tiles_from_local.dart @@ -0,0 +1,42 @@ +import 'dart:io'; +import 'package:latlong2/latlong.dart'; +import '../offline_area_service.dart'; +import '../offline_areas/offline_area_models.dart'; +import '../offline_areas/offline_tile_utils.dart'; + +/// Fetch a tile from the newest offline area that plausibly contains it, or throw if not found. +Future> fetchLocalTile({required int z, required int x, required int y}) async { + final areas = OfflineAreaService().offlineAreas; + final List<_AreaTileMatch> candidates = []; + + for (final area in areas) { + if (area.status != OfflineAreaStatus.complete) continue; + if (z < area.minZoom || z > area.maxZoom) continue; + + // Get tile coverage for area at this zoom only + final coveredTiles = computeTileList(area.bounds, z, z); + if (coveredTiles.contains([z, x, y])) { + final tilePath = _tilePath(area.directory, z, x, y); + final file = File(tilePath); + if (await file.exists()) { + final stat = await file.stat(); + candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified)); + } + } + } + if (candidates.isEmpty) { + throw Exception('Tile $z/$x/$y not found in any offline area'); + } + candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first + return await candidates.first.file.readAsBytes(); +} + +String _tilePath(String areaDir, int z, int x, int y) => + '$areaDir/tiles/$z/$x/$y.png'; + +class _AreaTileMatch { + final OfflineArea area; + final File file; + final DateTime modified; + _AreaTileMatch({required this.area, required this.file, required this.modified}); +} diff --git a/lib/widgets/tile_provider_with_cache.dart b/lib/widgets/tile_provider_with_cache.dart index ba2c019..80c21c7 100644 --- a/lib/widgets/tile_provider_with_cache.dart +++ b/lib/widgets/tile_provider_with_cache.dart @@ -34,11 +34,6 @@ class TileProviderWithCache extends TileProvider { void _fetchAndCacheTile(TileCoordinates coords, String key, {MapSource source = MapSource.auto}) async { // Don't fire multiple fetches for the same tile simultaneously if (_tileCache.containsKey(key)) return; - // Only block REMOTE fetch in offline mode, but allow local/offline sources in the future. - if (AppState.instance.offlineMode && source != MapSource.local) { - print('[TileProviderWithCache] BLOCKED tile $key due to offline mode'); - return; - } try { final bytes = await MapDataProvider().getTile( z: coords.z, x: coords.x, y: coords.y, source: source,