diff --git a/lib/models/osm_camera_node.dart b/lib/models/osm_camera_node.dart index 7e994b4..dab9cd5 100644 --- a/lib/models/osm_camera_node.dart +++ b/lib/models/osm_camera_node.dart @@ -11,6 +11,27 @@ class OsmCameraNode { required this.tags, }); + Map toJson() => { + 'id': id, + 'lat': coord.latitude, + 'lon': coord.longitude, + 'tags': tags, + }; + + factory OsmCameraNode.fromJson(Map json) { + final tags = {}; + if (json['tags'] != null) { + (json['tags'] as Map).forEach((k, v) { + tags[k.toString()] = v.toString(); + }); + } + return OsmCameraNode( + id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0, + coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()), + tags: tags, + ); + } + bool get hasDirection => tags.containsKey('direction') || tags.containsKey('camera:direction'); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f62159f..1a3fa34 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart'; import '../app_state.dart'; import '../models/camera_profile.dart'; import 'profile_editor.dart'; +import '../services/offline_area_service.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -18,7 +19,7 @@ class SettingsScreen extends StatelessWidget { body: ListView( padding: const EdgeInsets.all(16), children: [ - // Authentication section + // 1. Authentication section ListTile( leading: Icon( appState.isLoggedIn ? Icons.person : Icons.login, @@ -27,7 +28,7 @@ class SettingsScreen extends StatelessWidget { title: Text(appState.isLoggedIn ? 'Logged in as ${appState.username}' : 'Log in to OpenStreetMap'), - subtitle: appState.isLoggedIn + subtitle: appState.isLoggedIn ? const Text('Tap to logout') : const Text('Required to submit camera data'), onTap: () async { @@ -39,7 +40,7 @@ class SettingsScreen extends StatelessWidget { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(appState.isLoggedIn + content: Text(appState.isLoggedIn ? 'Logged in as ${appState.username}' : 'Logged out'), backgroundColor: appState.isLoggedIn ? Colors.green : Colors.grey, @@ -48,7 +49,7 @@ class SettingsScreen extends StatelessWidget { } }, ), - // Test connection (only when logged in) + // 1.5 Test connection (only when logged in) if (appState.isLoggedIn) ListTile( leading: const Icon(Icons.wifi_protected_setup), @@ -73,6 +74,109 @@ class SettingsScreen extends StatelessWidget { }, ), const Divider(), + // 2. Upload mode selector + ListTile( + leading: const Icon(Icons.cloud_upload), + title: const Text('Upload Destination'), + subtitle: const Text('Choose where cameras are uploaded'), + trailing: DropdownButton( + value: appState.uploadMode, + items: const [ + DropdownMenuItem( + value: UploadMode.production, + child: Text('Production'), + ), + DropdownMenuItem( + value: UploadMode.sandbox, + child: Text('Sandbox'), + ), + DropdownMenuItem( + value: UploadMode.simulate, + child: Text('Simulate'), + ), + ], + onChanged: (mode) { + if (mode != null) appState.setUploadMode(mode); + }, + ), + ), + // Help text + Padding( + padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12), + child: Builder( + builder: (context) { + switch (appState.uploadMode) { + case UploadMode.production: + return const Text('Upload to the live OSM database (visible to all users)', style: TextStyle(fontSize: 12, color: Colors.black87)); + case UploadMode.sandbox: + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Uploads go to the OSM Sandbox (safe for testing, resets regularly).', + style: TextStyle(fontSize: 12, color: Colors.orange), + ), + SizedBox(height: 2), + Text( + 'NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.', + style: TextStyle(fontSize: 11, color: Colors.redAccent), + ), + ], + ); + case UploadMode.simulate: + default: + return const Text('Simulate uploads (does not contact OSM servers)', style: TextStyle(fontSize: 12, color: Colors.deepPurple)); + } + }, + ), + ), + const Divider(), + // 3. Queue management + ListTile( + leading: const Icon(Icons.queue), + title: Text('Pending uploads: ${appState.pendingCount}'), + subtitle: appState.uploadMode == UploadMode.simulate + ? const Text('Simulate mode enabled – uploads simulated') + : appState.uploadMode == UploadMode.sandbox + ? const Text('Sandbox mode – uploads go to OSM Sandbox') + : const Text('Tap to view queue'), + onTap: appState.pendingCount > 0 ? () { + _showQueueDialog(context, appState); + } : null, + ), + if (appState.pendingCount > 0) + ListTile( + leading: const Icon(Icons.clear_all), + title: const Text('Clear Upload Queue'), + subtitle: Text('Remove all ${appState.pendingCount} pending uploads'), + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Queue'), + content: Text('Remove all ${appState.pendingCount} pending uploads?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.clearQueue(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Queue cleared')), + ); + }, + child: const Text('Clear'), + ), + ], + ), + ); + }, + ), + const Divider(), + // 4. Camera Profiles Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -143,107 +247,38 @@ class SettingsScreen extends StatelessWidget { ), ), const Divider(), - // Upload mode selector - Production/Sandbox/Simulate - ListTile( - leading: const Icon(Icons.cloud_upload), - title: const Text('Upload Destination'), - subtitle: const Text('Choose where cameras are uploaded'), - trailing: DropdownButton( - value: appState.uploadMode, - items: const [ - DropdownMenuItem( - value: UploadMode.production, - child: Text('Production'), - ), - DropdownMenuItem( - value: UploadMode.sandbox, - child: Text('Sandbox'), - ), - DropdownMenuItem( - value: UploadMode.simulate, - child: Text('Simulate'), - ), - ], - onChanged: (mode) { - if (mode != null) appState.setUploadMode(mode); - }, - ), - ), - // Help text - Padding( - padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12), - child: Builder( - builder: (context) { - switch (appState.uploadMode) { - case UploadMode.production: - return const Text('Upload to the live OSM database (visible to all users)', style: TextStyle(fontSize: 12, color: Colors.black87)); - case UploadMode.sandbox: - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Uploads go to the OSM Sandbox (safe for testing, resets regularly).', - style: TextStyle(fontSize: 12, color: Colors.orange), - ), - SizedBox(height: 2), - Text( - 'NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.', - style: TextStyle(fontSize: 11, color: Colors.redAccent), - ), - ], - ); - case UploadMode.simulate: - default: - return const Text('Simulate uploads (does not contact OSM servers)', style: TextStyle(fontSize: 12, color: Colors.deepPurple)); - } - }, - ), + // 5. --- Offline Areas Section --- + const Padding( + padding: EdgeInsets.only(bottom: 8.0), + child: Text('Offline Areas', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), + _OfflineAreasSection(), const Divider(), - // Queue management + // 6. About/info button ListTile( - leading: const Icon(Icons.queue), - title: Text('Pending uploads: ${appState.pendingCount}'), - subtitle: appState.uploadMode == UploadMode.simulate - ? const Text('Simulate mode enabled – uploads simulated') - : appState.uploadMode == UploadMode.sandbox - ? const Text('Sandbox mode – uploads go to OSM Sandbox') - : const Text('Tap to view queue'), - onTap: appState.pendingCount > 0 ? () { - _showQueueDialog(context, appState); - } : null, - ), - if (appState.pendingCount > 0) - ListTile( - leading: const Icon(Icons.clear_all), - title: const Text('Clear Upload Queue'), - subtitle: Text('Remove all ${appState.pendingCount} pending uploads'), - onTap: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Clear Queue'), - content: Text('Remove all ${appState.pendingCount} pending uploads?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - appState.clearQueue(); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Queue cleared')), - ); - }, - child: const Text('Clear'), - ), - ], + leading: const Icon(Icons.info_outline), + title: const Text('About / Info'), + onTap: () async { + // 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.)', + ), ), - ); - }, - ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + }, + ), ], ), ); @@ -331,3 +366,89 @@ class SettingsScreen extends StatelessWidget { ); } } + +// --- Offline Areas UI section --- + +class _OfflineAreasSection extends StatefulWidget { + @override + State<_OfflineAreasSection> createState() => _OfflineAreasSectionState(); +} + +class _OfflineAreasSectionState extends State<_OfflineAreasSection> { + final OfflineAreaService service = OfflineAreaService(); + + @override + void initState() { + super.initState(); + // Polling for now; can improve with ChangeNotifier or Streams pattern later. + Future.doWhile(() async { + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return false; + setState(() {}); + return true; + }); + } + + @override + Widget build(BuildContext context) { + final areas = service.offlineAreas; + if (areas.isEmpty) { + return const ListTile( + leading: Icon(Icons.download_for_offline), + title: Text('No offline areas'), + subtitle: Text('Download a map area for offline use.'), + ); + } + return Column( + children: areas.map((area) { + 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)}'; + if (area.status == OfflineAreaStatus.complete) { + subtitle += '\nCameras cached: ${area.cameras.length}'; + } + return Card( + child: ListTile( + leading: Icon(area.status == OfflineAreaStatus.complete + ? Icons.cloud_done + : area.status == OfflineAreaStatus.error + ? Icons.error + : Icons.download_for_offline), + title: Text('Area ${area.id.substring(0, 6)}...'), + subtitle: Text(subtitle), + isThreeLine: true, + trailing: area.status == OfflineAreaStatus.downloading + ? SizedBox( + width: 64, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LinearProgressIndicator(value: area.progress), + Text( + '${(area.progress * 100).toStringAsFixed(0)}%', + style: const TextStyle(fontSize: 12), + ) + ], + ), + ) + : IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + tooltip: 'Delete offline area', + onPressed: () async { + service.deleteArea(area.id); + setState(() {}); + }, + ), + onLongPress: area.status == OfflineAreaStatus.downloading + ? () { + service.cancelDownload(area.id); + setState(() {}); + } + : null, + ), + ); + }).toList(), + ); + } +} diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart new file mode 100644 index 0000000..018abb7 --- /dev/null +++ b/lib/services/offline_area_service.dart @@ -0,0 +1,174 @@ +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 '../models/osm_camera_node.dart'; + +/// Model for an offline area +enum OfflineAreaStatus { downloading, complete, error, cancelled } + +class OfflineArea { + final String id; + 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; + + OfflineArea({ + required this.id, + 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 [], + }); +} + +/// Service for managing download, storage, and retrieval of offline map areas and cameras. +class OfflineAreaService { + static final OfflineAreaService _instance = OfflineAreaService._(); + factory OfflineAreaService() => _instance; + OfflineAreaService._(); + + final List _areas = []; + + 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, + }) async { + final area = OfflineArea( + id: id, + bounds: bounds, + minZoom: minZoom, + maxZoom: maxZoom, + directory: directory, + ); + _areas.add(area); + + try { + // STEP 1: Tiles (incl. global z=1..4) + final tileTasks = _computeTileList(bounds, minZoom, maxZoom); + final globalTiles = _computeTileList(_globalWorldBounds(), 1, 4); + final allTiles = {...tileTasks, ...globalTiles}; + area.tilesTotal = allTiles.length; + + int done = 0; + for (final tile in allTiles) { + if (area.status == OfflineAreaStatus.cancelled) break; + await _downloadTile(tile[0], tile[1], tile[2], directory); + done++; + area.tilesDownloaded = done; + area.progress = done / area.tilesTotal; + if (onProgress != null) onProgress(area.progress); + } + + // STEP 2: Fetch cameras for this bbox (all, not limited!) + final cameras = await _downloadAllCameras(bounds); + area.cameras = cameras; + await _saveCameras(cameras, directory); + + area.status = OfflineAreaStatus.complete; + area.progress = 1.0; + if (onComplete != null) onComplete(area.status); + } catch (e) { + area.status = OfflineAreaStatus.error; + if (onComplete != null) onComplete(area.status); + } + } + + void cancelDownload(String id) { + final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found'); + area.status = OfflineAreaStatus.cancelled; + } + + void deleteArea(String id) async { + final area = _areas.firstWhere((a) => a.id == id, orElse: () => throw 'Area not found'); + Directory(area.directory).delete(recursive: true); + _areas.remove(area); + } + + // --- 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 = {}; + for (int z = zMin; z <= zMax; z++) { + 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++) { + tiles.add([z, x, y]); + } + } + } + return tiles; + } + + /// 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() { + return LatLngBounds(LatLng(-85.0511, -180.0), LatLng(85.0511, 180.0)); + } + + 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 + final resp = await http.get(Uri.parse(url)); + if (resp.statusCode == 200) { + await file.writeAsBytes(resp.bodyBytes); + } else { + throw Exception('Failed to download tile $z/$x/$y'); + } + } + + // --- 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'); + } + 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())); + } +}