mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
moving the right direction
This commit is contained in:
7
assets/info.txt
Normal file
7
assets/info.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Flock Map App
|
||||
|
||||
Built with Flutter.
|
||||
|
||||
Offline areas, privacy-respecting, designed for OpenStreetMap camera tagging.
|
||||
|
||||
This text is loaded from assets/info.txt.
|
||||
@@ -96,16 +96,38 @@ class DownloadAreaDialog extends StatefulWidget {
|
||||
|
||||
class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
double _zoom = 15;
|
||||
int? _minZoom;
|
||||
int? _tileCount;
|
||||
double? _mbEstimate;
|
||||
|
||||
// Fake estimation: about 0.5 MB per zoom per km² for now
|
||||
String get _storageEstimate {
|
||||
// This can be improved later to use map bounds
|
||||
final estMb = (0.5 * (_zoom - 11)).clamp(1, 50);
|
||||
return 'Est: ${estMb.toStringAsFixed(1)} MB';
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
}
|
||||
|
||||
void _recomputeEstimates() {
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final minZoom = OfflineAreaService().findDynamicMinZoom(bounds);
|
||||
final maxZoom = _zoom.toInt();
|
||||
final allTiles = OfflineAreaService().computeTileList(bounds, minZoom, maxZoom);
|
||||
final worldTiles = OfflineAreaService().computeTileList(
|
||||
OfflineAreaService().globalWorldBounds(), 1, 4);
|
||||
final nTiles = allTiles.length + worldTiles.length;
|
||||
const kbPerTile = 25; // Average PNG tile size
|
||||
final totalMb = (nTiles * kbPerTile) / 1024.0;
|
||||
setState(() {
|
||||
_minZoom = minZoom;
|
||||
_tileCount = nTiles;
|
||||
_mbEstimate = totalMb;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final maxZoom = _zoom.toInt();
|
||||
// We recompute estimates when the zoom slider changes
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: const [
|
||||
@@ -132,16 +154,29 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
divisions: 7,
|
||||
label: 'Z${_zoom.toStringAsFixed(0)}',
|
||||
value: _zoom,
|
||||
onChanged: (v) => setState(() => _zoom = v),
|
||||
onChanged: (v) {
|
||||
setState(() => _zoom = v);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Storage estimate:'),
|
||||
Text(_storageEstimate),
|
||||
Text(_mbEstimate == null
|
||||
? '…'
|
||||
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB'),
|
||||
],
|
||||
),
|
||||
if (_minZoom != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Min zoom:'),
|
||||
Text('Z$_minZoom'),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -153,16 +188,13 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final maxZoom = _zoom.toInt();
|
||||
final minZoom = _findDynamicMinZoom(bounds);
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final dir = '/tmp/offline_areas/$id';
|
||||
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
await OfflineAreaService().downloadArea(
|
||||
id: id,
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
minZoom: _minZoom ?? 12,
|
||||
maxZoom: maxZoom,
|
||||
directory: dir,
|
||||
onProgress: (progress) {},
|
||||
@@ -188,10 +220,5 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
int _findDynamicMinZoom(LatLngBounds bounds) {
|
||||
// For now, just pick 12 as min; can implement dynamic min‑zoom by area
|
||||
return 12;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,19 +262,24 @@ class SettingsScreen extends StatelessWidget {
|
||||
// show dialog with text (replace with file contents as needed)
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('About This App'),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
'Flock Map App\n\nBuilt with Flutter.\n\nOffline areas, privacy-respecting, designed for OpenStreetMap camera tagging.\n\n(Replace this with info.txt contents.)',
|
||||
builder: (context) => FutureBuilder<String>(
|
||||
future: DefaultAssetBundle.of(context).loadString('assets/info.txt'),
|
||||
builder: (context, snapshot) => AlertDialog(
|
||||
title: const Text('About This App'),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
snapshot.connectionState == ConnectionState.done
|
||||
? (snapshot.data ?? 'No info available.')
|
||||
: 'Loading...',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 '../models/osm_camera_node.dart';
|
||||
|
||||
/// Model for an offline area
|
||||
@@ -34,16 +35,70 @@ class OfflineArea {
|
||||
this.tilesTotal = 0,
|
||||
this.cameras = const [],
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'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(),
|
||||
};
|
||||
|
||||
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'],
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Service for managing download, storage, and retrieval of offline map areas and cameras.
|
||||
class OfflineAreaService {
|
||||
static final OfflineAreaService _instance = OfflineAreaService._();
|
||||
factory OfflineAreaService() => _instance;
|
||||
OfflineAreaService._();
|
||||
OfflineAreaService._() {
|
||||
_loadAreasFromDisk();
|
||||
}
|
||||
|
||||
final List<OfflineArea> _areas = [];
|
||||
|
||||
/// Where offline area data/metadata lives
|
||||
Future<Directory> getOfflineAreaDir() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final areaRoot = Directory("${dir.path}/offline_areas");
|
||||
if (!areaRoot.existsSync()) {
|
||||
areaRoot.createSync(recursive: true);
|
||||
}
|
||||
return areaRoot;
|
||||
}
|
||||
|
||||
Future<File> _getMetadataPath() async {
|
||||
final dir = await getOfflineAreaDir();
|
||||
return File("${dir.path}/offline_areas.json");
|
||||
}
|
||||
|
||||
List<OfflineArea> get offlineAreas => List.unmodifiable(_areas);
|
||||
|
||||
/// Start downloading an area: tiles and camera points.
|
||||
@@ -65,11 +120,12 @@ class OfflineAreaService {
|
||||
directory: directory,
|
||||
);
|
||||
_areas.add(area);
|
||||
await _saveAreasToDisk();
|
||||
|
||||
try {
|
||||
// STEP 1: Tiles (incl. global z=1..4)
|
||||
final tileTasks = _computeTileList(bounds, minZoom, maxZoom);
|
||||
final globalTiles = _computeTileList(_globalWorldBounds(), 1, 4);
|
||||
final tileTasks = computeTileList(bounds, minZoom, maxZoom);
|
||||
final globalTiles = computeTileList(globalWorldBounds(), 1, 4);
|
||||
final allTiles = {...tileTasks, ...globalTiles};
|
||||
area.tilesTotal = allTiles.length;
|
||||
|
||||
@@ -81,6 +137,7 @@ class OfflineAreaService {
|
||||
area.tilesDownloaded = done;
|
||||
area.progress = done / area.tilesTotal;
|
||||
if (onProgress != null) onProgress(area.progress);
|
||||
await _saveAreasToDisk();
|
||||
}
|
||||
|
||||
// STEP 2: Fetch cameras for this bbox (all, not limited!)
|
||||
@@ -90,9 +147,11 @@ class OfflineAreaService {
|
||||
|
||||
area.status = OfflineAreaStatus.complete;
|
||||
area.progress = 1.0;
|
||||
await _saveAreasToDisk();
|
||||
if (onComplete != null) onComplete(area.status);
|
||||
} catch (e) {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
await _saveAreasToDisk();
|
||||
if (onComplete != null) onComplete(area.status);
|
||||
}
|
||||
}
|
||||
@@ -100,24 +159,64 @@ class OfflineAreaService {
|
||||
void cancelDownload(String id) {
|
||||
final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found');
|
||||
area.status = OfflineAreaStatus.cancelled;
|
||||
_saveAreasToDisk();
|
||||
}
|
||||
|
||||
void deleteArea(String id) async {
|
||||
final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found');
|
||||
Directory(area.directory).delete(recursive: true);
|
||||
await Directory(area.directory).delete(recursive: true);
|
||||
_areas.remove(area);
|
||||
await _saveAreasToDisk();
|
||||
}
|
||||
|
||||
// --- PERSISTENCE LOGIC ---
|
||||
|
||||
Future<void> _saveAreasToDisk() async {
|
||||
try {
|
||||
final file = await _getMetadataPath();
|
||||
final content = jsonEncode(_areas.map((a) => a.toJson()).toList());
|
||||
await file.writeAsString(content);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to save offline areas: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAreasFromDisk() async {
|
||||
try {
|
||||
final file = await _getMetadataPath();
|
||||
if (!(await file.exists())) return;
|
||||
final str = await file.readAsString();
|
||||
final data = jsonDecode(str);
|
||||
_areas.clear();
|
||||
for (final areaJson in (data as List)) {
|
||||
final area = OfflineArea.fromJson(areaJson);
|
||||
// Check if directory still exists; adjust status if not
|
||||
if (!Directory(area.directory).existsSync()) {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
}
|
||||
_areas.add(area);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load offline areas: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
// Now a public method to support dialog estimation.
|
||||
Set<List<int>> tiles = {};
|
||||
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 maxTile = _latLonToTile(bounds.northEast.latitude, bounds.northEast.longitude, z);
|
||||
for (int x = minTile[0]; x <= maxTile[0]; x++) {
|
||||
for (int y = minTile[1]; y <= maxTile[1]; y++) {
|
||||
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];
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
for (int y = minY; y <= maxY; y++) {
|
||||
tiles.add([z, x, y]);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +224,27 @@ class OfflineAreaService {
|
||||
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;
|
||||
}
|
||||
}
|
||||
return maxSearchZoom;
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -133,7 +253,7 @@ class OfflineAreaService {
|
||||
return [xtile, ytile];
|
||||
}
|
||||
|
||||
LatLngBounds _globalWorldBounds() {
|
||||
LatLngBounds globalWorldBounds() {
|
||||
return LatLngBounds(LatLng(-85.0511, -180.0), LatLng(85.0511, 180.0));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user