Compare commits

...

1 Commits

5 changed files with 123 additions and 77 deletions

View File

@@ -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

View File

@@ -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<String, dynamic> 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
);
}

View File

@@ -81,78 +81,86 @@ class QueueSection extends StatelessWidget {
}
void _showQueueDialog(BuildContext context) {
final appState = context.read<AppState>();
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<AppState>(
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'),
),
],
),
),
);
}

View File

@@ -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 = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
@@ -99,6 +118,11 @@ class Uploader {
}
}
Future<http.Response> _get(String path) => http.get(
Uri.https(_host, path),
headers: _headers,
);
Future<http.Response> _post(String path, String body) => http.post(
Uri.https(_host, path),
headers: _headers,

View File

@@ -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<void> _saveQueue() async {
final prefs = await SharedPreferences.getInstance();