diff --git a/assets/info.txt b/assets/info.txt new file mode 100644 index 0000000..96fda99 --- /dev/null +++ b/assets/info.txt @@ -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. diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 56b59e4..8706bbf 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -96,16 +96,38 @@ class DownloadAreaDialog extends StatefulWidget { class _DownloadAreaDialogState extends State { 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 { 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 { 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 { ], ); } - - int _findDynamicMinZoom(LatLngBounds bounds) { - // For now, just pick 12 as min; can implement dynamic min‑zoom by area - return 12; - } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 1a3fa34..ed71337 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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( + 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'), - ), - ], ), ); }, diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 018abb7..2f621bc 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -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 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 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 _areas = []; + /// Where offline area data/metadata lives + Future getOfflineAreaDir() async { + final dir = await getApplicationDocumentsDirectory(); + final areaRoot = Directory("${dir.path}/offline_areas"); + if (!areaRoot.existsSync()) { + areaRoot.createSync(recursive: true); + } + return areaRoot; + } + + Future _getMetadataPath() async { + final dir = await getOfflineAreaDir(); + return File("${dir.path}/offline_areas.json"); + } + List 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 _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 _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> _computeTileList(LatLngBounds bounds, int zMin, int zMax) { + Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { + // Now a public method to support dialog estimation. Set> 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 _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 _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)); }