import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; import '../app_state.dart'; import '../dev_config.dart'; import '../models/node_profile.dart'; import '../models/operator_profile.dart'; import '../models/pending_upload.dart'; import '../services/localization_service.dart'; import '../services/map_data_provider.dart'; import '../services/node_data_manager.dart'; import '../services/changelog_service.dart'; import '../state/settings_state.dart'; import '../state/session_state.dart'; import 'refine_tags_sheet.dart'; import 'advanced_edit_options_sheet.dart'; import 'proximity_warning_dialog.dart'; import 'submission_guide_dialog.dart'; import 'positioning_tutorial_overlay.dart'; class EditNodeSheet extends StatefulWidget { const EditNodeSheet({super.key, required this.session}); final EditNodeSession session; @override State createState() => _EditNodeSheetState(); } class _EditNodeSheetState extends State { bool _showTutorial = false; bool _isCheckingTutorial = true; @override void initState() { super.initState(); _checkTutorialStatus(); // Listen to node data manager for cache updates NodeDataManager().addListener(_onCacheUpdated); } void _onCacheUpdated() { // Rebuild when cache updates (e.g., when new data loads) if (mounted) setState(() {}); } Future _checkTutorialStatus() async { final hasCompleted = await ChangelogService().hasCompletedPositioningTutorial(); if (mounted) { setState(() { _showTutorial = !hasCompleted; _isCheckingTutorial = false; }); // If tutorial should be shown, register callback with AppState if (_showTutorial) { final appState = context.read(); appState.registerTutorialCallback(_hideTutorial); } } } void _hideTutorial() { if (mounted && _showTutorial) { setState(() { _showTutorial = false; }); } } @override @override void dispose() { // Remove listener NodeDataManager().removeListener(_onCacheUpdated); // Clear tutorial callback when widget is disposed if (_showTutorial) { try { context.read().clearTutorialCallback(); } catch (e) { // Context might be unavailable during disposal, ignore } } super.dispose(); } void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) { _checkSubmissionGuideAndProceed(context, appState, locService); } void _checkSubmissionGuideAndProceed(BuildContext context, AppState appState, LocalizationService locService) async { // Check if user has seen the submission guide final hasSeenGuide = await ChangelogService().hasSeenSubmissionGuide(); if (!hasSeenGuide) { // Show submission guide dialog first final shouldProceed = await showDialog( context: context, barrierDismissible: false, builder: (context) => const SubmissionGuideDialog(), ); // If user canceled the submission guide, don't proceed with submission if (shouldProceed != true) { return; } } // Now proceed with proximity check _checkProximityOnly(context, appState, locService); } void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) { // Check for nearby nodes within the configured distance, excluding the node being edited final nearbyNodes = MapDataProvider().findNodesWithinDistance( widget.session.target, kNodeProximityWarningDistance, excludeNodeId: widget.session.originalNode.id, ); if (nearbyNodes.isNotEmpty) { // Show proximity warning dialog showDialog( context: context, builder: (context) => ProximityWarningDialog( nearbyNodes: nearbyNodes, distance: kNodeProximityWarningDistance, onGoBack: () { Navigator.of(context).pop(); // Close dialog }, onSubmitAnyway: () { Navigator.of(context).pop(); // Close dialog _commitWithoutCheck(context, appState, locService); }, ), ); } else { // No nearby nodes, proceed with commit _commitWithoutCheck(context, appState, locService); } } void _commitWithoutCheck(BuildContext context, AppState appState, LocalizationService locService) { appState.commitEditSession(); Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(locService.t('node.editQueuedForUpload'))), ); } /// Check if the edit session has any actual changes compared to the original node bool _hasActualChanges(EditNodeSession session) { debugPrint('EditNodeSheet: Checking for actual changes...'); // Extract operation is always a change if (session.extractFromWay) { debugPrint('EditNodeSheet: Extract operation detected - changes found'); return true; } // Check location change const double tolerance = 0.0000001; // ~1cm precision if ((session.target.latitude - session.originalNode.coord.latitude).abs() > tolerance || (session.target.longitude - session.originalNode.coord.longitude).abs() > tolerance) { debugPrint('EditNodeSheet: Location change detected - changes found'); return true; } // Check direction changes if (!_directionsEqual(session.directions, session.originalNode.directionDeg)) { debugPrint('EditNodeSheet: Direction change detected - changes found'); return true; } // Check tag changes (including operator profile) final originalTags = session.originalNode.tags; final newTags = _getSessionCombinedTags(session); debugPrint('EditNodeSheet: Original tags: $originalTags'); debugPrint('EditNodeSheet: New combined tags: $newTags'); if (!_tagsEqual(originalTags, newTags)) { debugPrint('EditNodeSheet: Tag changes detected - changes found'); return true; } debugPrint('EditNodeSheet: No changes detected'); return false; } /// Compare two direction lists, handling empty vs [0] cases bool _directionsEqual(List sessionDirs, List originalDirs) { // Sort both lists for comparison final sorted1 = List.from(sessionDirs)..sort(); final sorted2 = List.from(originalDirs)..sort(); // Handle empty list cases if (sorted1.isEmpty && sorted2.isEmpty) return true; if (sorted1.isEmpty || sorted2.isEmpty) { // Special case: if one is empty and the other is [0], consider them different // because the user either added or removed a direction return false; } if (sorted1.length != sorted2.length) return false; for (int i = 0; i < sorted1.length; i++) { if ((sorted1[i] - sorted2[i]).abs() > 0.1) return false; // 0.1° tolerance } return true; } /// Compare two tag maps, ignoring direction tags (handled separately) bool _tagsEqual(Map tags1, Map tags2) { final filtered1 = Map.from(tags1); final filtered2 = Map.from(tags2); // Remove direction tags - they're handled separately filtered1.remove('direction'); filtered1.remove('camera:direction'); filtered2.remove('direction'); filtered2.remove('camera:direction'); return _mapEquals(filtered1, filtered2); } /// Deep equality check for maps bool _mapEquals(Map map1, Map map2) { if (map1.length != map2.length) return false; for (final entry in map1.entries) { if (map2[entry.key] != entry.value) return false; } return true; } /// Get the combined tags that would be submitted for this session Map _getSessionCombinedTags(EditNodeSession session) { if (session.profile == null) return {}; // Create a temporary PendingUpload to use its getCombinedTags logic final tempUpload = PendingUpload( coord: session.target, direction: session.directions.isNotEmpty ? session.directions.first : 0.0, profile: session.profile, operatorProfile: session.operatorProfile, refinedTags: session.refinedTags, additionalExistingTags: session.additionalExistingTags, // Include additional existing tags! changesetComment: session.changesetComment, // Required parameter uploadMode: UploadMode.production, // Mode doesn't matter for tag combination operation: UploadOperation.modify, originalNodeId: session.originalNode.id, // Required for modify operations ); return tempUpload.getCombinedTags(); } /// Show dialog explaining why submission is disabled due to no changes void _showNoChangesDialog(BuildContext context, LocalizationService locService) { showDialog( context: context, builder: (context) => AlertDialog( title: Text(locService.t('editNode.noChangesTitle')), content: Text(locService.t('editNode.noChangesMessage')), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(locService.ok), ), ], ), ); } Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) { final requiresDirection = session.profile != null && session.profile!.requiresDirection; final is360Fov = session.profile?.fov == 360; final hasDirections = session.directions.isNotEmpty; final enableDirectionControls = requiresDirection && !is360Fov && hasDirections; final enableAddButton = requiresDirection && !is360Fov; // Force direction to 0 when FOV is 360 (omnidirectional) if (is360Fov && session.directionDegrees != 0) { WidgetsBinding.instance.addPostFrameCallback((_) { appState.updateEditSession(directionDeg: 0); }); } // Format direction display text with bold for current direction String directionsText = ''; if (requiresDirection && hasDirections) { final directionsWithBold = []; for (int i = 0; i < session.directions.length; i++) { final dirStr = session.directions[i].round().toString(); if (i == session.currentDirectionIndex) { directionsWithBold.add('**$dirStr**'); // Mark for bold formatting } else { directionsWithBold.add(dirStr); } } directionsText = directionsWithBold.join(', '); } return Column( children: [ ListTile( title: requiresDirection ? RichText( text: TextSpan( style: Theme.of(context).textTheme.titleMedium, children: [ const TextSpan(text: 'Directions: '), if (directionsText.isNotEmpty) ...directionsText.split('**').asMap().entries.map((entry) { final isEven = entry.key % 2 == 0; return TextSpan( text: entry.value, style: TextStyle( fontWeight: isEven ? FontWeight.normal : FontWeight.bold, ), ); }) else const TextSpan( text: 'None', style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey), ), ], ), ) : Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])), subtitle: Row( children: [ // Slider takes most of the space Expanded( child: Slider( min: 0, max: 359, divisions: 359, value: session.directionDegrees, label: session.directionDegrees.round().toString(), onChanged: enableDirectionControls ? (v) => appState.updateEditSession(directionDeg: v) : null, ), ), // Direction control buttons - always show but grey out when direction not required const SizedBox(width: 8), // Remove button IconButton( icon: Icon( Icons.remove, size: 20, color: enableDirectionControls && appState.canRemoveDirection ? null : Theme.of(context).disabledColor, ), onPressed: enableDirectionControls && appState.canRemoveDirection ? () => appState.removeDirection() : null, tooltip: requiresDirection ? (hasDirections ? (appState.canRemoveDirection ? 'Remove current direction' : 'Cannot remove - minimum reached') : 'No directions to remove') : 'Direction not required for this profile', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight), ), // Add button IconButton( icon: Icon( Icons.add, size: 20, color: enableAddButton && session.directions.length < 8 ? null : Theme.of(context).disabledColor, ), onPressed: enableAddButton && session.directions.length < 8 ? () => appState.addDirection() : null, tooltip: requiresDirection ? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction') : 'Direction not required for this profile', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight), ), // Cycle button IconButton( icon: Icon( Icons.repeat, size: 20, color: enableDirectionControls && session.directions.length > 1 ? null : Theme.of(context).disabledColor, ), onPressed: enableDirectionControls && session.directions.length > 1 ? () => appState.cycleDirection() : null, tooltip: requiresDirection ? (hasDirections ? (session.directions.length > 1 ? 'Cycle through directions' : 'Only one direction') : 'No directions to cycle') : 'Direction not required for this profile', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight), ), ], ), ), // Show info text when profile doesn't require direction or when no directions exist if (!requiresDirection) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ const Icon(Icons.info_outline, color: Colors.grey, size: 16), const SizedBox(width: 6), Expanded( child: Text( 'This profile does not require a direction.', style: const TextStyle(color: Colors.grey, fontSize: 12), ), ), ], ), ) else if (requiresDirection && !hasDirections) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ const Icon(Icons.info_outline, color: Colors.blue, size: 16), const SizedBox(width: 6), Expanded( child: Text( 'This device currently has no direction. Tap the + button to add one.', style: const TextStyle(color: Colors.blue, fontSize: 12), ), ), ], ), ), ], ); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: LocalizationService.instance, builder: (context, child) { final locService = LocalizationService.instance; final appState = context.watch(); void _commit() { // Check if there are any actual changes to submit if (!_hasActualChanges(widget.session)) { _showNoChangesDialog(context, locService); return; } _checkProximityAndCommit(context, appState, locService); } void _cancel() { appState.cancelEditSession(); Navigator.pop(context); } final session = widget.session; final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList(); final isSandboxMode = appState.uploadMode == UploadMode.sandbox; // Check if we have good cache coverage around the node position bool hasGoodCoverage = true; final nodeCoord = session.originalNode.coord; const double bufferDegrees = 0.001; // ~100m buffer final targetBounds = LatLngBounds( LatLng(nodeCoord.latitude - bufferDegrees, nodeCoord.longitude - bufferDegrees), LatLng(nodeCoord.latitude + bufferDegrees, nodeCoord.longitude + bufferDegrees), ); hasGoodCoverage = MapDataProvider().hasGoodCoverageFor(targetBounds); // If strict coverage check fails, fall back to checking if we have any nodes nearby // This handles the timing issue where cache might not be marked as "covered" yet if (!hasGoodCoverage) { final nearbyNodes = MapDataProvider().findNodesWithinDistance( nodeCoord, 200.0, // 200m radius - if we have nodes nearby, we likely have good data ); hasGoodCoverage = nearbyNodes.isNotEmpty; } final allowSubmit = kEnableNodeEdits && appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile != null && session.profile!.isSubmittable && hasGoodCoverage; void _navigateToLogin() { Navigator.pushNamed(context, '/settings/osm-account'); } void _openRefineTags() async { final result = await Navigator.push( context, MaterialPageRoute( builder: (context) => RefineTagsSheet( selectedOperatorProfile: session.operatorProfile, selectedProfile: session.profile, currentRefinedTags: session.refinedTags, currentAdditionalExistingTags: session.additionalExistingTags, originalNodeTags: session.originalNode.tags, operation: session.extractFromWay ? UploadOperation.extract : UploadOperation.modify, ), fullscreenDialog: true, ), ); if (result != null) { debugPrint('EditNodeSheet: Updating session from refine tags result'); debugPrint('EditNodeSheet: Profile: ${session.profile?.name}'); debugPrint('EditNodeSheet: AdditionalExistingTags: ${result.additionalExistingTags}'); debugPrint('EditNodeSheet: Current session additionalExistingTags: ${session.additionalExistingTags}'); appState.updateEditSession( operatorProfile: result.operatorProfile, refinedTags: result.refinedTags, additionalExistingTags: result.additionalExistingTags, changesetComment: result.changesetComment, ); } } return Stack( clipBehavior: Clip.none, fit: StackFit.loose, children: [ Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 12), Container( width: 40, height: 4, decoration: BoxDecoration( color: Colors.grey.shade400, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 8), Text( locService.t('editNode.title', params: [session.originalNode.id.toString()]), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 16), ListTile( title: Text(locService.t('editNode.profile')), trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService), ), // Direction controls _buildDirectionControls(context, appState, session, locService), // Constraint message for nodes that cannot be moved if (session.originalNode.isConstrained) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Column( children: [ // Extract from way checkbox (only show if enabled in dev config) if (kEnableNodeExtraction) ...[ CheckboxListTile( title: Text(locService.t('editNode.extractFromWay')), subtitle: Text(locService.t('editNode.extractFromWaySubtitle')), value: session.extractFromWay, onChanged: (value) { appState.updateEditSession(extractFromWay: value); }, controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, ), const SizedBox(height: 8), ], // Constraint info message (only show if extract is not checked or not enabled) if (!kEnableNodeExtraction || !session.extractFromWay) ...[ Row( children: [ const Icon(Icons.info_outline, size: 20), const SizedBox(width: 8), Expanded( child: Text( locService.t('editNode.cannotMoveConstrainedNode'), style: Theme.of(context).textTheme.bodyMedium, ), ), ], ), const SizedBox(height: 8), ], Row( mainAxisAlignment: MainAxisAlignment.end, children: [ OutlinedButton.icon( onPressed: () => _openAdvancedEdit(context), icon: const Icon(Icons.open_in_new, size: 16), label: Text(locService.t('actions.useAdvancedEditor')), style: OutlinedButton.styleFrom( minimumSize: const Size(0, 32), ), ), ], ), ], ), ), if (!kEnableNodeEdits) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ const Icon(Icons.construction, color: Colors.orange, size: 20), const SizedBox(width: 6), Expanded( child: Text( locService.t('editNode.temporarilyDisabled'), style: const TextStyle(color: Colors.orange, fontSize: 13), ), ), ], ), ) else if (!appState.isLoggedIn) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ const Icon(Icons.info_outline, color: Colors.red, size: 20), const SizedBox(width: 6), Expanded( child: Text( locService.t('editNode.mustBeLoggedIn'), style: const TextStyle(color: Colors.red, fontSize: 13), ), ), ], ), ) else if (submittableProfiles.isEmpty) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ const Icon(Icons.info_outline, color: Colors.red, size: 20), const SizedBox(width: 6), Expanded( child: Text( locService.t('editNode.enableSubmittableProfile'), style: const TextStyle(color: Colors.red, fontSize: 13), ), ), ], ), ) else if (session.profile == null) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ const Icon(Icons.info_outline, color: Colors.orange, size: 20), const SizedBox(width: 6), Expanded( child: Text( locService.t('editNode.profileRequired'), style: const TextStyle(color: Colors.orange, fontSize: 13), ), ), ], ), ) else if (!session.profile!.isSubmittable) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ const Icon(Icons.info_outline, color: Colors.orange, size: 20), const SizedBox(width: 6), Expanded( child: Text( locService.t('editNode.profileViewOnlyWarning'), style: const TextStyle(color: Colors.orange, fontSize: 13), ), ), ], ), ) else if (!hasGoodCoverage) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ const Icon(Icons.cloud_download, color: Colors.blue, size: 20), const SizedBox(width: 6), Expanded( child: Text( locService.t('editNode.loadingAreaData'), style: const TextStyle(color: Colors.blue, fontSize: 13), ), ), ], ), ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected icon: const Icon(Icons.tune), label: Text(locService.t('editNode.refineTags')), ), ), ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ Expanded( child: OutlinedButton( onPressed: _cancel, child: Text(locService.cancel), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton( onPressed: !appState.isLoggedIn ? _navigateToLogin : (allowSubmit ? _commit : null), child: Text(!appState.isLoggedIn ? locService.t('actions.logIn') : locService.t('actions.saveEdit')), ), ), ], ), ), const SizedBox(height: 20), ], ), // Tutorial overlay - show only if tutorial should be shown and we're done checking if (!_isCheckingTutorial && _showTutorial) Positioned.fill( child: PositioningTutorialOverlay(), ), ], ); }, ); } Widget _buildProfileDropdown(BuildContext context, AppState appState, EditNodeSession session, List submittableProfiles, LocalizationService locService) { // Display name for the current profile - localize the existing tags profile String getDisplayName(NodeProfile? profile) { if (profile == null) return locService.t('editNode.selectProfile'); if (profile.id.startsWith('temp-empty-')) { return locService.t('editNode.existingTags'); } return profile.name; } return PopupMenuButton( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade400), borderRadius: BorderRadius.circular(4), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( getDisplayName(session.profile), style: TextStyle( fontSize: 16, color: session.profile != null ? null : Colors.grey.shade600, ), ), const SizedBox(width: 4), const Icon(Icons.arrow_drop_down, size: 20), ], ), ), itemBuilder: (context) => [ // Existing tags profile (always first in edit mode) PopupMenuItem( value: 'existing_tags', child: Text(locService.t('editNode.existingTags')), ), // Divider after existing tags profile if (submittableProfiles.isNotEmpty) const PopupMenuDivider(), // Regular profiles ...submittableProfiles.map( (profile) => PopupMenuItem( value: 'profile_${profile.id}', child: Text(profile.name), ), ), // Divider if (submittableProfiles.isNotEmpty) const PopupMenuDivider(), // Get more... option PopupMenuItem( value: 'get_more', child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.language, size: 16), const SizedBox(width: 8), Text( locService.t('profiles.getMore'), style: const TextStyle( fontStyle: FontStyle.italic, ), ), ], ), ), ], onSelected: (value) { if (value == 'get_more') { _openIdentifyWebsite(context); } else if (value == 'existing_tags') { // Re-create and select the existing tags profile final existingTagsProfile = NodeProfile.createExistingTagsProfile(session.originalNode); appState.updateEditSession(profile: existingTagsProfile); } else if (value.startsWith('profile_')) { final profileId = value.substring(8); // Remove 'profile_' prefix final profile = submittableProfiles.firstWhere((p) => p.id == profileId); appState.updateEditSession(profile: profile); } }, ); } void _openIdentifyWebsite(BuildContext context) async { const url = 'https://deflock.me/identify'; try { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { await launchUrl( uri, mode: LaunchMode.externalApplication, // Force external browser ); } else { if (context.mounted) { _showErrorSnackBar(context, 'Unable to open website'); } } } catch (e) { if (context.mounted) { _showErrorSnackBar(context, 'Error opening website: $e'); } } } void _showErrorSnackBar(BuildContext context, String message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), backgroundColor: Colors.red, ), ); } void _openAdvancedEdit(BuildContext context) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => AdvancedEditOptionsSheet(node: widget.session.originalNode), ); } }