use real node id for pending uploads, more camera -> node

This commit is contained in:
stopflock
2025-09-28 20:20:00 -05:00
parent 4ad33d17e0
commit 4053c9b39b
10 changed files with 231 additions and 101 deletions

View File

@@ -88,11 +88,8 @@ flutter run
## Roadmap
### v1 todo/bug List
- Fix "tiles loaded" indicator accuracy across different providers
- Generic tile provider error messages (not always "OSM tiles slow")
- Optional custom icons for camera profiles
- Camera deletions
- Clean up cache when submitted changesets appear in Overpass results
- Upgrade device marker design (considering nullplate's svg)
### Future Features & Wishlist

View File

@@ -34,7 +34,7 @@ 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

View File

@@ -10,6 +10,7 @@ class PendingUpload {
final OperatorProfile? operatorProfile;
final UploadMode uploadMode; // Capture upload destination when queued
final int? originalNodeId; // If this is an edit, the ID of the original OSM node
int? submittedNodeId; // The actual node ID returned by OSM after successful submission
int attempts;
bool error;
bool completing; // True when upload succeeded but item is showing checkmark briefly
@@ -21,12 +22,13 @@ class PendingUpload {
this.operatorProfile,
required this.uploadMode,
this.originalNodeId,
this.submittedNodeId,
this.attempts = 0,
this.error = false,
this.completing = false,
});
// True if this is an edit of an existing camera, false if it's a new camera
// True if this is an edit of an existing node, false if it's a new node
bool get isEdit => originalNodeId != null;
// Get display name for the upload destination
@@ -41,11 +43,11 @@ class PendingUpload {
}
}
// Get combined tags from camera profile and operator profile
// Get combined tags from node profile and operator profile
Map<String, String> getCombinedTags() {
final tags = Map<String, String>.from(profile.tags);
// Add operator profile tags (they override camera profile tags if there are conflicts)
// Add operator profile tags (they override node profile tags if there are conflicts)
if (operatorProfile != null) {
tags.addAll(operatorProfile!.tags);
}
@@ -66,6 +68,7 @@ class PendingUpload {
'operatorProfile': operatorProfile?.toJson(),
'uploadMode': uploadMode.index,
'originalNodeId': originalNodeId,
'submittedNodeId': submittedNodeId,
'attempts': attempts,
'error': error,
'completing': completing,
@@ -84,6 +87,7 @@ class PendingUpload {
? UploadMode.values[j['uploadMode']]
: UploadMode.production, // Default for legacy entries
originalNodeId: j['originalNodeId'],
submittedNodeId: j['submittedNodeId'],
attempts: j['attempts'] ?? 0,
error: j['error'] ?? false,
completing: j['completing'] ?? false, // Default to false for legacy entries

View File

@@ -1,56 +0,0 @@
import 'package:latlong2/latlong.dart';
import '../models/osm_camera_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
class CameraCache {
// Singleton instance
static final CameraCache instance = CameraCache._internal();
factory CameraCache() => instance;
CameraCache._internal();
final Map<int, OsmCameraNode> _nodes = {};
/// Add or update a batch of camera nodes in the cache.
void addOrUpdate(List<OsmCameraNode> nodes) {
for (var node in nodes) {
final existing = _nodes[node.id];
if (existing != null) {
// Preserve any tags starting with underscore when updating existing nodes
final mergedTags = Map<String, String>.from(node.tags);
for (final entry in existing.tags.entries) {
if (entry.key.startsWith('_')) {
mergedTags[entry.key] = entry.value;
}
}
_nodes[node.id] = OsmCameraNode(
id: node.id,
coord: node.coord,
tags: mergedTags,
);
} else {
_nodes[node.id] = node;
}
}
}
/// Query for all cached cameras currently within the given LatLngBounds.
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
return _nodes.values
.where((node) => _inBounds(node.coord, bounds))
.toList();
}
/// Retrieve all cached cameras.
List<OsmCameraNode> getAll() => _nodes.values.toList();
/// Optionally clear the cache (rarely needed)
void clear() => _nodes.clear();
/// Utility: point-in-bounds for coordinates
bool _inBounds(LatLng coord, LatLngBounds bounds) {
return coord.latitude >= bounds.southWest.latitude &&
coord.latitude <= bounds.northEast.latitude &&
coord.longitude >= bounds.southWest.longitude &&
coord.longitude <= bounds.northEast.longitude;
}
}

View File

@@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart';
import '../../models/node_profile.dart';
import '../../models/osm_camera_node.dart';
import '../../models/pending_upload.dart';
import '../../app_state.dart';
import '../network_status.dart';
@@ -47,7 +48,7 @@ Future<List<OsmCameraNode>> fetchOverpassNodes({
NetworkStatus.instance.reportOverpassSuccess();
return elements.whereType<Map<String, dynamic>>().map((element) {
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
return OsmCameraNode(
id: element['id'],
coord: LatLng(element['lat'], element['lon']),
@@ -55,6 +56,11 @@ Future<List<OsmCameraNode>> fetchOverpassNodes({
);
}).toList();
// Clean up any pending uploads that now appear in Overpass results
_cleanupCompletedUploads(nodes);
return nodes;
} catch (e) {
debugPrint('[fetchOverpassNodes] Exception: $e');
@@ -92,4 +98,40 @@ String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int
);
$outputClause
''';
}
/// Clean up pending uploads that now appear in Overpass results
void _cleanupCompletedUploads(List<OsmCameraNode> overpassNodes) {
try {
final appState = AppState.instance;
final pendingUploads = appState.pendingUploads;
if (pendingUploads.isEmpty) return;
final overpassNodeIds = overpassNodes.map((n) => n.id).toSet();
// Find pending uploads whose submitted node IDs now appear in Overpass results
final uploadsToRemove = <PendingUpload>[];
for (final upload in pendingUploads) {
if (upload.submittedNodeId != null &&
overpassNodeIds.contains(upload.submittedNodeId!)) {
uploadsToRemove.add(upload);
debugPrint('[OverpassCleanup] Found submitted node ${upload.submittedNodeId} in Overpass results, removing from pending queue');
}
}
// Remove the completed uploads from the queue
for (final upload in uploadsToRemove) {
appState.removeFromQueue(upload);
}
if (uploadsToRemove.isNotEmpty) {
debugPrint('[OverpassCleanup] Cleaned up ${uploadsToRemove.length} completed uploads');
}
} catch (e) {
debugPrint('[OverpassCleanup] Error during cleanup: $e');
// Don't let cleanup errors break the main functionality
}
}

View File

@@ -0,0 +1,103 @@
import 'package:latlong2/latlong.dart';
import '../models/osm_camera_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
class NodeCache {
// Singleton instance
static final NodeCache instance = NodeCache._internal();
factory NodeCache() => instance;
NodeCache._internal();
final Map<int, OsmCameraNode> _nodes = {};
/// Add or update a batch of nodes in the cache.
void addOrUpdate(List<OsmCameraNode> nodes) {
for (var node in nodes) {
final existing = _nodes[node.id];
if (existing != null) {
// Preserve any tags starting with underscore when updating existing nodes
final mergedTags = Map<String, String>.from(node.tags);
for (final entry in existing.tags.entries) {
if (entry.key.startsWith('_')) {
mergedTags[entry.key] = entry.value;
}
}
_nodes[node.id] = OsmCameraNode(
id: node.id,
coord: node.coord,
tags: mergedTags,
);
} else {
_nodes[node.id] = node;
}
}
}
/// Query for all cached nodes currently within the given LatLngBounds.
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
return _nodes.values
.where((node) => _inBounds(node.coord, bounds))
.toList();
}
/// Retrieve all cached nodes.
List<OsmCameraNode> getAll() => _nodes.values.toList();
/// Optionally clear the cache (rarely needed)
void clear() => _nodes.clear();
/// Remove the _pending_edit marker from a specific node
void removePendingEditMarker(int nodeId) {
final node = _nodes[nodeId];
if (node != null && node.tags.containsKey('_pending_edit')) {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_edit');
_nodes[nodeId] = OsmCameraNode(
id: node.id,
coord: node.coord,
tags: cleanTags,
);
}
}
/// Remove temporary nodes (negative IDs) with _pending_upload marker at the given coordinate
/// This is used when a real node ID is assigned to clean up temp placeholders
void removeTempNodesByCoordinate(LatLng coord, {double tolerance = 0.00001}) {
final nodesToRemove = <int>[];
for (final entry in _nodes.entries) {
final nodeId = entry.key;
final node = entry.value;
// Only consider temp nodes (negative IDs) with pending upload marker
if (nodeId < 0 &&
node.tags.containsKey('_pending_upload') &&
_coordsMatch(node.coord, coord, tolerance)) {
nodesToRemove.add(nodeId);
}
}
for (final nodeId in nodesToRemove) {
_nodes.remove(nodeId);
}
if (nodesToRemove.isNotEmpty) {
print('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
}
}
/// Check if two coordinates match within tolerance
bool _coordsMatch(LatLng coord1, LatLng coord2, double tolerance) {
return (coord1.latitude - coord2.latitude).abs() < tolerance &&
(coord1.longitude - coord2.longitude).abs() < tolerance;
}
/// Utility: point-in-bounds for coordinates
bool _inBounds(LatLng coord, LatLngBounds bounds) {
return coord.latitude >= bounds.southWest.latitude &&
coord.latitude <= bounds.northEast.latitude &&
coord.longitude >= bounds.southWest.longitude &&
coord.longitude <= bounds.northEast.longitude;
}
}

View File

@@ -9,7 +9,7 @@ class Uploader {
Uploader(this.accessToken, this.onSuccess, {this.uploadMode = UploadMode.production});
final String accessToken;
final void Function() onSuccess;
final void Function(int nodeId) onSuccess;
final UploadMode uploadMode;
Future<bool> upload(PendingUpload p) async {
@@ -99,7 +99,8 @@ class Uploader {
print('Uploader: Close response: ${closeResp.statusCode}');
print('Uploader: Upload successful!');
onSuccess();
final nodeIdInt = int.parse(nodeId);
onSuccess(nodeIdInt);
return true;
} catch (e) {
print('Uploader: Upload failed with error: $e');

View File

@@ -5,7 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/pending_upload.dart';
import '../models/osm_camera_node.dart';
import '../services/camera_cache.dart';
import '../services/node_cache.dart';
import '../services/uploader.dart';
import '../widgets/camera_provider_with_cache.dart';
import 'settings_state.dart';
@@ -37,7 +37,7 @@ class UploadQueueState extends ChangeNotifier {
_queue.add(upload);
_saveQueue();
// Add to camera cache immediately so it shows on the map
// Add to node cache immediately so it shows on the map
// Create a temporary node with a negative ID (to distinguish from real OSM nodes)
// Using timestamp as negative ID to ensure uniqueness
final tempId = -DateTime.now().millisecondsSinceEpoch;
@@ -50,8 +50,8 @@ class UploadQueueState extends ChangeNotifier {
tags: tags,
);
CameraCache.instance.addOrUpdate([tempNode]);
// Notify camera provider to update the map
NodeCache.instance.addOrUpdate([tempNode]);
// Notify node provider to update the map
CameraProviderWithCache.instance.notifyListeners();
notifyListeners();
@@ -73,7 +73,7 @@ class UploadQueueState extends ChangeNotifier {
// Create two cache entries:
// 1. Mark the original camera with _pending_edit (grey ring) at original location
// 1. Mark the original node with _pending_edit (grey ring) at original location
final originalTags = Map<String, String>.from(session.originalNode.tags);
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
@@ -83,7 +83,7 @@ class UploadQueueState extends ChangeNotifier {
tags: originalTags,
);
// 2. Create new temp node for the edited camera (purple ring) at new location
// 2. Create new temp node for the edited node (purple ring) at new location
final tempId = -DateTime.now().millisecondsSinceEpoch;
final editedTags = upload.getCombinedTags();
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
@@ -95,8 +95,8 @@ class UploadQueueState extends ChangeNotifier {
tags: editedTags,
);
CameraCache.instance.addOrUpdate([originalNode, editedNode]);
// Notify camera provider to update the map
NodeCache.instance.addOrUpdate([originalNode, editedNode]);
// Notify node provider to update the map
CameraProviderWithCache.instance.notifyListeners();
notifyListeners();
@@ -153,19 +153,16 @@ class UploadQueueState extends ChangeNotifier {
debugPrint('[UploadQueue] Simulating upload (no real API call)');
await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
ok = true;
// Simulate a node ID for simulate mode
_markAsCompleting(item, simulatedNodeId: DateTime.now().millisecondsSinceEpoch);
} else {
// Real upload -- use the upload mode that was saved when this item was queued
debugPrint('[UploadQueue] Real upload to: ${item.uploadMode}');
final up = Uploader(access, () {
_markAsCompleting(item);
final up = Uploader(access, (nodeId) {
_markAsCompleting(item, submittedNodeId: nodeId);
}, uploadMode: item.uploadMode);
ok = await up.upload(item);
}
if (ok && item.uploadMode == UploadMode.simulate) {
// Mark as completing for simulate mode too
_markAsCompleting(item);
}
if (!ok) {
item.attempts++;
if (item.attempts >= 3) {
@@ -186,8 +183,22 @@ class UploadQueueState extends ChangeNotifier {
}
// Mark an item as completing (shows checkmark) and schedule removal after 1 second
void _markAsCompleting(PendingUpload item) {
void _markAsCompleting(PendingUpload item, {int? submittedNodeId, int? simulatedNodeId}) {
item.completing = true;
// Store the submitted node ID for cleanup purposes
if (submittedNodeId != null) {
item.submittedNodeId = submittedNodeId;
debugPrint('[UploadQueue] Upload successful, OSM assigned node ID: $submittedNodeId');
// Update cache with real node ID instead of temp ID
_updateCacheWithRealNodeId(item, submittedNodeId);
} else if (simulatedNodeId != null && item.uploadMode == UploadMode.simulate) {
// For simulate mode, use a fake but positive ID
item.submittedNodeId = simulatedNodeId;
debugPrint('[UploadQueue] Simulated upload, fake node ID: $simulatedNodeId');
}
_saveQueue();
notifyListeners();
@@ -198,6 +209,34 @@ class UploadQueueState extends ChangeNotifier {
notifyListeners();
});
}
// Update the cache to use the real OSM node ID instead of temporary ID
void _updateCacheWithRealNodeId(PendingUpload item, int realNodeId) {
// Create the node with real ID and clean tags (remove temp markers)
final tags = item.getCombinedTags();
final realNode = OsmCameraNode(
id: realNodeId,
coord: item.coord,
tags: tags, // Clean tags without _pending_upload markers
);
// Add/update the cache with the real node
NodeCache.instance.addOrUpdate([realNode]);
// Clean up any temp nodes at the same coordinate
NodeCache.instance.removeTempNodesByCoordinate(item.coord);
// For edits, also clean up the original node's _pending_edit marker
if (item.isEdit && item.originalNodeId != null) {
// Remove the _pending_edit marker from the original node in cache
// The next Overpass fetch will provide the authoritative data anyway
NodeCache.instance.removePendingEditMarker(item.originalNodeId!);
}
// Notify node provider to update the map
CameraProviderWithCache.instance.notifyListeners();
}
// ---------- Queue persistence ----------
Future<void> _saveQueue() async {

View File

@@ -4,13 +4,13 @@ import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../services/map_data_provider.dart';
import '../services/camera_cache.dart';
import '../services/node_cache.dart';
import '../services/network_status.dart';
import '../models/node_profile.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
/// Provides cameras for a map view, using an in-memory cache and optionally
/// Provides surveillance nodes for a map view, using an in-memory cache and optionally
/// merging in new results from Overpass via MapDataProvider when not offline.
class CameraProviderWithCache extends ChangeNotifier {
static final CameraProviderWithCache instance = CameraProviderWithCache._internal();
@@ -21,16 +21,16 @@ class CameraProviderWithCache extends ChangeNotifier {
/// Call this to get (quickly) all cached overlays for the given view.
/// Filters by currently enabled profiles.
List<OsmCameraNode> getCachedCamerasForBounds(LatLngBounds bounds) {
final allCameras = CameraCache.instance.queryByBounds(bounds);
List<OsmCameraNode> getCachedNodesForBounds(LatLngBounds bounds) {
final allNodes = NodeCache.instance.queryByBounds(bounds);
final enabledProfiles = AppState.instance.enabledProfiles;
// If no profiles are enabled, show no cameras
// If no profiles are enabled, show no nodes
if (enabledProfiles.isEmpty) return [];
// Filter cameras to only show those matching enabled profiles
return allCameras.where((camera) {
return _matchesAnyProfile(camera, enabledProfiles);
// Filter nodes to only show those matching enabled profiles
return allNodes.where((node) {
return _matchesAnyProfile(node, enabledProfiles);
}).toList();
}
@@ -55,13 +55,13 @@ class CameraProviderWithCache extends ChangeNotifier {
source: MapSource.auto,
);
if (fresh.isNotEmpty) {
CameraCache.instance.addOrUpdate(fresh);
// Clear waiting status when camera data arrives
NodeCache.instance.addOrUpdate(fresh);
// Clear waiting status when node data arrives
NetworkStatus.instance.clearWaiting();
notifyListeners();
}
} catch (e) {
debugPrint('[CameraProviderWithCache] Camera fetch failed: $e');
debugPrint('[CameraProviderWithCache] Node fetch failed: $e');
// Cache already holds whatever is available for the view
}
});
@@ -69,7 +69,7 @@ class CameraProviderWithCache extends ChangeNotifier {
/// Optionally: clear the cache (could be used for testing/dev)
void clearCache() {
CameraCache.instance.clear();
NodeCache.instance.clear();
notifyListeners();
}
@@ -78,18 +78,18 @@ class CameraProviderWithCache extends ChangeNotifier {
notifyListeners();
}
/// Check if a camera matches any of the provided profiles
bool _matchesAnyProfile(OsmCameraNode camera, List<NodeProfile> profiles) {
/// Check if a node matches any of the provided profiles
bool _matchesAnyProfile(OsmCameraNode node, List<NodeProfile> profiles) {
for (final profile in profiles) {
if (_cameraMatchesProfile(camera, profile)) return true;
if (_nodeMatchesProfile(node, profile)) return true;
}
return false;
}
/// Check if a camera matches a specific profile (all profile tags must match)
bool _cameraMatchesProfile(OsmCameraNode camera, NodeProfile profile) {
/// Check if a node matches a specific profile (all profile tags must match)
bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
for (final entry in profile.tags.entries) {
if (camera.tags[entry.key] != entry.value) return false;
if (node.tags[entry.key] != entry.value) return false;
}
return true;
}

View File

@@ -228,7 +228,7 @@ class MapViewState extends State<MapView> {
mapBounds = null;
}
final cameras = (mapBounds != null)
? cameraProvider.getCachedCamerasForBounds(mapBounds)
? cameraProvider.getCachedNodesForBounds(mapBounds)
: <OsmCameraNode>[];
final markers = CameraMarkersBuilder.buildCameraMarkers(