diff --git a/lib/app_state.dart b/lib/app_state.dart index 0e200b5..29f863a 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -41,6 +41,7 @@ import 'state/upload_queue_state.dart'; export 'state/navigation_state.dart' show AppNavigationMode; export 'state/settings_state.dart' show UploadMode, FollowMeMode; export 'state/session_state.dart' show AddNodeSession, EditNodeSession; +export 'models/pending_upload.dart' show UploadOperation; // ------------------ AppState ------------------ class AppState extends ChangeNotifier { @@ -444,6 +445,7 @@ class AppState extends ChangeNotifier { OperatorProfile? operatorProfile, LatLng? target, Map? refinedTags, + String? changesetComment, }) { _sessionState.updateSession( directionDeg: directionDeg, @@ -451,6 +453,7 @@ class AppState extends ChangeNotifier { operatorProfile: operatorProfile, target: target, refinedTags: refinedTags, + changesetComment: changesetComment, ); // Check tutorial completion if position changed @@ -466,6 +469,7 @@ class AppState extends ChangeNotifier { LatLng? target, bool? extractFromWay, Map? refinedTags, + String? changesetComment, }) { _sessionState.updateEditSession( directionDeg: directionDeg, @@ -474,6 +478,7 @@ class AppState extends ChangeNotifier { target: target, extractFromWay: extractFromWay, refinedTags: refinedTags, + changesetComment: changesetComment, ); // Check tutorial completion if position changed @@ -797,6 +802,31 @@ class AppState extends ChangeNotifier { ); } + // ---------- Utility Methods ---------- + + /// Generate a default changeset comment for a submission + /// Handles special case of profile by using "a" instead + static String generateDefaultChangesetComment({ + required NodeProfile? profile, + required UploadOperation operation, + }) { + // Handle temp profiles with brackets by using "a" + final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true + ? 'a' + : profile?.name ?? 'surveillance'; + + switch (operation) { + case UploadOperation.create: + return 'Add $profileName surveillance node'; + case UploadOperation.modify: + return 'Update $profileName surveillance node'; + case UploadOperation.delete: + return 'Delete $profileName surveillance node'; + case UploadOperation.extract: + return 'Extract $profileName surveillance node'; + } + } + // ---------- Private Methods ---------- /// Attempts to fetch missing tile preview images in the background (fire and forget) void _fetchMissingTilePreviews() { diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index d2f53e0..38fe4bf 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -22,6 +22,7 @@ class PendingUpload { final NodeProfile? profile; final OperatorProfile? operatorProfile; final Map refinedTags; // User-selected values for empty profile tags + final String changesetComment; // User-editable changeset comment final UploadMode uploadMode; // Capture upload destination when queued final UploadOperation operation; // Type of operation: create, modify, or delete final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node @@ -45,6 +46,7 @@ class PendingUpload { this.profile, this.operatorProfile, Map? refinedTags, + required this.changesetComment, required this.uploadMode, required this.operation, this.originalNodeId, @@ -269,6 +271,7 @@ class PendingUpload { 'profile': profile?.toJson(), 'operatorProfile': operatorProfile?.toJson(), 'refinedTags': refinedTags, + 'changesetComment': changesetComment, 'uploadMode': uploadMode.index, 'operation': operation.index, 'originalNodeId': originalNodeId, @@ -299,6 +302,7 @@ class PendingUpload { refinedTags: j['refinedTags'] != null ? Map.from(j['refinedTags']) : {}, // Default empty map for legacy entries + changesetComment: j['changesetComment'] ?? _generateLegacyComment(j), // Default for legacy entries uploadMode: j['uploadMode'] != null ? UploadMode.values[j['uploadMode']] : UploadMode.production, // Default for legacy entries @@ -338,5 +342,25 @@ class PendingUpload { if (error) return UploadState.error; return UploadState.pending; } + + /// Generate a default changeset comment for legacy uploads that don't have one + static String _generateLegacyComment(Map j) { + final operation = j['operation'] != null + ? UploadOperation.values[j['operation']] + : (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create); + + final profileName = j['profile']?['name'] ?? 'surveillance'; + + switch (operation) { + case UploadOperation.create: + return 'Add $profileName surveillance node'; + case UploadOperation.modify: + return 'Update $profileName surveillance node'; + case UploadOperation.delete: + return 'Delete $profileName surveillance node'; + case UploadOperation.extract: + return 'Extract $profileName surveillance node'; + } + } } diff --git a/lib/services/uploader.dart b/lib/services/uploader.dart index e0d9612..88926fc 100644 --- a/lib/services/uploader.dart +++ b/lib/services/uploader.dart @@ -71,12 +71,13 @@ class Uploader { break; } - final profileName = p.profile?.name ?? 'surveillance'; + // Use the user's changeset comment, with XML sanitization + final sanitizedComment = _sanitizeXmlText(p.changesetComment); final csXml = ''' - + '''; @@ -371,6 +372,21 @@ class Uploader { 'Authorization': 'Bearer $accessToken', 'Content-Type': 'text/xml', }; + + /// Sanitize text for safe inclusion in XML attributes and content + /// Removes or escapes characters that could break XML parsing + String _sanitizeXmlText(String input) { + return input + .replaceAll('&', '&') // Must be first to avoid double-escaping + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('\n', ' ') // Replace newlines with spaces + .replaceAll('\r', ' ') // Replace carriage returns with spaces + .replaceAll('\t', ' ') // Replace tabs with spaces + .trim(); // Remove leading/trailing whitespace + } } extension StringExtension on String { diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index 3481c07..5781ca3 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -4,6 +4,7 @@ import 'package:latlong2/latlong.dart'; import '../models/node_profile.dart'; import '../models/operator_profile.dart'; import '../models/osm_node.dart'; +import '../models/pending_upload.dart'; // For UploadOperation enum // ------------------ AddNodeSession ------------------ class AddNodeSession { @@ -13,6 +14,7 @@ class AddNodeSession { List directions; // All directions [90, 180, 270] int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°) Map refinedTags; // User-selected values for empty profile tags + String changesetComment; // User-editable changeset comment AddNodeSession({ this.profile, @@ -20,9 +22,11 @@ class AddNodeSession { this.operatorProfile, this.target, Map? refinedTags, + String? changesetComment, }) : directions = [initialDirection], currentDirectionIndex = 0, - refinedTags = refinedTags ?? {}; + refinedTags = refinedTags ?? {}, + changesetComment = changesetComment ?? ''; // Slider always shows the current direction being edited double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0 @@ -46,6 +50,7 @@ class EditNodeSession { int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°) bool extractFromWay; // True if user wants to extract this constrained node Map refinedTags; // User-selected values for empty profile tags + String changesetComment; // User-editable changeset comment EditNodeSession({ required this.originalNode, @@ -56,9 +61,11 @@ class EditNodeSession { required this.target, this.extractFromWay = false, Map? refinedTags, + String? changesetComment, }) : directions = [initialDirection], currentDirectionIndex = 0, - refinedTags = refinedTags ?? {}; + refinedTags = refinedTags ?? {}, + changesetComment = changesetComment ?? ''; // Slider always shows the current direction being edited double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0 @@ -82,7 +89,9 @@ class SessionState extends ChangeNotifier { void startAddSession(List enabledProfiles) { // Start with no profile selected - force user to choose - _session = AddNodeSession(); + _session = AddNodeSession( + changesetComment: 'Add surveillance node', // Default comment, will be updated when profile is selected + ); _editSession = null; // Clear any edit session notifyListeners(); } @@ -106,6 +115,7 @@ class SessionState extends ChangeNotifier { operatorProfile: _detectedOperatorProfile, initialDirection: initialDirection, target: node.coord, + changesetComment: 'Update a surveillance node', // Default comment for existing tags profile ); // Replace the default single direction with all existing directions (or empty list) @@ -131,6 +141,7 @@ class SessionState extends ChangeNotifier { OperatorProfile? operatorProfile, LatLng? target, Map? refinedTags, + String? changesetComment, }) { if (_session == null) return; @@ -141,6 +152,11 @@ class SessionState extends ChangeNotifier { } if (profile != null && profile != _session!.profile) { _session!.profile = profile; + // Regenerate changeset comment when profile changes + _session!.changesetComment = _generateDefaultChangesetComment( + profile: profile, + operation: UploadOperation.create, + ); dirty = true; } if (operatorProfile != _session!.operatorProfile) { @@ -155,6 +171,10 @@ class SessionState extends ChangeNotifier { _session!.refinedTags = Map.from(refinedTags); dirty = true; } + if (changesetComment != null) { + _session!.changesetComment = changesetComment; + dirty = true; + } if (dirty) notifyListeners(); } @@ -165,6 +185,7 @@ class SessionState extends ChangeNotifier { LatLng? target, bool? extractFromWay, Map? refinedTags, + String? changesetComment, }) { if (_editSession == null) return; @@ -189,6 +210,13 @@ class SessionState extends ChangeNotifier { _editSession!.operatorProfile = _detectedOperatorProfile; } + // Regenerate changeset comment when profile changes + final operation = _editSession!.extractFromWay ? UploadOperation.extract : UploadOperation.modify; + _editSession!.changesetComment = _generateDefaultChangesetComment( + profile: profile, + operation: operation, + ); + dirty = true; } // Only update operator profile if explicitly provided or different from current @@ -214,6 +242,10 @@ class SessionState extends ChangeNotifier { _editSession!.refinedTags = Map.from(refinedTags); dirty = true; } + if (changesetComment != null) { + _editSession!.changesetComment = changesetComment; + dirty = true; + } if (dirty) notifyListeners(); @@ -341,4 +373,27 @@ class SessionState extends ChangeNotifier { _editSession!.currentDirectionIndex = 0; } } + + /// Generate a default changeset comment for a submission + /// Handles special case of profile by using "a" instead + String _generateDefaultChangesetComment({ + required NodeProfile? profile, + required UploadOperation operation, + }) { + // Handle temp profiles with brackets by using "a" + final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true + ? 'a' + : profile?.name ?? 'surveillance'; + + switch (operation) { + case UploadOperation.create: + return 'Add $profileName surveillance node'; + case UploadOperation.modify: + return 'Update $profileName surveillance node'; + case UploadOperation.delete: + return 'Delete $profileName surveillance node'; + case UploadOperation.extract: + return 'Extract $profileName surveillance node'; + } + } } \ No newline at end of file diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 05caa11..04cf377 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -130,6 +130,7 @@ class UploadQueueState extends ChangeNotifier { profile: session.profile!, // Safe to use ! because commitSession() checks for null operatorProfile: session.operatorProfile, refinedTags: session.refinedTags, + changesetComment: session.changesetComment, uploadMode: uploadMode, operation: UploadOperation.create, ); @@ -187,6 +188,7 @@ class UploadQueueState extends ChangeNotifier { profile: session.profile!, // Safe to use ! because commitEditSession() checks for null operatorProfile: session.operatorProfile, refinedTags: session.refinedTags, + changesetComment: session.changesetComment, uploadMode: uploadMode, operation: operation, originalNodeId: session.originalNode.id, // Track which node we're editing @@ -256,6 +258,7 @@ class UploadQueueState extends ChangeNotifier { coord: node.coord, direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API profile: null, // No profile needed for deletions - just delete by node ID + changesetComment: 'Delete a surveillance node', // Default comment for deletions uploadMode: uploadMode, operation: UploadOperation.delete, originalNodeId: node.id, diff --git a/lib/widgets/add_node_sheet.dart b/lib/widgets/add_node_sheet.dart index 1e0443b..0a7ec2b 100644 --- a/lib/widgets/add_node_sheet.dart +++ b/lib/widgets/add_node_sheet.dart @@ -454,6 +454,7 @@ class _AddNodeSheetState extends State { selectedOperatorProfile: session.operatorProfile, selectedProfile: session.profile, currentRefinedTags: session.refinedTags, + operation: UploadOperation.create, ), fullscreenDialog: true, ), @@ -462,6 +463,7 @@ class _AddNodeSheetState extends State { appState.updateSession( operatorProfile: result.operatorProfile, refinedTags: result.refinedTags, + changesetComment: result.changesetComment, ); } } diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index c5f294f..0e57978 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -238,6 +238,7 @@ class _EditNodeSheetState extends State { profile: session.profile, operatorProfile: session.operatorProfile, refinedTags: session.refinedTags, + changesetComment: session.changesetComment, // Required parameter uploadMode: UploadMode.production, // Mode doesn't matter for tag combination operation: UploadOperation.modify, ); @@ -491,6 +492,7 @@ class _EditNodeSheetState extends State { selectedProfile: session.profile, currentRefinedTags: session.refinedTags, originalNodeTags: session.originalNode.tags, + operation: session.extractFromWay ? UploadOperation.extract : UploadOperation.modify, ), fullscreenDialog: true, ), @@ -505,11 +507,13 @@ class _EditNodeSheetState extends State { profile: updatedProfile, operatorProfile: result.operatorProfile, refinedTags: result.refinedTags, + changesetComment: result.changesetComment, ); } else { appState.updateEditSession( operatorProfile: result.operatorProfile, refinedTags: result.refinedTags, + changesetComment: result.changesetComment, ); } } diff --git a/lib/widgets/refine_tags_sheet.dart b/lib/widgets/refine_tags_sheet.dart index 13f97a0..48457c3 100644 --- a/lib/widgets/refine_tags_sheet.dart +++ b/lib/widgets/refine_tags_sheet.dart @@ -12,11 +12,13 @@ class RefineTagsResult { final OperatorProfile? operatorProfile; final Map refinedTags; final Map? editedTags; // For existing tags profile mode + final String changesetComment; // User-editable changeset comment RefineTagsResult({ required this.operatorProfile, required this.refinedTags, this.editedTags, + required this.changesetComment, }); } @@ -27,12 +29,14 @@ class RefineTagsSheet extends StatefulWidget { this.selectedProfile, this.currentRefinedTags, this.originalNodeTags, + required this.operation, }); final OperatorProfile? selectedOperatorProfile; final NodeProfile? selectedProfile; final Map? currentRefinedTags; final Map? originalNodeTags; + final UploadOperation operation; @override State createState() => _RefineTagsSheetState(); @@ -44,6 +48,9 @@ class _RefineTagsSheetState extends State { // For existing tags profile: full tag editing late List> _editableTags; + + // Changeset comment editing + late final TextEditingController _commentController; @override void initState() { @@ -60,6 +67,19 @@ class _RefineTagsSheetState extends State { } else { _editableTags = []; } + + // Initialize changeset comment with default + final defaultComment = AppState.generateDefaultChangesetComment( + profile: widget.selectedProfile, + operation: widget.operation, + ); + _commentController = TextEditingController(text: defaultComment); + } + + @override + void dispose() { + _commentController.dispose(); + super.dispose(); } /// Pre-populate refined tags with existing values from the original node @@ -113,6 +133,7 @@ class _RefineTagsSheetState extends State { operatorProfile: widget.selectedOperatorProfile, refinedTags: widget.currentRefinedTags ?? {}, editedTags: _isExistingTagsMode ? widget.selectedProfile?.tags : null, + changesetComment: _commentController.text, )), ), actions: [ @@ -126,6 +147,7 @@ class _RefineTagsSheetState extends State { operatorProfile: _selectedOperatorProfile, refinedTags: _refinedTags, editedTags: editedTags, + changesetComment: _commentController.text, )); }, child: Text(locService.t('refineTagsSheet.done')), @@ -133,7 +155,12 @@ class _RefineTagsSheetState extends State { ], ), body: ListView( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.fromLTRB( + 16, + 16, + 16, + 16 + MediaQuery.of(context).padding.bottom, + ), children: [ Text( locService.t('refineTagsSheet.operatorProfile'), @@ -253,6 +280,25 @@ class _RefineTagsSheetState extends State { ...(_isExistingTagsMode ? _buildExistingTagsEditingSection(locService) : _buildRefinableTagsSection(locService)), + + // Changeset comment section + const SizedBox(height: 16), + Text( + 'Change Comment', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + TextField( + controller: _commentController, + decoration: const InputDecoration( + hintText: 'Describe your changes...', + border: OutlineInputBorder(), + isDense: true, + ), + maxLines: 2, + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 16), ], ), );