mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-05-15 21:48:18 +02:00
Merge pull request #15 from FoggedLens/sandbox-enhancements
Sandbox enhancements
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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<void> 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
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
|
||||
@@ -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 <OsmCameraNode>[];
|
||||
} 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<Future<List<OsmCameraNode>>> 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<List<OsmCameraNode>> _fetchRemoteNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<OsmCameraNode>> fetchOsmApiNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> 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 = <OsmCameraNode>[];
|
||||
|
||||
// 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 = <String, String>{};
|
||||
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<String, String> nodeTags, List<NodeProfile> 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<String, String> 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;
|
||||
}
|
||||
@@ -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<OperatorProfile?>(
|
||||
@@ -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),
|
||||
|
||||
@@ -129,6 +129,42 @@ class MapViewState extends State<MapView> {
|
||||
static Future<void> clearStoredMapPosition() =>
|
||||
MapPositionManager.clearStoredMapPosition();
|
||||
|
||||
/// Get minimum zoom level for camera fetching based on upload mode
|
||||
int _getMinZoomForCameras(BuildContext context) {
|
||||
final appState = context.read<AppState>();
|
||||
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<AppState>();
|
||||
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<MapView> {
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
+1
-1
@@ -793,7 +793,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user