mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
even closer to working - download estimate working - settings page working
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
@@ -107,7 +108,27 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
}
|
||||
|
||||
void _recomputeEstimates() {
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
var bounds = widget.controller.camera.visibleBounds;
|
||||
// If the visible area is nearly zero, nudge the bounds for estimation
|
||||
const double epsilon = 0.0002;
|
||||
final latSpan = (bounds.north - bounds.south).abs();
|
||||
final lngSpan = (bounds.east - bounds.west).abs();
|
||||
if (latSpan < epsilon && lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
} else if (latSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude)
|
||||
);
|
||||
} else if (lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
}
|
||||
final minZoom = OfflineAreaService().findDynamicMinZoom(bounds);
|
||||
final maxZoom = _zoom.toInt();
|
||||
final allTiles = OfflineAreaService().computeTileList(bounds, minZoom, maxZoom);
|
||||
|
||||
@@ -406,12 +406,19 @@ class _OfflineAreasSectionState extends State<_OfflineAreasSection> {
|
||||
}
|
||||
return Column(
|
||||
children: areas.map((area) {
|
||||
String diskStr = area.sizeBytes > 0
|
||||
? area.sizeBytes > 1024 * 1024
|
||||
? "${(area.sizeBytes / (1024 * 1024)).toStringAsFixed(2)} MB"
|
||||
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} KB"
|
||||
: '--';
|
||||
String subtitle =
|
||||
'Z${area.minZoom}-${area.maxZoom}\n' +
|
||||
'Lat: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
|
||||
'Lat: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
|
||||
subtitle += '\nTiles: ${area.tilesTotal}';
|
||||
subtitle += ' | Size: $diskStr';
|
||||
if (area.status == OfflineAreaStatus.complete) {
|
||||
subtitle += '\nCameras cached: ${area.cameras.length}';
|
||||
subtitle += ' | Cameras: ${area.cameras.length}';
|
||||
}
|
||||
return Card(
|
||||
child: ListTile(
|
||||
@@ -420,7 +427,63 @@ class _OfflineAreasSectionState extends State<_OfflineAreasSection> {
|
||||
: area.status == OfflineAreaStatus.error
|
||||
? Icons.error
|
||||
: Icons.download_for_offline),
|
||||
title: Text('Area ${area.id.substring(0, 6)}...'),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(area.name.isNotEmpty
|
||||
? area.name
|
||||
: 'Area ${area.id.substring(0, 6)}...'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
tooltip: 'Rename area',
|
||||
onPressed: () async {
|
||||
String? newName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
final ctrl = TextEditingController(text: area.name);
|
||||
return AlertDialog(
|
||||
title: const Text('Rename Offline Area'),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
maxLength: 40,
|
||||
decoration: const InputDecoration(labelText: 'Area Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx, ctrl.text.trim());
|
||||
},
|
||||
child: const Text('Rename'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (newName != null && newName.trim().isNotEmpty) {
|
||||
setState(() {
|
||||
area.name = newName.trim();
|
||||
service.saveAreasToDisk();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
if (area.status != OfflineAreaStatus.downloading)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
tooltip: 'Delete offline area',
|
||||
onPressed: () async {
|
||||
service.deleteArea(area.id);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(subtitle),
|
||||
isThreeLine: true,
|
||||
trailing: area.status == OfflineAreaStatus.downloading
|
||||
@@ -437,14 +500,7 @@ class _OfflineAreasSectionState extends State<_OfflineAreasSection> {
|
||||
],
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
tooltip: 'Delete offline area',
|
||||
onPressed: () async {
|
||||
service.deleteArea(area.id);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
: null,
|
||||
onLongPress: area.status == OfflineAreaStatus.downloading
|
||||
? () {
|
||||
service.cancelDownload(area.id);
|
||||
|
||||
@@ -13,6 +13,7 @@ enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
||||
|
||||
class OfflineArea {
|
||||
final String id;
|
||||
String name;
|
||||
final LatLngBounds bounds;
|
||||
final int minZoom;
|
||||
final int maxZoom;
|
||||
@@ -22,9 +23,11 @@ class OfflineArea {
|
||||
int tilesDownloaded;
|
||||
int tilesTotal;
|
||||
List<OsmCameraNode> cameras;
|
||||
int sizeBytes; // Disk size in bytes
|
||||
|
||||
OfflineArea({
|
||||
required this.id,
|
||||
this.name = '',
|
||||
required this.bounds,
|
||||
required this.minZoom,
|
||||
required this.maxZoom,
|
||||
@@ -34,10 +37,12 @@ class OfflineArea {
|
||||
this.tilesDownloaded = 0,
|
||||
this.tilesTotal = 0,
|
||||
this.cameras = const [],
|
||||
this.sizeBytes = 0,
|
||||
});
|
||||
|
||||
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},
|
||||
@@ -50,6 +55,7 @@ class OfflineArea {
|
||||
'tilesDownloaded': tilesDownloaded,
|
||||
'tilesTotal': tilesTotal,
|
||||
'cameras': cameras.map((c) => c.toJson()).toList(),
|
||||
'sizeBytes': sizeBytes,
|
||||
};
|
||||
|
||||
static OfflineArea fromJson(Map<String, dynamic> json) {
|
||||
@@ -59,6 +65,7 @@ class OfflineArea {
|
||||
);
|
||||
return OfflineArea(
|
||||
id: json['id'],
|
||||
name: json['name'] ?? '',
|
||||
bounds: bounds,
|
||||
minZoom: json['minZoom'],
|
||||
maxZoom: json['maxZoom'],
|
||||
@@ -70,12 +77,31 @@ class OfflineArea {
|
||||
tilesTotal: json['tilesTotal'] ?? 0,
|
||||
cameras: (json['cameras'] as List? ?? [])
|
||||
.map((e) => OsmCameraNode.fromJson(e)).toList(),
|
||||
sizeBytes: json['sizeBytes'] ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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._() {
|
||||
@@ -111,16 +137,18 @@ class OfflineAreaService {
|
||||
required String directory,
|
||||
void Function(double progress)? onProgress,
|
||||
void Function(OfflineAreaStatus status)? onComplete,
|
||||
String? name,
|
||||
}) async {
|
||||
final area = OfflineArea(
|
||||
id: id,
|
||||
name: name ?? '',
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
);
|
||||
_areas.add(area);
|
||||
await _saveAreasToDisk();
|
||||
await saveAreasToDisk();
|
||||
|
||||
try {
|
||||
// STEP 1: Tiles (incl. global z=1..4)
|
||||
@@ -137,21 +165,23 @@ class OfflineAreaService {
|
||||
area.tilesDownloaded = done;
|
||||
area.progress = done / area.tilesTotal;
|
||||
if (onProgress != null) onProgress(area.progress);
|
||||
await _saveAreasToDisk();
|
||||
await getAreaSizeBytes(area); // Update size as we download
|
||||
await saveAreasToDisk();
|
||||
}
|
||||
|
||||
// STEP 2: Fetch cameras for this bbox (all, not limited!)
|
||||
final cameras = await _downloadAllCameras(bounds);
|
||||
area.cameras = cameras;
|
||||
await _saveCameras(cameras, directory);
|
||||
await getAreaSizeBytes(area);
|
||||
|
||||
area.status = OfflineAreaStatus.complete;
|
||||
area.progress = 1.0;
|
||||
await _saveAreasToDisk();
|
||||
await saveAreasToDisk();
|
||||
if (onComplete != null) onComplete(area.status);
|
||||
} catch (e) {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
await _saveAreasToDisk();
|
||||
await saveAreasToDisk();
|
||||
if (onComplete != null) onComplete(area.status);
|
||||
}
|
||||
}
|
||||
@@ -159,19 +189,22 @@ class OfflineAreaService {
|
||||
void cancelDownload(String id) {
|
||||
final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found');
|
||||
area.status = OfflineAreaStatus.cancelled;
|
||||
_saveAreasToDisk();
|
||||
saveAreasToDisk();
|
||||
}
|
||||
|
||||
void deleteArea(String id) async {
|
||||
final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found');
|
||||
await Directory(area.directory).delete(recursive: true);
|
||||
final dir = Directory(area.directory);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
_areas.remove(area);
|
||||
await _saveAreasToDisk();
|
||||
await saveAreasToDisk();
|
||||
}
|
||||
|
||||
// --- PERSISTENCE LOGIC ---
|
||||
|
||||
Future<void> _saveAreasToDisk() async {
|
||||
Future<void> saveAreasToDisk() async {
|
||||
try {
|
||||
final file = await _getMetadataPath();
|
||||
final content = jsonEncode(_areas.map((a) => a.toJson()).toList());
|
||||
@@ -193,6 +226,9 @@ class OfflineAreaService {
|
||||
// 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);
|
||||
}
|
||||
@@ -205,16 +241,29 @@ class OfflineAreaService {
|
||||
|
||||
/// Returns set of [z, x, y] tuples needed to cover [bounds] at [zMin]..[zMax].
|
||||
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
// Now a public method to support dialog estimation.
|
||||
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++) {
|
||||
// Lower bounds: .floor(), upper bounds: .ceil()-1 for inclusivity
|
||||
final minTile = _latLonToTile(bounds.southWest.latitude, bounds.southWest.longitude, z);
|
||||
final neTileRaw = _latLonToTileRaw(bounds.northEast.latitude, bounds.northEast.longitude, z);
|
||||
final maxX = neTileRaw[0].ceil() - 1;
|
||||
final maxY = neTileRaw[1].ceil() - 1;
|
||||
final minX = minTile[0];
|
||||
final minY = minTile[1];
|
||||
// Convert both corners and clamp
|
||||
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]);
|
||||
|
||||
Reference in New Issue
Block a user