diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 4b5132c..d9e0f86 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -26,7 +26,7 @@ const double kAddPinYOffset = 0.0; // Client name and version for OSM uploads ("created_by" tag) const String kClientName = 'FlockMap'; -const String kClientVersion = '0.9.4'; +const String kClientVersion = '0.9.5'; // Marker/camera interaction const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras or warning diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index 0adf94d..feda5d6 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -10,6 +10,7 @@ class PendingUpload { final int? originalNodeId; // If this is an edit, the ID of the original OSM node int attempts; bool error; + bool completing; // True when upload succeeded but item is showing checkmark briefly PendingUpload({ required this.coord, @@ -19,6 +20,7 @@ class PendingUpload { this.originalNodeId, 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 @@ -45,6 +47,7 @@ class PendingUpload { 'originalNodeId': originalNodeId, 'attempts': attempts, 'error': error, + 'completing': completing, }; factory PendingUpload.fromJson(Map j) => PendingUpload( @@ -59,6 +62,7 @@ class PendingUpload { originalNodeId: j['originalNodeId'], attempts: j['attempts'] ?? 0, error: j['error'] ?? false, + completing: j['completing'] ?? false, // Default to false for legacy entries ); } diff --git a/lib/screens/settings_screen_sections/queue_section.dart b/lib/screens/settings_screen_sections/queue_section.dart index 3781c86..c0ea906 100644 --- a/lib/screens/settings_screen_sections/queue_section.dart +++ b/lib/screens/settings_screen_sections/queue_section.dart @@ -81,78 +81,86 @@ class QueueSection extends StatelessWidget { } void _showQueueDialog(BuildContext context) { - final appState = context.read(); showDialog( context: context, - builder: (context) => AlertDialog( - title: Text('Upload Queue (${appState.pendingCount} items)'), - content: SizedBox( - width: double.maxFinite, - height: 300, - child: ListView.builder( - itemCount: appState.pendingUploads.length, - itemBuilder: (context, index) { - final upload = appState.pendingUploads[index]; - return ListTile( - leading: Icon( - upload.error ? Icons.error : Icons.camera_alt, - color: upload.error - ? Colors.red - : _getUploadModeColor(upload.uploadMode), - ), - title: Text('Camera ${index + 1}${upload.error ? " (Error)" : ""}'), - subtitle: Text( - 'Dest: ${_getUploadModeDisplayName(upload.uploadMode)}\n' - 'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n' - 'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n' - 'Direction: ${upload.direction.round()}°\n' - 'Attempts: ${upload.attempts}' + - (upload.error ? "\nUpload failed. Tap retry to try again." : "") - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (upload.error) - IconButton( - icon: const Icon(Icons.refresh), - color: Colors.orange, - tooltip: 'Retry upload', - onPressed: () { - appState.retryUpload(upload); - }, - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - appState.removeFromQueue(upload); - if (appState.pendingCount == 0) { - Navigator.pop(context); - } - }, - ), - ], - ), - ); - }, + builder: (context) => Consumer( + builder: (context, appState, child) => AlertDialog( + title: Text('Upload Queue (${appState.pendingCount} items)'), + content: SizedBox( + width: double.maxFinite, + height: 300, + child: appState.pendingUploads.isEmpty + ? const Center(child: Text('Queue is empty')) + : ListView.builder( + itemCount: appState.pendingUploads.length, + itemBuilder: (context, index) { + final upload = appState.pendingUploads[index]; + return ListTile( + leading: Icon( + upload.error ? Icons.error : Icons.camera_alt, + color: upload.error + ? Colors.red + : _getUploadModeColor(upload.uploadMode), + ), + title: Text('Camera ${index + 1}' + '${upload.error ? " (Error)" : ""}' + '${upload.completing ? " (Completing...)" : ""}'), + subtitle: Text( + 'Dest: ${_getUploadModeDisplayName(upload.uploadMode)}\n' + 'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n' + 'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n' + 'Direction: ${upload.direction.round()}°\n' + 'Attempts: ${upload.attempts}' + + (upload.error ? "\nUpload failed. Tap retry to try again." : "") + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (upload.error && !upload.completing) + IconButton( + icon: const Icon(Icons.refresh), + color: Colors.orange, + tooltip: 'Retry upload', + onPressed: () { + appState.retryUpload(upload); + }, + ), + if (upload.completing) + const Icon(Icons.check_circle, color: Colors.green) + else + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + appState.removeFromQueue(upload); + if (appState.pendingCount == 0) { + Navigator.pop(context); + } + }, + ), + ], + ), + ); + }, + ), ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - if (appState.pendingCount > 1) + actions: [ TextButton( - onPressed: () { - appState.clearQueue(); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Queue cleared')), - ); - }, - child: const Text('Clear All'), + onPressed: () => Navigator.pop(context), + child: const Text('Close'), ), - ], + if (appState.pendingCount > 1) + TextButton( + onPressed: () { + appState.clearQueue(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Queue cleared')), + ); + }, + child: const Text('Clear All'), + ), + ], + ), ), ); } diff --git a/lib/services/uploader.dart b/lib/services/uploader.dart index b6e2dc7..547fb14 100644 --- a/lib/services/uploader.dart +++ b/lib/services/uploader.dart @@ -45,10 +45,29 @@ class Uploader { final String nodeId; if (p.isEdit) { - // Update existing node + // First, fetch the current node to get its version + print('Uploader: Fetching current node ${p.originalNodeId} to get version...'); + final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}'); + print('Uploader: Current node response: ${currentNodeResp.statusCode}'); + if (currentNodeResp.statusCode != 200) { + print('Uploader: Failed to fetch current node'); + return false; + } + + // Parse version from the response XML + final currentNodeXml = currentNodeResp.body; + final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml); + if (versionMatch == null) { + print('Uploader: Could not parse version from current node XML'); + return false; + } + final currentVersion = versionMatch.group(1)!; + print('Uploader: Current node version: $currentVersion'); + + // Update existing node with version final nodeXml = ''' - + $tagsXml '''; @@ -99,6 +118,11 @@ class Uploader { } } + Future _get(String path) => http.get( + Uri.https(_host, path), + headers: _headers, + ); + Future _post(String path, String body) => http.post( Uri.https(_host, path), headers: _headers, diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 2c3961c..107a4f5 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -157,18 +157,14 @@ class UploadQueueState extends ChangeNotifier { // 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, () { - _queue.remove(item); - _saveQueue(); - notifyListeners(); + _markAsCompleting(item); }, uploadMode: item.uploadMode); ok = await up.upload(item); } if (ok && item.uploadMode == UploadMode.simulate) { - // Remove manually for simulate mode - _queue.remove(item); - _saveQueue(); - notifyListeners(); + // Mark as completing for simulate mode too + _markAsCompleting(item); } if (!ok) { item.attempts++; @@ -189,6 +185,20 @@ class UploadQueueState extends ChangeNotifier { _uploadTimer?.cancel(); } + // Mark an item as completing (shows checkmark) and schedule removal after 1 second + void _markAsCompleting(PendingUpload item) { + item.completing = true; + _saveQueue(); + notifyListeners(); + + // Remove the item after 1 second + Timer(const Duration(seconds: 1), () { + _queue.remove(item); + _saveQueue(); + notifyListeners(); + }); + } + // ---------- Queue persistence ---------- Future _saveQueue() async { final prefs = await SharedPreferences.getInstance();