diff --git a/do_builds.sh b/do_builds.sh index 11b3b07..8e3f548 100755 --- a/do_builds.sh +++ b/do_builds.sh @@ -36,7 +36,7 @@ if [ "$BUILD_IOS" = true ]; then ./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1 echo "Moving iOS files..." - mv Runner.ipa "../flockmap_v${appver}.ipa" || exit 1 + mv Runner.ipa "../deflock_v${appver}.ipa" || exit 1 echo fi @@ -45,7 +45,7 @@ if [ "$BUILD_ANDROID" = true ]; then flutter build apk || exit 1 echo "Moving Android files..." - cp build/app/outputs/flutter-apk/app-release.apk "../flockmap_v${appver}.apk" || exit 1 + cp build/app/outputs/flutter-apk/app-release.apk "../deflock_v${appver}.apk" || exit 1 echo fi diff --git a/lib/app_state.dart b/lib/app_state.dart index cec1b47..16765ae 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -8,6 +8,8 @@ import 'models/osm_camera_node.dart'; import 'models/pending_upload.dart'; import 'models/tile_provider.dart'; import 'services/offline_area_service.dart'; +import 'services/node_cache.dart'; +import 'widgets/camera_provider_with_cache.dart'; import 'state/auth_state.dart'; import 'state/operator_profile_state.dart'; import 'state/profile_state.dart'; @@ -238,6 +240,11 @@ class AppState extends ChangeNotifier { } Future setUploadMode(UploadMode mode) async { + // Clear node cache when switching upload modes to prevent mixing production/sandbox data + NodeCache.instance.clear(); + CameraProviderWithCache.instance.notifyListeners(); + debugPrint('[AppState] Cleared node cache due to upload mode change'); + await _settingsState.setUploadMode(mode); await _authState.onUploadModeChanged(mode); _startUploader(); // Restart uploader with new mode diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 16fd4df..2b61dc7 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -34,10 +34,11 @@ const String kClientName = 'DeFlock'; const String kClientVersion = '0.9.11'; // Development/testing features - set to false for production builds -const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode +const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode // Marker/node interaction -const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes or warning +const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass) +const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode) const Duration kMarkerTapTimeout = Duration(milliseconds: 250); const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index d7ea4f8..9c5ad29 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -6,6 +6,7 @@ import '../models/node_profile.dart'; import '../models/osm_camera_node.dart'; import '../app_state.dart'; import 'map_data_submodules/nodes_from_overpass.dart'; +import 'map_data_submodules/nodes_from_osm_api.dart'; import 'map_data_submodules/tiles_from_remote.dart'; import 'map_data_submodules/nodes_from_local.dart'; import 'map_data_submodules/tiles_from_local.dart'; @@ -48,7 +49,7 @@ class MapDataProvider { if (offline) { throw OfflineModeException("Cannot fetch remote nodes in offline mode."); } - return fetchOverpassNodes( + return _fetchRemoteNodes( bounds: bounds, profiles: profiles, uploadMode: uploadMode, @@ -64,15 +65,31 @@ class MapDataProvider { ); } - // AUTO: In offline mode, only fetch local. In online mode, fetch both remote and local, then merge. + // AUTO: In offline mode, behavior depends on upload mode if (offline) { - return fetchLocalNodes( + if (uploadMode == UploadMode.sandbox) { + // Offline + Sandbox = no nodes (local cache is production data) + debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)'); + return []; + } else { + // Offline + Production = use local cache + return fetchLocalNodes( + bounds: bounds, + profiles: profiles, + maxNodes: AppState.instance.maxCameras, + ); + } + } else if (uploadMode == UploadMode.sandbox) { + // Sandbox mode: Only fetch from sandbox API, ignore local production nodes + debugPrint('[MapDataProvider] Sandbox mode: fetching only from sandbox API, ignoring local cache'); + return _fetchRemoteNodes( bounds: bounds, profiles: profiles, - maxNodes: AppState.instance.maxCameras, + uploadMode: uploadMode, + maxResults: AppState.instance.maxCameras, ); } else { - // Online mode: fetch both remote and local, then merge with deduplication + // Production mode: fetch both remote and local, then merge with deduplication final List>> futures = []; // Always try to get local nodes (fast, cached) @@ -83,7 +100,7 @@ class MapDataProvider { )); // Always try to get remote nodes (slower, fresh data) - futures.add(fetchOverpassNodes( + futures.add(_fetchRemoteNodes( bounds: bounds, profiles: profiles, uploadMode: uploadMode, @@ -134,7 +151,7 @@ class MapDataProvider { if (offline) { throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode."); } - return fetchOverpassNodes( + return _fetchRemoteNodes( bounds: bounds, profiles: profiles, uploadMode: uploadMode, @@ -195,4 +212,58 @@ class MapDataProvider { void clearTileQueue() { clearRemoteTileQueue(); } + + /// Fetch remote nodes with Overpass first, OSM API fallback + Future> _fetchRemoteNodes({ + required LatLngBounds bounds, + required List profiles, + UploadMode uploadMode = UploadMode.production, + required int maxResults, + }) async { + // For sandbox mode, skip Overpass and go directly to OSM API + // (Overpass doesn't have sandbox data) + if (uploadMode == UploadMode.sandbox) { + debugPrint('[MapDataProvider] Sandbox mode detected, using OSM API directly'); + return fetchOsmApiNodes( + bounds: bounds, + profiles: profiles, + uploadMode: uploadMode, + maxResults: maxResults, + ); + } + + // For production mode, try Overpass first, then fallback to OSM API + try { + final nodes = await fetchOverpassNodes( + bounds: bounds, + profiles: profiles, + uploadMode: uploadMode, + maxResults: maxResults, + ); + + // If Overpass returns nodes, we're good + if (nodes.isNotEmpty) { + return nodes; + } + + // If Overpass returns empty (could be no data or could be an issue), + // try OSM API as well to be thorough + debugPrint('[MapDataProvider] Overpass returned no nodes, trying OSM API fallback'); + return fetchOsmApiNodes( + bounds: bounds, + profiles: profiles, + uploadMode: uploadMode, + maxResults: maxResults, + ); + + } catch (e) { + debugPrint('[MapDataProvider] Overpass failed ($e), trying OSM API fallback'); + return fetchOsmApiNodes( + bounds: bounds, + profiles: profiles, + uploadMode: uploadMode, + maxResults: maxResults, + ); + } + } } \ No newline at end of file diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart new file mode 100644 index 0000000..01a8363 --- /dev/null +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:xml/xml.dart'; + +import '../../models/node_profile.dart'; +import '../../models/osm_camera_node.dart'; +import '../../app_state.dart'; +import '../network_status.dart'; + +/// Fetches surveillance nodes from the direct OSM API using bbox query. +/// This is a fallback for when Overpass is not available (e.g., sandbox mode). +Future> fetchOsmApiNodes({ + required LatLngBounds bounds, + required List profiles, + UploadMode uploadMode = UploadMode.production, + required int maxResults, +}) async { + if (profiles.isEmpty) return []; + + // Choose API endpoint based on upload mode + final String apiHost = uploadMode == UploadMode.sandbox + ? 'api06.dev.openstreetmap.org' + : 'api.openstreetmap.org'; + + // Build the map query URL - fetches all data in bounding box + final left = bounds.southWest.longitude; + final bottom = bounds.southWest.latitude; + final right = bounds.northEast.longitude; + final top = bounds.northEast.latitude; + + final url = 'https://$apiHost/api/0.6/map?bbox=$left,$bottom,$right,$top'; + + try { + debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...'); + debugPrint('[fetchOsmApiNodes] URL: $url'); + + final response = await http.get(Uri.parse(url)); + + if (response.statusCode != 200) { + debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}'); + NetworkStatus.instance.reportOverpassIssue(); // Reuse same status tracking + return []; + } + + // Parse XML response + final document = XmlDocument.parse(response.body); + final nodes = []; + + // Find all node elements + for (final nodeElement in document.findAllElements('node')) { + final id = int.tryParse(nodeElement.getAttribute('id') ?? ''); + final latStr = nodeElement.getAttribute('lat'); + final lonStr = nodeElement.getAttribute('lon'); + + if (id == null || latStr == null || lonStr == null) continue; + + final lat = double.tryParse(latStr); + final lon = double.tryParse(lonStr); + if (lat == null || lon == null) continue; + + // Parse tags + final tags = {}; + for (final tagElement in nodeElement.findElements('tag')) { + final key = tagElement.getAttribute('k'); + final value = tagElement.getAttribute('v'); + if (key != null && value != null) { + tags[key] = value; + } + } + + // Check if this node matches any of our profiles + if (_nodeMatchesProfiles(tags, profiles)) { + nodes.add(OsmCameraNode( + id: id, + coord: LatLng(lat, lon), + tags: tags, + )); + } + + // Respect maxResults limit if set + if (maxResults > 0 && nodes.length >= maxResults) { + break; + } + } + + if (nodes.isNotEmpty) { + debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes'); + } + + NetworkStatus.instance.reportOverpassSuccess(); // Reuse same status tracking + return nodes; + + } catch (e) { + debugPrint('[fetchOsmApiNodes] Exception: $e'); + + // Report network issues for connection errors + if (e.toString().contains('Connection refused') || + e.toString().contains('Connection timed out') || + e.toString().contains('Connection reset')) { + NetworkStatus.instance.reportOverpassIssue(); + } + + return []; + } +} + +/// Check if a node's tags match any of the given profiles +bool _nodeMatchesProfiles(Map nodeTags, List profiles) { + for (final profile in profiles) { + if (_nodeMatchesProfile(nodeTags, profile)) { + return true; + } + } + return false; +} + +/// Check if a node's tags match a specific profile +bool _nodeMatchesProfile(Map nodeTags, NodeProfile profile) { + // All profile tags must be present in the node for it to match + for (final entry in profile.tags.entries) { + if (nodeTags[entry.key] != entry.value) { + return false; + } + } + return true; +} \ No newline at end of file diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index 9e1c110..54ea91d 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -36,7 +36,7 @@ class EditNodeSheet extends StatelessWidget { final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList(); final isSandboxMode = appState.uploadMode == UploadMode.sandbox; - final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable && !isSandboxMode; + final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable; void _openRefineTags() async { final result = await Navigator.push( @@ -130,22 +130,6 @@ class EditNodeSheet extends StatelessWidget { ], ), ) - else if (isSandboxMode) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Row( - children: [ - const Icon(Icons.info_outline, color: Colors.blue, size: 20), - const SizedBox(width: 6), - Expanded( - child: Text( - locService.t('editNode.sandboxModeWarning'), - style: const TextStyle(color: Colors.blue, fontSize: 13), - ), - ), - ], - ), - ) else if (submittableProfiles.isEmpty) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index b2b911d..36e81c3 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -129,6 +129,42 @@ class MapViewState extends State { static Future clearStoredMapPosition() => MapPositionManager.clearStoredMapPosition(); + /// Get minimum zoom level for camera fetching based on upload mode + int _getMinZoomForCameras(BuildContext context) { + final appState = context.read(); + final uploadMode = appState.uploadMode; + + // OSM API (sandbox mode) needs higher zoom level due to bbox size limits + if (uploadMode == UploadMode.sandbox) { + return kOsmApiMinZoomLevel; + } else { + return kCameraMinZoomLevel; + } + } + + /// Show zoom warning if user is below minimum zoom level + void _showZoomWarningIfNeeded(BuildContext context, double currentZoom, int minZoom) { + // Only show warning once per zoom level to avoid spam + if (currentZoom.floor() == (minZoom - 1)) { + final appState = context.read(); + final uploadMode = appState.uploadMode; + + final message = uploadMode == UploadMode.sandbox + ? 'Zoom to level $minZoom or higher to see nodes in sandbox mode (OSM API bbox limit)' + : 'Zoom to level $minZoom or higher to see surveillance nodes'; + + // Show a brief snackbar + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 4), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + void _refreshCamerasFromProvider() { @@ -321,11 +357,15 @@ class MapViewState extends State { }); // Request more cameras on any map movement/zoom at valid zoom level (slower debounce) - if (pos.zoom >= 10) { + final minZoom = _getMinZoomForCameras(context); + if (pos.zoom >= minZoom) { _cameraDebounce(_refreshCamerasFromProvider); } else { // Skip nodes at low zoom - report immediate completion (brutalist approach) NetworkStatus.instance.reportNodeComplete(); + + // Show zoom warning if needed + _showZoomWarningIfNeeded(context, pos.zoom, minZoom); } }, ), diff --git a/pubspec.lock b/pubspec.lock index cc5d217..b522d63 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -793,7 +793,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 diff --git a/pubspec.yaml b/pubspec.yaml index 6a4e7c7..d8f8ed9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: geolocator: ^10.1.0 http: ^1.2.1 flutter_svg: ^2.0.10 + xml: ^6.4.2 # Auth, storage, prefs oauth2_client: ^4.2.0