Merge pull request #15 from FoggedLens/sandbox-enhancements

Sandbox enhancements
This commit is contained in:
stopflock
2025-09-28 23:17:52 -05:00
committed by GitHub
9 changed files with 263 additions and 30 deletions
+2 -2
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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);
+78 -7
View File
@@ -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;
}
+1 -17
View File
@@ -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),
+41 -1
View File
@@ -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
View File
@@ -793,7 +793,7 @@ packages:
source: hosted
version: "1.1.0"
xml:
dependency: transitive
dependency: "direct main"
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
+1
View File
@@ -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