mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-05-15 21:48:18 +02:00
break up offline area monopoly
This commit is contained in:
@@ -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**:
|
||||
|
||||
+17
-17
@@ -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();
|
||||
|
||||
@@ -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<DownloadAreaDialog> {
|
||||
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(() {
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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<OsmCameraNode> 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<String, dynamic> 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<String, dynamic> 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<int> 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<void> _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<List<int>> 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<List<int>> 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<OfflineArea> _areas = [];
|
||||
List<OfflineArea> get offlineAreas => List.unmodifiable(_areas);
|
||||
|
||||
/// Where offline area data/metadata lives
|
||||
Future<Directory> 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<OfflineArea> 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<void> 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<List<int>> 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<List<int>> allTilesSet = allTiles.toSet();
|
||||
Set<List<int>> 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<List<int>> 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<int> 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<void> 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<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
Set<List<int>> 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<void> _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<double> _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<List<int>> expectedTiles = computeTileList(worldBounds, 1, 4);
|
||||
if (world != null) {
|
||||
int filesFound = 0;
|
||||
List<List<int>> 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<int> _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<void> _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<void> 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<List<OsmCameraNode>> _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<List<int>> 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<List<int>> allTilesSet = allTiles.toSet();
|
||||
Set<List<int>> 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<List<int>> 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<dynamic>?)?.map((e) => OsmCameraNode.fromJson(e)).toList() ?? [];
|
||||
}
|
||||
|
||||
Future<void> _saveCameras(List<OsmCameraNode> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OsmCameraNode> 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<String, dynamic> 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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> 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<List<OsmCameraNode>> 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<dynamic>?)?.map((e) => OsmCameraNode.fromJson(e)).toList() ?? [];
|
||||
}
|
||||
|
||||
Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
|
||||
final file = File('$dir/cameras.json');
|
||||
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
|
||||
}
|
||||
@@ -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<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
Set<List<int>> 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<double> 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<int> 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));
|
||||
}
|
||||
Reference in New Issue
Block a user