From f8b4134bd3c9f9026e133c8a99a00741d96b988b Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 7 Aug 2025 20:52:24 -0500 Subject: [PATCH] break up offline area monopoly --- README.md | 19 +- lib/app_state.dart | 34 +- lib/screens/home_screen.dart | 5 +- .../offline_areas_section.dart | 1 + lib/services/offline_area_service.dart | 649 ++++++------------ .../offline_areas/offline_area_models.dart | 82 +++ .../offline_area_service_tile_fetch.dart | 56 ++ .../offline_areas/offline_tile_utils.dart | 69 ++ 8 files changed, 451 insertions(+), 464 deletions(-) create mode 100644 lib/services/offline_areas/offline_area_models.dart create mode 100644 lib/services/offline_areas/offline_area_service_tile_fetch.dart create mode 100644 lib/services/offline_areas/offline_tile_utils.dart diff --git a/README.md b/README.md index 2940ace..100e8a0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,15 @@ A Flutter app for mapping and tagging ALPR-style cameras (and other surveillance --- +## Code Organization + +This project uses a modular file/folder structure for maintainability: +- **Settings sections** each live in their own file under `lib/screens/settings_screen_sections/`. +- **Offline map area models, tile logic, and network/camera helpers** are grouped under `lib/services/offline_areas/`. +- The main Settings and OfflineAreaService files are now slim front-ends that delegate logic to these modules. + +--- + ## User Experience & Features ### Map View @@ -21,14 +30,16 @@ A Flutter app for mapping and tagging ALPR-style cameras (and other surveillance ### Offline Map Areas - **Download Any Region, Any Zoom:** Save the current map area at any zoom for true offline viewing. -- **Intelligent Tile Management:** World tiles at zooms 1–4 are permanently available (via a protected offline area). All downloads include accurate tile and storage estimates. +- **Intelligent Tile Management:** World tiles at zooms 1–4 are permanently available (via a protected offline area). All downloads include accurate tile and storage estimates, and never request duplicate or unnecessary tiles. +- **Robust Downloading:** All tile/download logic uses serial fetching and exponential backoff for network failures, minimizing risk of OSM rate-limits and always respecting API etiquette. - **No Duplicates:** Only one world area; can be re-downloaded (refreshed) but never deleted or renamed. - **Camera Cache:** Download areas keep camera points in sync for full offline visibility—except the global area, which never attempts to fetch all world cameras. - **Settings Management:** Cancel, refresh, or remove downloads as needed. Progress, tile count, storage consumption, and cached camera count always displayed. -### Polished UX Features +### Polished UX & Settings Architecture - **Permanent global base map:** Coverage for the entire world at zooms 1–4, always present. - **Smooth map gestures:** Double-tap to zoom even on markers; pinch zoom; camera popups distinguished from zoom. +- **Modular Settings:** All major settings/queue/offline/camera management UI sections are cleanly separated for extensibility and rapid development. - **Order-preserving overlays:** Your location is always drawn on top for easy visibility. - **No more dead ends:** Disabling all profiles is impossible; canceling downloads is clean and instant. @@ -48,9 +59,11 @@ A Flutter app for mapping and tagging ALPR-style cameras (and other surveillance ## Roadmap - **COMPLETE**: - - Offline map area download/storage/camera overlay; cancel/retry; fast tile/camera/size estimates. + - Offline map area download/storage/camera overlay; cancel/retry; fast tile/camera/size estimates; exponential backoff and robust retry logic for network outages or rate-limiting. - Pro-grade map UX (zoom bar, marker tap/double-tap, robust FABs). + - Modularized, maintainable codebase using small service/helper files and section-separated UI components. - **SOON**: + - "Offline mode" setting: map never hits the network and always provides a fallback tile for every view (no blank maps; graceful offline-first UX). - Resumable/robust interrupted downloads. - Further polish for edge cases (queue, error states). - **LATER**: diff --git a/lib/app_state.dart b/lib/app_state.dart index 1515aea..c96719d 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -49,10 +49,10 @@ class AppState extends ChangeNotifier { print('AppState: Switching mode, token exists; validating...'); final isValid = await validateToken(); if (isValid) { - print('AppState: Switching mode; fetching username for $mode...'); + print("AppState: Switching mode; fetching username for $mode..."); _username = await _auth.login(); if (_username != null) { - print('AppState: Switched mode, now logged in as $_username'); + print("AppState: Switched mode, now logged in as $_username"); } else { print('AppState: Switched mode but failed to retrieve username'); } @@ -62,15 +62,15 @@ class AppState extends ChangeNotifier { } } else { _username = null; - print('AppState: Mode change: not logged in in $mode'); + print("AppState: Mode change: not logged in in $mode"); } } catch (e) { _username = null; - print('AppState: Mode change user restoration error: $e'); + print("AppState: Mode change user restoration error: $e"); } final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_uploadModePrefsKey, mode.index); - print('AppState: Upload mode set to $mode'); + print("AppState: Upload mode set to $mode"); notifyListeners(); } @@ -125,7 +125,7 @@ class AppState extends ChangeNotifier { print('AppState: User appears to be logged in, fetching username...'); _username = await _auth.login(); if (_username != null) { - print('AppState: Successfully retrieved username: $_username'); + print("AppState: Successfully retrieved username: $_username"); } else { print('AppState: Failed to retrieve username despite being logged in'); } @@ -133,7 +133,7 @@ class AppState extends ChangeNotifier { print('AppState: User is not logged in'); } } catch (e) { - print('AppState: Error during auth initialization: $e'); + print("AppState: Error during auth initialization: $e"); } _startUploader(); @@ -146,12 +146,12 @@ class AppState extends ChangeNotifier { print('AppState: Starting login process...'); _username = await _auth.login(); if (_username != null) { - print('AppState: Login successful for user: $_username'); + print("AppState: Login successful for user: $_username"); } else { print('AppState: Login failed - no username returned'); } } catch (e) { - print('AppState: Login error: $e'); + print("AppState: Login error: $e"); _username = null; } notifyListeners(); @@ -171,7 +171,7 @@ class AppState extends ChangeNotifier { print('AppState: Token exists, fetching username...'); _username = await _auth.login(); if (_username != null) { - print('AppState: Auth refresh successful: $_username'); + print("AppState: Auth refresh successful: $_username"); } else { print('AppState: Auth refresh failed - no username'); } @@ -180,7 +180,7 @@ class AppState extends ChangeNotifier { _username = null; } } catch (e) { - print('AppState: Auth refresh error: $e'); + print("AppState: Auth refresh error: $e"); _username = null; } notifyListeners(); @@ -192,12 +192,12 @@ class AppState extends ChangeNotifier { print('AppState: Starting forced fresh login...'); _username = await _auth.forceLogin(); if (_username != null) { - print('AppState: Forced login successful: $_username'); + print("AppState: Forced login successful: $_username"); } else { print('AppState: Forced login failed - no username returned'); } } catch (e) { - print('AppState: Forced login error: $e'); + print("AppState: Forced login error: $e"); _username = null; } notifyListeners(); @@ -208,7 +208,7 @@ class AppState extends ChangeNotifier { try { return await _auth.isLoggedIn(); } catch (e) { - print('AppState: Token validation error: $e'); + print("AppState: Token validation error: $e"); return false; } } @@ -356,7 +356,7 @@ class AppState extends ChangeNotifier { bool ok; if (_uploadMode == UploadMode.simulate) { // Simulate successful upload without calling real API - print('AppState: UploadMode.simulate - simulating upload for ${item.coord}'); + print("AppState: UploadMode.simulate - simulating upload for ${item.coord}"); await Future.delayed(const Duration(seconds: 1)); // Simulate network delay ok = true; print('AppState: Simulated upload successful'); @@ -394,14 +394,14 @@ class AppState extends ChangeNotifier { // ---------- Queue management ---------- void clearQueue() { - print('AppState: Clearing upload queue (${_queue.length} items)'); + print("AppState: Clearing upload queue (${_queue.length} items)"); _queue.clear(); _saveQueue(); notifyListeners(); } void removeFromQueue(PendingUpload upload) { - print('AppState: Removing upload from queue: ${upload.coord}'); + print("AppState: Removing upload from queue: ${upload.coord}"); _queue.remove(upload); _saveQueue(); notifyListeners(); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index adb8180..999cf0f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -7,6 +7,7 @@ import '../widgets/map_view.dart'; import 'package:flutter_map/flutter_map.dart'; import '../services/offline_area_service.dart'; import '../widgets/add_camera_sheet.dart'; +import '../services/offline_areas/offline_tile_utils.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -129,9 +130,9 @@ class _DownloadAreaDialogState extends State { LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon) ); } - final minZoom = OfflineAreaService().findDynamicMinZoom(bounds); + final minZoom = findDynamicMinZoom(bounds); final maxZoom = _zoom.toInt(); - final nTiles = OfflineAreaService().computeTileList(bounds, minZoom, maxZoom).length; + final nTiles = computeTileList(bounds, minZoom, maxZoom).length; const kbPerTile = 25.0; // Empirically ~6.5kB average for OSM tiles at z=1-19 final totalMb = (nTiles * kbPerTile) / 1024.0; setState(() { diff --git a/lib/screens/settings_screen_sections/offline_areas_section.dart b/lib/screens/settings_screen_sections/offline_areas_section.dart index 17f780e..0e7f741 100644 --- a/lib/screens/settings_screen_sections/offline_areas_section.dart +++ b/lib/screens/settings_screen_sections/offline_areas_section.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../services/offline_area_service.dart'; +import '../../services/offline_areas/offline_area_models.dart'; class OfflineAreasSection extends StatefulWidget { const OfflineAreasSection({super.key}); diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 5e382da..9e8c4b5 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -1,200 +1,25 @@ import 'dart:io'; import 'dart:convert'; -import 'dart:math'; import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; import 'package:path_provider/path_provider.dart'; +import 'offline_areas/offline_area_models.dart'; +import 'offline_areas/offline_tile_utils.dart'; +import 'offline_areas/offline_area_service_tile_fetch.dart'; import '../models/osm_camera_node.dart'; -/// Model for an offline area -enum OfflineAreaStatus { downloading, complete, error, cancelled } - -class OfflineArea { - final String id; - String name; - final LatLngBounds bounds; - final int minZoom; - final int maxZoom; - final String directory; // base dir for area storage - OfflineAreaStatus status; - double progress; // 0.0 - 1.0 - int tilesDownloaded; - int tilesTotal; - List cameras; - int sizeBytes; // Disk size in bytes - final bool isPermanent; // Not user-deletable if true - - OfflineArea({ - required this.id, - this.name = '', - required this.bounds, - required this.minZoom, - required this.maxZoom, - required this.directory, - this.status = OfflineAreaStatus.downloading, - this.progress = 0, - this.tilesDownloaded = 0, - this.tilesTotal = 0, - this.cameras = const [], - this.sizeBytes = 0, - this.isPermanent = false, - }); - - Map toJson() => { - 'id': id, - 'name': name, - 'bounds': { - 'sw': {'lat': bounds.southWest.latitude, 'lng': bounds.southWest.longitude}, - 'ne': {'lat': bounds.northEast.latitude, 'lng': bounds.northEast.longitude}, - }, - 'minZoom': minZoom, - 'maxZoom': maxZoom, - 'directory': directory, - 'status': status.name, - 'progress': progress, - 'tilesDownloaded': tilesDownloaded, - 'tilesTotal': tilesTotal, - 'cameras': cameras.map((c) => c.toJson()).toList(), - 'sizeBytes': sizeBytes, - 'isPermanent': isPermanent, - }; - - static OfflineArea fromJson(Map json) { - final bounds = LatLngBounds( - LatLng(json['bounds']['sw']['lat'], json['bounds']['sw']['lng']), - LatLng(json['bounds']['ne']['lat'], json['bounds']['ne']['lng']), - ); - return OfflineArea( - id: json['id'], - name: json['name'] ?? '', - bounds: bounds, - minZoom: json['minZoom'], - maxZoom: json['maxZoom'], - directory: json['directory'], - status: OfflineAreaStatus.values.firstWhere( - (e) => e.name == json['status'], orElse: () => OfflineAreaStatus.error), - progress: (json['progress'] ?? 0).toDouble(), - tilesDownloaded: json['tilesDownloaded'] ?? 0, - tilesTotal: json['tilesTotal'] ?? 0, - cameras: (json['cameras'] as List? ?? []) - .map((e) => OsmCameraNode.fromJson(e)).toList(), - sizeBytes: json['sizeBytes'] ?? 0, - isPermanent: json['isPermanent'] ?? false, - ); - } -} - /// Service for managing download, storage, and retrieval of offline map areas and cameras. class OfflineAreaService { - // Public wrapper to allow UI code to persist area changes - // Wrapper removed; see implementation at line 204 - /// Compute area disk usage (recursive) - Future getAreaSizeBytes(OfflineArea area) async { - int total = 0; - final dir = Directory(area.directory); - if (await dir.exists()) { - await for (var fse in dir.list(recursive: true)) { - if (fse is File) { - total += await fse.length(); - } - } - } - area.sizeBytes = total; - await saveAreasToDisk(); - return total; - } - static final OfflineAreaService _instance = OfflineAreaService._(); factory OfflineAreaService() => _instance; OfflineAreaService._() { _loadAreasFromDisk().then((_) => _ensureAndAutoDownloadWorldArea()); } - // Ensure permanent world area exists and auto-download if tiles missing - Future _ensureAndAutoDownloadWorldArea() async { - final dir = await getOfflineAreaDir(); - final worldDir = "${dir.path}/world_z1_4"; - final LatLngBounds worldBounds = globalWorldBounds(); - OfflineArea? world; - for (final a in _areas) { - if (a.isPermanent) { world = a; break; } - } - final Set> expectedTiles = computeTileList(worldBounds, 1, 4); - - // Recount actual files if world area exists (can be slow but only on launch or change) - if (world != null) { - int filesFound = 0; - List> missingTiles = []; - for (final tile in expectedTiles) { - final f = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png'); - if (f.existsSync()) { - filesFound++; - } else if (missingTiles.length < 10) { - missingTiles.add(tile); - } - } - if (filesFound != expectedTiles.length) { - debugPrint('World area: missing \\${expectedTiles.length - filesFound} tiles. First few: \\$missingTiles'); - } else { - debugPrint('World area: all tiles accounted for.'); - } - - world.tilesTotal = expectedTiles.length; - world.tilesDownloaded = filesFound; - world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal); - if (filesFound == world.tilesTotal) { - world.status = OfflineAreaStatus.complete; - await saveAreasToDisk(); - return; - } else { - world.status = OfflineAreaStatus.downloading; - await saveAreasToDisk(); - downloadArea( - id: world.id, - bounds: world.bounds, - minZoom: world.minZoom, - maxZoom: world.maxZoom, - directory: world.directory, - name: world.name, - onProgress: null, - onComplete: null, - ); - return; - } - } - // If not present, create and start download - world = OfflineArea( - id: 'permanent_world_z1_4', - name: 'World (zoom 1-4)', - bounds: worldBounds, - minZoom: 1, - maxZoom: 4, - directory: worldDir, - status: OfflineAreaStatus.downloading, - progress: 0.0, - isPermanent: true, - tilesTotal: expectedTiles.length, - tilesDownloaded: 0, - ); - _areas.insert(0, world); - await saveAreasToDisk(); - downloadArea( - id: world.id, - bounds: world.bounds, - minZoom: world.minZoom, - maxZoom: world.maxZoom, - directory: world.directory, - name: world.name, - onProgress: null, - onComplete: null, - ); - } - final List _areas = []; + List get offlineAreas => List.unmodifiable(_areas); - /// Where offline area data/metadata lives Future getOfflineAreaDir() async { final dir = await getApplicationDocumentsDirectory(); final areaRoot = Directory("${dir.path}/offline_areas"); @@ -209,159 +34,21 @@ class OfflineAreaService { return File("${dir.path}/offline_areas.json"); } - List get offlineAreas => List.unmodifiable(_areas); - - /// Start downloading an area: tiles and camera points. - /// [onProgress] is called with 0.0..1.0, [onComplete] when finished or failed. - Future downloadArea({ - required String id, - required LatLngBounds bounds, - required int minZoom, - required int maxZoom, - required String directory, - void Function(double progress)? onProgress, - void Function(OfflineAreaStatus status)? onComplete, - String? name, - }) async { - // If area with same id exists, replace its contents, else add. - OfflineArea? area; - for (final a in _areas) { - if (a.id == id) { area = a; break; } - } - if (area != null) { - // Remove area and its files before creating fresh - _areas.remove(area); - final dirObj = Directory(area.directory); - if (await dirObj.exists()) { - await dirObj.delete(recursive: true); - } - } - area = OfflineArea( - id: id, - name: name ?? area?.name ?? '', - bounds: bounds, - minZoom: minZoom, - maxZoom: maxZoom, - directory: directory, - isPermanent: area?.isPermanent ?? false, - ); - _areas.add(area); - await saveAreasToDisk(); - - try { - // STEP 1: Tiles: user areas get only their bbox/zooms; world area gets only global z=1..4 - Set> allTiles; - if (area.isPermanent) { - allTiles = computeTileList(globalWorldBounds(), 1, 4); - } else { - allTiles = computeTileList(bounds, minZoom, maxZoom); - } - area.tilesTotal = allTiles.length; - - // NEW ROBUST MULTI-PASS DOWNLOAD - // Will try up to 3 passes, only downloading what's still missing each time; - // area marked error (and non-permanent ones deleted) if incomplete after 3 passes - const int maxPasses = 3; - int pass = 0; - Set> allTilesSet = allTiles.toSet(); - Set> tilesToFetch = allTilesSet; - bool success = false; - int totalDone = 0; // cumulative - while (pass < maxPasses && tilesToFetch.isNotEmpty) { - pass++; - int doneThisPass = 0; - debugPrint('DownloadArea: pass #$pass for area $id. Need \\${tilesToFetch.length} tiles.'); - for (final tile in tilesToFetch) { - if (area.status == OfflineAreaStatus.cancelled) break; - try { - await _downloadTile(tile[0], tile[1], tile[2], directory); - totalDone++; - doneThisPass++; - area.tilesDownloaded = totalDone; - area.progress = area.tilesTotal == 0 ? 0.0 : ((area.tilesDownloaded) / area.tilesTotal); - } catch (e) { - debugPrint('Tile download failed for z=\\${tile[0]}, x=\\${tile[1]}, y=\\${tile[2]}: \\$e'); - } - if (onProgress != null) onProgress(area.progress); - } - await getAreaSizeBytes(area); // Update size as we download - await saveAreasToDisk(); - // After a pass, check for missing tiles - Set> missingTiles = {}; - for (final tile in allTilesSet) { - final f = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png'); - if (!f.existsSync()) missingTiles.add(tile); - } - if (missingTiles.isEmpty) { - success = true; - break; - } - tilesToFetch = missingTiles; - } - - // STEP 2: Fetch cameras for this bbox (all, not limited!) - if (!area.isPermanent) { - final cameras = await _downloadAllCameras(bounds); - area.cameras = cameras; - await _saveCameras(cameras, directory); - } else { - area.cameras = []; - } - await getAreaSizeBytes(area); - - if (success) { - area.status = OfflineAreaStatus.complete; - area.progress = 1.0; - debugPrint('Area $id: all tiles accounted for and area marked complete.'); - } else { - area.status = OfflineAreaStatus.error; - debugPrint('Area $id: MISSING tiles after $maxPasses passes. First 10: \\${tilesToFetch.toList().take(10)}'); - // Clean up area if not permanent - if (!area.isPermanent) { - final dirObj = Directory(area.directory); - if (await dirObj.exists()) { - await dirObj.delete(recursive: true); - } - _areas.remove(area); - } - } - await saveAreasToDisk(); - if (onComplete != null) onComplete(area.status); - } catch (e) { - area.status = OfflineAreaStatus.error; - await saveAreasToDisk(); - if (onComplete != null) onComplete(area.status); - } - } - - void cancelDownload(String id) async { - final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found'); - area.status = OfflineAreaStatus.cancelled; - // Delete partial files as on standard delete + Future getAreaSizeBytes(OfflineArea area) async { + int total = 0; final dir = Directory(area.directory); if (await dir.exists()) { - await dir.delete(recursive: true); + await for (var fse in dir.list(recursive: true)) { + if (fse is File) { + total += await fse.length(); + } + } } - _areas.remove(area); // always remove, world will get recreated/refetched as needed + area.sizeBytes = total; await saveAreasToDisk(); - if (area.isPermanent) { - // Immediately recreate and auto-download world area - _ensureAndAutoDownloadWorldArea(); - } + return total; } - void deleteArea(String id) async { - final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found'); - final dir = Directory(area.directory); - if (await dir.exists()) { - await dir.delete(recursive: true); - } - _areas.remove(area); - await saveAreasToDisk(); - } - - // --- PERSISTENCE LOGIC --- - Future saveAreasToDisk() async { try { final file = await _getMetadataPath(); @@ -388,11 +75,9 @@ class OfflineAreaService { _areas.clear(); for (final areaJson in data) { final area = OfflineArea.fromJson(areaJson); - // Check if directory still exists; adjust status if not if (!Directory(area.directory).existsSync()) { area.status = OfflineAreaStatus.error; } else { - // Update sizeBytes async getAreaSizeBytes(area); } _areas.add(area); @@ -402,130 +87,210 @@ class OfflineAreaService { } } - // --- TILE LOGIC --- - - /// Returns set of [z, x, y] tuples needed to cover [bounds] at [zMin]..[zMax]. - Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { - Set> tiles = {}; - const double epsilon = 1e-7; - double latMin = min(bounds.southWest.latitude, bounds.northEast.latitude); - double latMax = max(bounds.southWest.latitude, bounds.northEast.latitude); - double lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude); - double lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude); - // Expand degenerate/flat areas a hair - if ((latMax - latMin).abs() < epsilon) { - latMin -= epsilon; - latMax += epsilon; - } - if ((lonMax - lonMin).abs() < epsilon) { - lonMin -= epsilon; - lonMax += epsilon; - } -for (int z = zMin; z <= zMax; z++) { - final n = pow(2, z).toInt(); - final minTile = _latLonToTile(latMin, lonMin, z); - final maxTile = _latLonToTile(latMax, lonMax, z); - final minX = min(minTile[0], maxTile[0]); - final maxX = max(minTile[0], maxTile[0]); - final minY = min(minTile[1], maxTile[1]); - final maxY = max(minTile[1], maxTile[1]); - - // New diagnostics! - // Removed verbose debugPrint analysis outputs - for (int x = minX; x <= maxX; x++) { - for (int y = minY; y <= maxY; y++) { - tiles.add([z, x, y]); + Future _ensureAndAutoDownloadWorldArea() async { + final dir = await getOfflineAreaDir(); + final worldDir = "${dir.path}/world_z1_4"; + final LatLngBounds worldBounds = globalWorldBounds(); + OfflineArea? world; + for (final a in _areas) { + if (a.isPermanent) { world = a; break; } } - } - // Removed verbose debugPrint tile add outputs -} - return tiles; - } - - // Returns x, y as double for NE corners - List _latLonToTileRaw(double lat, double lon, int zoom) { - final n = pow(2.0, zoom); - final xtile = (lon + 180.0) / 360.0 * n; - final ytile = (1.0 - log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * n; - return [xtile, ytile]; - } - - /// Finds the minimum zoom at which a single tile covers [bounds]. - /// Returns the highest z (up to [maxSearchZoom]) for which both corners are in the same tile. - int findDynamicMinZoom(LatLngBounds bounds, {int maxSearchZoom = 19}) { - for (int z = 1; z <= maxSearchZoom; z++) { - final swTile = _latLonToTile(bounds.southWest.latitude, bounds.southWest.longitude, z); - final neTile = _latLonToTile(bounds.northEast.latitude, bounds.northEast.longitude, z); - if (swTile[0] != neTile[0] || swTile[1] != neTile[1]) { - return z - 1 > 0 ? z - 1 : 1; + final Set> expectedTiles = computeTileList(worldBounds, 1, 4); + if (world != null) { + int filesFound = 0; + List> missingTiles = []; + for (final tile in expectedTiles) { + final f = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png'); + if (f.existsSync()) { + filesFound++; + } else if (missingTiles.length < 10) { + missingTiles.add(tile); + } + } + if (filesFound != expectedTiles.length) { + debugPrint('World area: missing \\${expectedTiles.length - filesFound} tiles. First few: \\$missingTiles'); + } else { + debugPrint('World area: all tiles accounted for.'); + } + world.tilesTotal = expectedTiles.length; + world.tilesDownloaded = filesFound; + world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal); + if (filesFound == world.tilesTotal) { + world.status = OfflineAreaStatus.complete; + await saveAreasToDisk(); + return; + } else { + world.status = OfflineAreaStatus.downloading; + await saveAreasToDisk(); + downloadArea( + id: world.id, + bounds: world.bounds, + minZoom: world.minZoom, + maxZoom: world.maxZoom, + directory: world.directory, + name: world.name, + ); + return; } } - return maxSearchZoom; + // If not present, create and start download + world = OfflineArea( + id: 'permanent_world_z1_4', + name: 'World (zoom 1-4)', + bounds: worldBounds, + minZoom: 1, + maxZoom: 4, + directory: worldDir, + status: OfflineAreaStatus.downloading, + progress: 0.0, + isPermanent: true, + tilesTotal: expectedTiles.length, + tilesDownloaded: 0, + ); + _areas.insert(0, world); + await saveAreasToDisk(); + downloadArea( + id: world.id, + bounds: world.bounds, + minZoom: world.minZoom, + maxZoom: world.maxZoom, + directory: world.directory, + name: world.name, + ); } - /// Converts lat/lon+zoom to OSM tile xy as [x, y] - List _latLonToTile(double lat, double lon, int zoom) { - final n = pow(2.0, zoom); - final xtile = ((lon + 180.0) / 360.0 * n).floor(); - final ytile = ((1.0 - log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * n).floor(); - return [xtile, ytile]; - } - - 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)); - } - - Future _downloadTile(int z, int x, int y, String baseDir) async { - final url = 'https://tile.openstreetmap.org/$z/$x/$y.png'; - final dir = Directory('$baseDir/tiles/$z/$x'); - await dir.create(recursive: true); - final file = File('${dir.path}/$y.png'); - if (await file.exists()) return; // already downloaded - const int maxAttempts = 3; - int attempt = 0; - final random = Random(); - // Backoff schedule in ms: 0 (immediate), 3000±500 (3s+/-), 10000±2000 (10s+/-) - final delays = [0, 3000 + random.nextInt(1000) - 500, 10000 + random.nextInt(4000) - 2000]; - while (true) { - try { - attempt++; - final resp = await http.get(Uri.parse(url)); - if (resp.statusCode == 200) { - await file.writeAsBytes(resp.bodyBytes); - return; - } else { - throw Exception('Failed to download tile $z/$x/$y (status \\${resp.statusCode})'); - } - } catch (e) { - if (attempt >= maxAttempts) { - throw Exception('Failed to download tile $z/$x/$y after $attempt attempts: $e'); - } - final delay = delays[attempt-1].clamp(0, 60000); - debugPrint('Retrying tile $z/$x/$y after failure (attempt $attempt, delaying \\${delay}ms): $e'); - await Future.delayed(Duration(milliseconds: delay)); + Future downloadArea({ + required String id, + required LatLngBounds bounds, + required int minZoom, + required int maxZoom, + required String directory, + void Function(double progress)? onProgress, + void Function(OfflineAreaStatus status)? onComplete, + String? name, + }) async { + OfflineArea? area; + for (final a in _areas) { + if (a.id == id) { area = a; break; } + } + if (area != null) { + _areas.remove(area); + final dirObj = Directory(area.directory); + if (await dirObj.exists()) { + await dirObj.delete(recursive: true); } } - } + area = OfflineArea( + id: id, + name: name ?? area?.name ?? '', + bounds: bounds, + minZoom: minZoom, + maxZoom: maxZoom, + directory: directory, + isPermanent: area?.isPermanent ?? false, + ); + _areas.add(area); + await saveAreasToDisk(); - // --- CAMERA LOGIC --- - Future> _downloadAllCameras(LatLngBounds bounds) async { - // Overpass QL: fetch all cameras with no limit. - final sw = bounds.southWest; - final ne = bounds.northEast; - final bbox = [sw.latitude, sw.longitude, ne.latitude, ne.longitude].join(','); - final query = '[out:json][timeout:60];node["man_made"="surveillance"]["camera:mount"="pole"]($bbox);out body;'; - final url = 'https://overpass-api.de/api/interpreter'; - final resp = await http.post(Uri.parse(url), body: { 'data': query }); - if (resp.statusCode != 200) { - throw Exception('Failed to fetch cameras'); + try { + Set> allTiles; + if (area.isPermanent) { + allTiles = computeTileList(globalWorldBounds(), 1, 4); + } else { + allTiles = computeTileList(bounds, minZoom, maxZoom); + } + area.tilesTotal = allTiles.length; + const int maxPasses = 3; + int pass = 0; + Set> allTilesSet = allTiles.toSet(); + Set> tilesToFetch = allTilesSet; + bool success = false; + int totalDone = 0; + while (pass < maxPasses && tilesToFetch.isNotEmpty) { + pass++; + int doneThisPass = 0; + debugPrint('DownloadArea: pass #$pass for area $id. Need \\${tilesToFetch.length} tiles.'); + for (final tile in tilesToFetch) { + if (area.status == OfflineAreaStatus.cancelled) break; + try { + await downloadTile(tile[0], tile[1], tile[2], directory); + totalDone++; + doneThisPass++; + area.tilesDownloaded = totalDone; + area.progress = area.tilesTotal == 0 ? 0.0 : ((area.tilesDownloaded) / area.tilesTotal); + } catch (e) { + debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e"); + } + if (onProgress != null) onProgress(area.progress); + } + await getAreaSizeBytes(area); + await saveAreasToDisk(); + Set> missingTiles = {}; + for (final tile in allTilesSet) { + final f = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png'); + if (!f.existsSync()) missingTiles.add(tile); + } + if (missingTiles.isEmpty) { + success = true; + break; + } + tilesToFetch = missingTiles; + } + + if (!area.isPermanent) { + final cameras = await downloadAllCameras(bounds); + area.cameras = cameras; + await saveCameras(cameras, directory); + } else { + area.cameras = []; + } + await getAreaSizeBytes(area); + + if (success) { + area.status = OfflineAreaStatus.complete; + area.progress = 1.0; + debugPrint('Area $id: all tiles accounted for and area marked complete.'); + } else { + area.status = OfflineAreaStatus.error; + debugPrint('Area $id: MISSING tiles after $maxPasses passes. First 10: \\${tilesToFetch.toList().take(10)}'); + if (!area.isPermanent) { + final dirObj = Directory(area.directory); + if (await dirObj.exists()) { + await dirObj.delete(recursive: true); + } + _areas.remove(area); + } + } + await saveAreasToDisk(); + if (onComplete != null) onComplete(area.status); + } catch (e) { + area.status = OfflineAreaStatus.error; + await saveAreasToDisk(); + if (onComplete != null) onComplete(area.status); } - final data = jsonDecode(resp.body); - return (data['elements'] as List?)?.map((e) => OsmCameraNode.fromJson(e)).toList() ?? []; } - Future _saveCameras(List cams, String dir) async { - final file = File('$dir/cameras.json'); - await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList())); + void cancelDownload(String id) async { + final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found'); + area.status = OfflineAreaStatus.cancelled; + final dir = Directory(area.directory); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + _areas.remove(area); + await saveAreasToDisk(); + if (area.isPermanent) { + _ensureAndAutoDownloadWorldArea(); + } + } + + void deleteArea(String id) async { + final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found'); + final dir = Directory(area.directory); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + _areas.remove(area); + await saveAreasToDisk(); } } diff --git a/lib/services/offline_areas/offline_area_models.dart b/lib/services/offline_areas/offline_area_models.dart new file mode 100644 index 0000000..9dca081 --- /dev/null +++ b/lib/services/offline_areas/offline_area_models.dart @@ -0,0 +1,82 @@ +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import '../../models/osm_camera_node.dart'; + +/// Status of an offline area +enum OfflineAreaStatus { downloading, complete, error, cancelled } + +/// Model class describing an offline area for map/camera caching +class OfflineArea { + final String id; + String name; + final LatLngBounds bounds; + final int minZoom; + final int maxZoom; + final String directory; // base dir for area storage + OfflineAreaStatus status; + double progress; // 0.0 - 1.0 + int tilesDownloaded; + int tilesTotal; + List cameras; + int sizeBytes; // Disk size in bytes + final bool isPermanent; // Not user-deletable if true + + OfflineArea({ + required this.id, + this.name = '', + required this.bounds, + required this.minZoom, + required this.maxZoom, + required this.directory, + this.status = OfflineAreaStatus.downloading, + this.progress = 0, + this.tilesDownloaded = 0, + this.tilesTotal = 0, + this.cameras = const [], + this.sizeBytes = 0, + this.isPermanent = false, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'bounds': { + 'sw': {'lat': bounds.southWest.latitude, 'lng': bounds.southWest.longitude}, + 'ne': {'lat': bounds.northEast.latitude, 'lng': bounds.northEast.longitude}, + }, + 'minZoom': minZoom, + 'maxZoom': maxZoom, + 'directory': directory, + 'status': status.name, + 'progress': progress, + 'tilesDownloaded': tilesDownloaded, + 'tilesTotal': tilesTotal, + 'cameras': cameras.map((c) => c.toJson()).toList(), + 'sizeBytes': sizeBytes, + 'isPermanent': isPermanent, + }; + + static OfflineArea fromJson(Map json) { + final bounds = LatLngBounds( + LatLng(json['bounds']['sw']['lat'], json['bounds']['sw']['lng']), + LatLng(json['bounds']['ne']['lat'], json['bounds']['ne']['lng']), + ); + return OfflineArea( + id: json['id'], + name: json['name'] ?? '', + bounds: bounds, + minZoom: json['minZoom'], + maxZoom: json['maxZoom'], + directory: json['directory'], + status: OfflineAreaStatus.values.firstWhere( + (e) => e.name == json['status'], orElse: () => OfflineAreaStatus.error), + progress: (json['progress'] ?? 0).toDouble(), + tilesDownloaded: json['tilesDownloaded'] ?? 0, + tilesTotal: json['tilesTotal'] ?? 0, + cameras: (json['cameras'] as List? ?? []) + .map((e) => OsmCameraNode.fromJson(e)).toList(), + sizeBytes: json['sizeBytes'] ?? 0, + isPermanent: json['isPermanent'] ?? false, + ); + } +} diff --git a/lib/services/offline_areas/offline_area_service_tile_fetch.dart b/lib/services/offline_areas/offline_area_service_tile_fetch.dart new file mode 100644 index 0000000..c0ba7bc --- /dev/null +++ b/lib/services/offline_areas/offline_area_service_tile_fetch.dart @@ -0,0 +1,56 @@ +import 'dart:math'; +import 'dart:io'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; +import '../../models/osm_camera_node.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; + +Future downloadTile(int z, int x, int y, String baseDir) async { + final url = 'https://tile.openstreetmap.org/$z/$x/$y.png'; + final dir = Directory('$baseDir/tiles/$z/$x'); + await dir.create(recursive: true); + final file = File('${dir.path}/$y.png'); + if (await file.exists()) return; // already downloaded + const int maxAttempts = 3; + int attempt = 0; + final random = Random(); + final delays = [0, 3000 + random.nextInt(1000) - 500, 10000 + random.nextInt(4000) - 2000]; + while (true) { + try { + attempt++; + final resp = await http.get(Uri.parse(url)); + if (resp.statusCode == 200) { + await file.writeAsBytes(resp.bodyBytes); + return; + } else { + throw Exception('Failed to download tile $z/$x/$y (status \\${resp.statusCode})'); + } + } catch (e) { + if (attempt >= maxAttempts) { + throw Exception("Failed to download tile $z/$x/$y after $attempt attempts: $e"); + } + final delay = delays[attempt-1].clamp(0, 60000); + await Future.delayed(Duration(milliseconds: delay)); + } + } +} + +Future> downloadAllCameras(LatLngBounds bounds) async { + final sw = bounds.southWest; + final ne = bounds.northEast; + final bbox = [sw.latitude, sw.longitude, ne.latitude, ne.longitude].join(','); + final query = '[out:json][timeout:60];node["man_made"="surveillance"]["camera:mount"="pole"]($bbox);out body;'; + final url = 'https://overpass-api.de/api/interpreter'; + final resp = await http.post(Uri.parse(url), body: { 'data': query }); + if (resp.statusCode != 200) { + throw Exception('Failed to fetch cameras'); + } + final data = jsonDecode(resp.body); + return (data['elements'] as List?)?.map((e) => OsmCameraNode.fromJson(e)).toList() ?? []; +} + +Future saveCameras(List cams, String dir) async { + final file = File('$dir/cameras.json'); + await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList())); +} diff --git a/lib/services/offline_areas/offline_tile_utils.dart b/lib/services/offline_areas/offline_tile_utils.dart new file mode 100644 index 0000000..b532cba --- /dev/null +++ b/lib/services/offline_areas/offline_tile_utils.dart @@ -0,0 +1,69 @@ +import 'dart:math'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; + +/// Utility for tile calculations and lat/lon conversions for OSM offline logic + +Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { + Set> tiles = {}; + const double epsilon = 1e-7; + double latMin = min(bounds.southWest.latitude, bounds.northEast.latitude); + double latMax = max(bounds.southWest.latitude, bounds.northEast.latitude); + double lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude); + double lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude); + // Expand degenerate/flat areas a hair + if ((latMax - latMin).abs() < epsilon) { + latMin -= epsilon; + latMax += epsilon; + } + if ((lonMax - lonMin).abs() < epsilon) { + lonMin -= epsilon; + lonMax += epsilon; + } + for (int z = zMin; z <= zMax; z++) { + final n = pow(2, z).toInt(); + final minTile = latLonToTile(latMin, lonMin, z); + final maxTile = latLonToTile(latMax, lonMax, z); + final minX = min(minTile[0], maxTile[0]); + final maxX = max(minTile[0], maxTile[0]); + final minY = min(minTile[1], maxTile[1]); + final maxY = max(minTile[1], maxTile[1]); + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + tiles.add([z, x, y]); + } + } + } + return tiles; +} + +List latLonToTileRaw(double lat, double lon, int zoom) { + final n = pow(2.0, zoom); + final xtile = (lon + 180.0) / 360.0 * n; + final ytile = (1.0 - + log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * n; + return [xtile, ytile]; +} + +List latLonToTile(double lat, double lon, int zoom) { + final n = pow(2.0, zoom); + final xtile = ((lon + 180.0) / 360.0 * n).floor(); + final ytile = ((1.0 - log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * n).floor(); + return [xtile, ytile]; +} + +int findDynamicMinZoom(LatLngBounds bounds, {int maxSearchZoom = 19}) { + for (int z = 1; z <= maxSearchZoom; z++) { + final swTile = latLonToTile(bounds.southWest.latitude, bounds.southWest.longitude, z); + final neTile = latLonToTile(bounds.northEast.latitude, bounds.northEast.longitude, z); + if (swTile[0] != neTile[0] || swTile[1] != neTile[1]) { + return z - 1 > 0 ? z - 1 : 1; + } + } + return maxSearchZoom; +} + +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)); +}