From 21702ed3d213800f0e2ba848bf4fcdb9d2fd613f Mon Sep 17 00:00:00 2001 From: Asiya Kadivar Date: Tue, 7 Jul 2026 21:27:26 -0400 Subject: [PATCH 01/10] lifecycle status, staleness delection, check_date --- lib/app_state.dart | 231 ++--- lib/dev_config.dart | 192 +++-- lib/localizations/en.json | 11 +- lib/models/osm_node.dart | 85 +- lib/models/pending_upload.dart | 321 ++++--- lib/screens/advanced_settings_screen.dart | 17 +- .../sections/staleness_indicator_section.dart | 94 ++ lib/state/session_state.dart | 270 ++++-- lib/state/settings_state.dart | 183 ++-- lib/state/upload_queue_state.dart | 587 +++++++++---- lib/widgets/camera_icon.dart | 17 +- lib/widgets/edit_node_sheet.dart | 810 +++++++++++------- lib/widgets/map/marker_layer_builder.dart | 85 +- lib/widgets/map/node_markers.dart | 100 ++- lib/widgets/node_provider_with_cache.dart | 36 +- lib/widgets/node_tag_sheet.dart | 352 +++++--- 16 files changed, 2213 insertions(+), 1178 deletions(-) create mode 100644 lib/screens/settings/sections/staleness_indicator_section.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index 1cf00a87..e2cf4236 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -44,7 +44,7 @@ export 'models/pending_upload.dart' show UploadOperation; // ------------------ AppState ------------------ class AppState extends ChangeNotifier { static late AppState instance; - + // State modules late final AuthState _authState; late final MessagesState _messagesState; @@ -58,10 +58,11 @@ class AppState extends ChangeNotifier { late final UploadQueueState _uploadQueueState; bool _isInitialized = false; - + // Positioning tutorial state LatLng? _tutorialStartPosition; // Track where the tutorial started - VoidCallback? _tutorialCompletionCallback; // Callback when tutorial is completed + VoidCallback? + _tutorialCompletionCallback; // Callback when tutorial is completed Timer? _messageCheckTimer; AppState() { @@ -76,7 +77,7 @@ class AppState extends ChangeNotifier { _settingsState = SettingsState(); _suspectedLocationState = SuspectedLocationState(); _uploadQueueState = UploadQueueState(); - + // Set up state change listeners _authState.addListener(_onStateChanged); _messagesState.addListener(_onStateChanged); @@ -88,17 +89,17 @@ class AppState extends ChangeNotifier { _settingsState.addListener(_onStateChanged); _suspectedLocationState.addListener(_onStateChanged); _uploadQueueState.addListener(_onStateChanged); - + _init(); } // Getters that delegate to individual state modules bool get isInitialized => _isInitialized; - + // Auth state bool get isLoggedIn => _authState.isLoggedIn; String get username => _authState.username; - + // Navigation state - simplified AppNavigationMode get navigationMode => _navigationState.mode; LatLng? get provisionalPinLocation => _navigationState.provisionalPinLocation; @@ -110,7 +111,7 @@ class AppState extends ChangeNotifier { bool get showSearchButton => _navigationState.showSearchButton; bool get showRouteButton => _navigationState.showRouteButton; List? get routePath => _navigationState.routePath; - + // Route state LatLng? get routeStart => _navigationState.routeStart; LatLng? get routeEnd => _navigationState.routeEnd; @@ -121,35 +122,38 @@ class AppState extends ChangeNotifier { bool get isSettingSecondPoint => _navigationState.isSettingSecondPoint; bool get areRoutePointsTooClose => _navigationState.areRoutePointsTooClose; double? get distanceFromFirstPoint => _navigationState.distanceFromFirstPoint; - bool get distanceExceedsWarningThreshold => _navigationState.distanceExceedsWarningThreshold; + bool get distanceExceedsWarningThreshold => + _navigationState.distanceExceedsWarningThreshold; bool get isCalculating => _navigationState.isCalculating; bool get showingOverview => _navigationState.showingOverview; String? get routingError => _navigationState.routingError; bool get hasRoutingError => _navigationState.hasRoutingError; - + // Navigation search state bool get isNavigationSearchLoading => _navigationState.isSearchLoading; - List get navigationSearchResults => _navigationState.searchResults; - int get navigationAvoidanceDistance => _settingsState.navigationAvoidanceDistance; + List get navigationSearchResults => + _navigationState.searchResults; + int get navigationAvoidanceDistance => + _settingsState.navigationAvoidanceDistance; DistanceUnit get distanceUnit => _settingsState.distanceUnit; - + // Profile state List get profiles => _profileState.profiles; List get enabledProfiles => _profileState.enabledProfiles; bool isEnabled(NodeProfile p) => _profileState.isEnabled(p); - + // Operator profile state List get operatorProfiles => _operatorProfileState.profiles; - + // Search state bool get isSearchLoading => _searchState.isLoading; List get searchResults => _searchState.results; String get lastSearchQuery => _searchState.lastQuery; - + // Session state AddNodeSession? get session => _sessionState.session; EditNodeSession? get editSession => _sessionState.editSession; - + // Settings state bool get offlineMode => _settingsState.offlineMode; bool get pauseQueueProcessing => _settingsState.pauseQueueProcessing; @@ -160,30 +164,37 @@ class AppState extends ChangeNotifier { bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled; int get proximityAlertDistance => _settingsState.proximityAlertDistance; - bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled; - int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance; - + bool get networkStatusIndicatorEnabled => + _settingsState.networkStatusIndicatorEnabled; + int get suspectedLocationMinDistance => + _settingsState.suspectedLocationMinDistance; + bool get stalenessIndicatorEnabled => + _settingsState.stalenessIndicatorEnabled; + int get stalenessThresholdDays => _settingsState.stalenessThresholdDays; + // Messages state int? get unreadMessageCount => _messagesState.unreadCount; bool get hasUnreadMessages => _messagesState.hasUnreadMessages; bool get isCheckingMessages => _messagesState.isChecking; - + // Tile provider state List get tileProviders => _settingsState.tileProviders; TileType? get selectedTileType => _settingsState.selectedTileType; TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider; - - + // Upload queue state int get pendingCount => _uploadQueueState.pendingCount; List get pendingUploads => _uploadQueueState.pendingUploads; // Suspected location state - SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation; + SuspectedLocation? get selectedSuspectedLocation => + _suspectedLocationState.selectedLocation; bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled; bool get suspectedLocationsLoading => _suspectedLocationState.isLoading; - double? get suspectedLocationsDownloadProgress => _suspectedLocationState.downloadProgress; - Future get suspectedLocationsLastFetch => _suspectedLocationState.lastFetchTime; + double? get suspectedLocationsDownloadProgress => + _suspectedLocationState.downloadProgress; + Future get suspectedLocationsLastFetch => + _suspectedLocationState.lastFetchTime; void _onStateChanged() { notifyListeners(); @@ -193,89 +204,89 @@ class AppState extends ChangeNotifier { Future _init() async { // Initialize all state modules await _settingsState.init(); - + // Initialize changelog service await ChangelogService().init(); - + // Attempt to fetch missing tile type preview tiles (fails silently) _fetchMissingTilePreviews(); - + // Check if we should add default profiles (first launch OR no profiles of each type exist) final prefs = await SharedPreferences.getInstance(); const firstLaunchKey = 'profiles_defaults_initialized'; final isFirstLaunch = !(prefs.getBool(firstLaunchKey) ?? false); - + // Load existing profiles to check each type independently final existingOperatorProfiles = await OperatorProfileService().load(); final existingNodeProfiles = await ProfileService().load(); - - final shouldAddOperatorDefaults = isFirstLaunch || existingOperatorProfiles.isEmpty; + + final shouldAddOperatorDefaults = + isFirstLaunch || existingOperatorProfiles.isEmpty; final shouldAddNodeDefaults = isFirstLaunch || existingNodeProfiles.isEmpty; - + await _operatorProfileState.init(addDefaults: shouldAddOperatorDefaults); await _profileState.init(addDefaults: shouldAddNodeDefaults); - + // Set up callback to clear stale sessions when profiles are deleted _profileState.setProfileDeletedCallback(_onProfileDeleted); - + // Mark defaults as initialized if this was first launch if (isFirstLaunch) { await prefs.setBool(firstLaunchKey, true); } - + await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode); await _uploadQueueState.init(); await _authState.init(_settingsState.uploadMode); - + // Set up callback to repopulate pending nodes after cache clears NodeProviderWithCache.instance.setOnCacheClearedCallback(() { _uploadQueueState.repopulateCacheFromQueue(); }); - + // Check for messages on app launch if user is already logged in if (isLoggedIn) { checkMessages(); } - + // Note: Re-auth check will be triggered from home screen after init - + // Initialize OfflineAreaService to ensure offline areas are loaded await OfflineAreaService().ensureInitialized(); - + // Preload offline nodes into cache for immediate display await NodeDataManager().preloadOfflineNodes(); - + // Start uploader if conditions are met _startUploader(); - + _isInitialized = true; - + // Start background refresh of suspected locations if needed (non-blocking) - _suspectedLocationState.initBackgroundRefresh(offlineMode: _settingsState.offlineMode); - + _suspectedLocationState.initBackgroundRefresh( + offlineMode: _settingsState.offlineMode, + ); + // Check for initial deep link after a small delay to let navigation settle Future.delayed(const Duration(milliseconds: 500), () { DeepLinkService().checkInitialLink(); }); - + // Start periodic message checking _startMessageCheckTimer(); - + notifyListeners(); } - + void _startMessageCheckTimer() { _messageCheckTimer?.cancel(); - + // Check messages every 10 minutes when logged in - _messageCheckTimer = Timer.periodic( - const Duration(minutes: 10), - (timer) { - if (isLoggedIn) { - checkMessages(); - } - }, - ); + _messageCheckTimer = Timer.periodic(const Duration(minutes: 10), (timer) { + if (isLoggedIn) { + checkMessages(); + } + }); } // ---------- Auth Methods ---------- @@ -308,7 +319,7 @@ class AppState extends ChangeNotifier { Future validateToken() async { return await _authState.validateToken(); } - + // ---------- Messages Methods ---------- Future checkMessages({bool forceRefresh = false}) async { final accessToken = await _authState.getAccessToken(); @@ -318,15 +329,15 @@ class AppState extends ChangeNotifier { forceRefresh: forceRefresh, ); } - + String getMessagesUrl() { return _messagesState.getMessagesUrl(uploadMode); } - + void clearMessages() { _messagesState.clearMessages(); } - + /// Check if the current OAuth token has required scopes for message notifications /// Returns true if re-authentication is needed Future needsReauthForMessages() async { @@ -334,10 +345,10 @@ class AppState extends ChangeNotifier { if (!isLoggedIn || uploadMode == UploadMode.simulate) { return false; } - + final accessToken = await _authState.getAccessToken(); if (accessToken == null) return false; - + final client = UserAgentClient(); try { // Try to fetch user details - this should include message data if scope is correct @@ -366,7 +377,7 @@ class AppState extends ChangeNotifier { client.close(); } } - + /// Show re-authentication dialog if needed Future checkAndPromptReauthForMessages(BuildContext context) async { if (await needsReauthForMessages()) { @@ -374,7 +385,7 @@ class AppState extends ChangeNotifier { _showReauthDialog(context); } } - + void _showReauthDialog(BuildContext context) { showDialog( context: context, @@ -389,7 +400,7 @@ class AppState extends ChangeNotifier { ), ); } - + String _getApiHost() { switch (uploadMode) { case UploadMode.production: @@ -422,15 +433,15 @@ class AppState extends ChangeNotifier { Future reloadProfiles() async { await _profileState.reloadFromStorage(); } - + // Callback when a profile is deleted - clear any stale session references void _onProfileDeleted(NodeProfile deletedProfile) { // Clear add session if it references the deleted profile if (_sessionState.session?.profile?.id == deletedProfile.id) { cancelSession(); } - - // Clear edit session if it references the deleted profile + + // Clear edit session if it references the deleted profile if (_sessionState.editSession?.profile?.id == deletedProfile.id) { cancelEditSession(); } @@ -478,7 +489,7 @@ class AppState extends ChangeNotifier { changesetComment: changesetComment, updateOperatorProfile: updateOperatorProfile, ); - + // Check tutorial completion if position changed if (target != null) { _checkTutorialCompletion(target); @@ -491,6 +502,7 @@ class AppState extends ChangeNotifier { OperatorProfile? operatorProfile, LatLng? target, bool? extractFromWay, + String? lifecycleStatus, Map? refinedTags, Map? additionalExistingTags, String? changesetComment, @@ -502,18 +514,19 @@ class AppState extends ChangeNotifier { operatorProfile: operatorProfile, target: target, extractFromWay: extractFromWay, + lifecycleStatus: lifecycleStatus, refinedTags: refinedTags, additionalExistingTags: additionalExistingTags, changesetComment: changesetComment, updateOperatorProfile: updateOperatorProfile, ); - + // Check tutorial completion if position changed if (target != null) { _checkTutorialCompletion(target); } } - + // For map view to check for pending snap backs LatLng? consumePendingSnapBack() { return _sessionState.consumePendingSnapBack(); @@ -536,18 +549,23 @@ class AppState extends ChangeNotifier { } void _checkTutorialCompletion(LatLng newPosition) { - if (_tutorialCompletionCallback == null || _tutorialStartPosition == null) return; - + if (_tutorialCompletionCallback == null || _tutorialStartPosition == null) + return; + // Calculate distance moved final distance = Distance(); - final distanceMoved = distance.as(LengthUnit.Meter, _tutorialStartPosition!, newPosition); - + final distanceMoved = distance.as( + LengthUnit.Meter, + _tutorialStartPosition!, + newPosition, + ); + if (distanceMoved >= kPositioningTutorialMinMovementMeters) { // Tutorial completed! Mark as complete and notify callback immediately final callback = _tutorialCompletionCallback; clearTutorialCallback(); callback?.call(); - + // Mark as complete in background (don't await to avoid delays) ChangelogService().markPositioningTutorialCompleted(); } @@ -567,8 +585,6 @@ class AppState extends ChangeNotifier { _sessionState.cycleDirection(); } - - void cancelSession() { _sessionState.cancelSession(); } @@ -593,8 +609,17 @@ class AppState extends ChangeNotifier { } } + void verifyNode(OsmNode node) { + _uploadQueueState.addFromVerification(node, uploadMode: uploadMode); + _startUploader(); + } + void deleteNode(OsmNode node, {String? changesetComment}) { - _uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment); + _uploadQueueState.addFromNodeDeletion( + node, + uploadMode: uploadMode, + changesetComment: changesetComment, + ); _startUploader(); } @@ -625,7 +650,9 @@ class AppState extends ChangeNotifier { } void startRoutePlanning({required bool thisLocationIsStart}) { - _navigationState.startRoutePlanning(thisLocationIsStart: thisLocationIsStart); + _navigationState.startRoutePlanning( + thisLocationIsStart: thisLocationIsStart, + ); } void selectSecondRoutePoint() { @@ -634,12 +661,12 @@ class AppState extends ChangeNotifier { void startRoute() { _navigationState.startRoute(); - + // Auto-enable follow-me if user is near the start point // We need to get user location from the GPS controller // This will be handled in HomeScreen where we have access to MapView } - + bool shouldAutoEnableFollowMe(LatLng? userLocation) { return _navigationState.shouldAutoEnableFollowMe(userLocation); } @@ -702,19 +729,19 @@ class AppState extends ChangeNotifier { // Clear node cache when switching upload modes to prevent mixing production/sandbox data MapDataProvider().clearCache(); debugPrint('[AppState] Cleared node cache due to upload mode change'); - + await _settingsState.setUploadMode(mode); await _authState.onUploadModeChanged(mode); - + // Clear and re-check messages for new mode clearMessages(); if (isLoggedIn) { // Don't await - let it run in background checkMessages(); - + // Note: Re-auth check will be triggered from the settings screen after mode change } - + _startUploader(); // Restart uploader with new mode } @@ -742,7 +769,7 @@ class AppState extends ChangeNotifier { Future setFollowMeMode(FollowMeMode mode) async { await _settingsState.setFollowMeMode(mode); } - + /// Set proximity alerts enabled/disabled Future setProximityAlertsEnabled(bool enabled) async { await _settingsState.setProximityAlertsEnabled(enabled); @@ -758,13 +785,21 @@ class AppState extends ChangeNotifier { await _settingsState.setNetworkStatusIndicatorEnabled(enabled); } - - /// Set suspected location minimum distance from real nodes Future setSuspectedLocationMinDistance(int distance) async { await _settingsState.setSuspectedLocationMinDistance(distance); } + /// Set staleness indicator enabled/disabled + Future setStalenessIndicatorEnabled(bool enabled) async { + await _settingsState.setStalenessIndicatorEnabled(enabled); + } + + /// Set staleness threshold in days + Future setStalenessThresholdDays(int days) async { + await _settingsState.setStalenessThresholdDays(days); + } + /// Set navigation avoidance distance Future setNavigationAvoidanceDistance(int distance) async { await _settingsState.setNavigationAvoidanceDistance(distance); @@ -778,7 +813,7 @@ class AppState extends ChangeNotifier { void clearQueue() { _uploadQueueState.clearQueue(); } - + void removeFromQueue(PendingUpload upload) { _uploadQueueState.removeFromQueue(upload); } @@ -827,7 +862,7 @@ class AppState extends ChangeNotifier { west: west, ); } - + List getSuspectedLocationsInBoundsSync({ required double north, required double south, @@ -851,15 +886,17 @@ class AppState extends ChangeNotifier { required UploadOperation operation, }) { // Handle temp profiles with brackets by using "a" - final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true + 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'; + return 'Update $profileName surveillance node'; case UploadOperation.delete: return 'Delete $profileName surveillance node'; case UploadOperation.extract: @@ -899,7 +936,7 @@ class AppState extends ChangeNotifier { _settingsState.removeListener(_onStateChanged); _suspectedLocationState.removeListener(_onStateChanged); _uploadQueueState.removeListener(_onStateChanged); - + _uploadQueueState.dispose(); super.dispose(); } diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 0d16a25e..ffd5d853 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -20,18 +20,29 @@ const double kDirectionConeOpacity = 0.5; // Fill opacity for FOV cones const double _kDirectionConeBorderWidthBase = 1.6; // Bottom button bar positioning -const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area) +const double kBottomButtonBarOffset = + 4.0; // Distance from screen bottom (above safe area) const double kButtonBarHeight = 60.0; // Button height (48) + padding (12) // Map overlay spacing relative to button bar top -const double kAttributionSpacingAboveButtonBar = 10.0; // Attribution above button bar top -const double kZoomIndicatorSpacingAboveButtonBar = 40.0; // Zoom indicator above button bar top -const double kScaleBarSpacingAboveButtonBar = 70.0; // Scale bar above button bar top -const double kZoomControlsSpacingAboveButtonBar = 20.0; // Zoom controls above button bar top +const double kAttributionSpacingAboveButtonBar = + 10.0; // Attribution above button bar top +const double kZoomIndicatorSpacingAboveButtonBar = + 40.0; // Zoom indicator above button bar top +const double kScaleBarSpacingAboveButtonBar = + 70.0; // Scale bar above button bar top +const double kZoomControlsSpacingAboveButtonBar = + 20.0; // Zoom controls above button bar top // Helper to calculate bottom position relative to button bar -double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeAreaBottom) { - return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar; +double bottomPositionFromButtonBar( + double spacingAboveButtonBar, + double safeAreaBottom, +) { + return safeAreaBottom + + kBottomButtonBarOffset + + kButtonBarHeight + + spacingAboveButtonBar; } // Helper to get left positioning that accounts for safe area (for landscape mode) @@ -56,34 +67,51 @@ const String kContactEmail = 'admin@stopflock.com'; const String kHomepageUrl = 'https://deflock.org'; // Upload and changeset configuration -const Duration kUploadHttpTimeout = Duration(seconds: 30); // HTTP request timeout for uploads -const Duration kUploadQueueProcessingInterval = Duration(seconds: 5); // How often to check for new uploads to start -const int kMaxConcurrentUploads = 5; // Maximum number of uploads processing simultaneously +const Duration kUploadHttpTimeout = Duration( + seconds: 30, +); // HTTP request timeout for uploads +const Duration kUploadQueueProcessingInterval = Duration( + seconds: 5, +); // How often to check for new uploads to start +const int kMaxConcurrentUploads = + 5; // Maximum number of uploads processing simultaneously const Duration kChangesetCloseInitialRetryDelay = Duration(seconds: 10); -const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5 minutes -const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close +const Duration kChangesetCloseMaxRetryDelay = Duration( + minutes: 5, +); // Cap at 5 minutes +const Duration kChangesetAutoCloseTimeout = Duration( + minutes: 59, +); // Give up and trust OSM auto-close const double kChangesetCloseBackoffMultiplier = 2.0; // Overpass API configuration -const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded) +const Duration kOverpassQueryTimeout = Duration( + seconds: 45, +); // Timeout for Overpass API queries (was 25s hardcoded) // Suspected locations CSV URL -const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv'; +const String kSuspectedLocationsCsvUrl = + 'https://alprwatch.org/suspected-locations/deflock-latest.csv'; // 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 = + false; // Set to false to hide sandbox/simulate modes and force production mode // Navigation features - set to false to hide navigation UI elements while in development -const bool kEnableNavigationFeatures = true; // Hide navigation until fully implemented +const bool kEnableNavigationFeatures = + true; // Hide navigation until fully implemented // Node editing features - set to false to temporarily disable editing -const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing +const bool kEnableNodeEdits = + true; // Set to false to temporarily disable node editing // Node extraction features - set to false to hide extract functionality for constrained nodes -const bool kEnableNodeExtraction = false; // Set to true to enable extract from way/relation feature (WIP) +const bool kEnableNodeExtraction = + false; // Set to true to enable extract from way/relation feature (WIP) // Profile FOV features - set to false to restrict profiles to 360° FOV only -const bool kEnableNon360FOVs = false; // Set to true to allow custom FOV values in profiles +const bool kEnableNon360FOVs = + false; // Set to true to allow custom FOV values in profiles /// Navigation availability: only dev builds, and only when online bool enableNavigationFeatures({required bool offlineMode}) { @@ -92,69 +120,101 @@ bool enableNavigationFeatures({required bool offlineMode}) { // Marker/node interaction const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass) -const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode) -const int kMinZoomForNodeEditingSheets = 16; // Minimum zoom to open add/edit node sheets -const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes) +const int kOsmApiMinZoomLevel = + 13; // Minimum zoom for OSM API bbox queries (sandbox mode) +const int kMinZoomForNodeEditingSheets = + 16; // Minimum zoom to open add/edit node sheets +const int kMinZoomForOfflineDownload = + 10; // Minimum zoom to download offline areas (prevents large area crashes) const Duration kMarkerTapTimeout = Duration(milliseconds: 250); -const Duration kMapLongPressTimeout = Duration(milliseconds: 600); // Duration to trigger "add node here" on empty map area +const Duration kMapLongPressTimeout = Duration( + milliseconds: 600, +); // Duration to trigger "add node here" on empty map area const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); // Pre-fetch area configuration -const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching -const double kNodeRenderingBoundsExpansion = 1.3; // Expand visible bounds by this factor for node rendering to prevent edge blinking -const double kRouteProximityThresholdMeters = 500.0; // Distance threshold for determining if user is near route when resuming navigation -const double kResumeNavigationZoomLevel = 16.0; // Zoom level when resuming navigation -const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes -const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit +const double kPreFetchAreaExpansionMultiplier = + 3.0; // Expand visible bounds by this factor for pre-fetching +const double kNodeRenderingBoundsExpansion = + 1.3; // Expand visible bounds by this factor for node rendering to prevent edge blinking +const double kRouteProximityThresholdMeters = + 500.0; // Distance threshold for determining if user is near route when resuming navigation +const double kResumeNavigationZoomLevel = + 16.0; // Zoom level when resuming navigation +const int kPreFetchZoomLevel = + 10; // Always pre-fetch at this zoom level for consistent area sizes +const int kMaxPreFetchSplitDepth = + 3; // Maximum recursive splits when hitting Overpass node limit // Data refresh configuration -const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds +const int kDataRefreshIntervalSeconds = + 60; // Refresh cached data after this many seconds // Follow-me mode smooth transitions const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600); -const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation +const double kMinSpeedForRotationMps = + 1.0; // Minimum speed (m/s) to apply rotation // Sheet content configuration -const double kMaxTagListHeightRatioPortrait = 0.3; // Maximum height for tag lists in portrait mode -const double kMaxTagListHeightRatioLandscape = 0.2; // Maximum height for tag lists in landscape mode +const double kMaxTagListHeightRatioPortrait = + 0.3; // Maximum height for tag lists in portrait mode +const double kMaxTagListHeightRatioLandscape = + 0.2; // Maximum height for tag lists in landscape mode /// Get appropriate tag list height ratio based on screen orientation double getTagListHeightRatio(BuildContext context) { final size = MediaQuery.of(context).size; final isLandscape = size.width > size.height; - return isLandscape ? kMaxTagListHeightRatioLandscape : kMaxTagListHeightRatioPortrait; + return isLandscape + ? kMaxTagListHeightRatioLandscape + : kMaxTagListHeightRatioPortrait; } // Proximity alerts configuration const int kProximityAlertDefaultDistance = 400; // meters const int kProximityAlertMinDistance = 50; // meters const int kProximityAlertMaxDistance = 1600; // meters -const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node +const Duration kProximityAlertCooldown = Duration( + minutes: 10, +); // Cooldown between alerts for same node // Node proximity warning configuration (for new/edited nodes that are too close to existing ones) -const double kNodeProximityWarningDistance = 50.0; // meters - distance threshold to show warning +const double kNodeProximityWarningDistance = + 50.0; // meters - distance threshold to show warning // Positioning tutorial configuration -const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay -const double kPositioningTutorialMinMovementMeters = 1.0; // Minimum map movement to complete tutorial +const double kPositioningTutorialBlurSigma = + 3.0; // Blur strength for sheet overlay +const double kPositioningTutorialMinMovementMeters = + 1.0; // Minimum map movement to complete tutorial // Navigation route planning configuration -const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points -const double kNavigationDistanceWarningThreshold = 300000.0; // meters - distance threshold for timeout warning (30km) +const double kNavigationMinRouteDistance = + 100.0; // meters - minimum distance between start and end points +const double kNavigationDistanceWarningThreshold = + 300000.0; // meters - distance threshold for timeout warning (30km) // Node display configuration -const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once +const int kDefaultMaxNodes = + 500; // Default maximum number of nodes to render on the map at once // NSI (Name Suggestion Index) configuration -const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful -const int kNSIMaxSuggestions = 10; // Maximum number of tag value suggestions to fetch and display +const int kNSIMinimumHitCount = + 500; // Minimum hit count for NSI suggestions to be considered useful +const int kNSIMaxSuggestions = + 10; // Maximum number of tag value suggestions to fetch and display // Map interaction configuration -const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0) -const double kScrollWheelVelocity = 0.01; // Mouse scroll wheel zoom speed (default 0.005) -const double kPinchZoomThreshold = 0.2; // How much pinch required to start zoom (reduced for gesture race) -const double kPinchMoveThreshold = 30.0; // How much drag required for two-finger pan (default 40.0) -const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style) +const double kNodeDoubleTapZoomDelta = + 1.0; // How much to zoom in when double-tapping nodes (was 1.0) +const double kScrollWheelVelocity = + 0.01; // Mouse scroll wheel zoom speed (default 0.005) +const double kPinchZoomThreshold = + 0.2; // How much pinch required to start zoom (reduced for gesture race) +const double kPinchMoveThreshold = + 30.0; // How much drag required for two-finger pan (default 40.0) +const double kRotationThreshold = + 6.0; // Degrees of rotation required before map actually rotates (Google Maps style) // User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min) const int kMaxUserDownloadZoomSpan = 7; @@ -168,25 +228,39 @@ const int kAbsoluteMaxZoom = 23; const double kNodeIconDiameter = 18.0; const double _kNodeRingThicknessBase = 2.5; const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior -const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue -const Color kNodeRingColorMock = Color(0xD0FFFFFF); // Add node mock point - white -const Color kNodeRingColorPending = Color(0xD09C27B0); // Submitted/pending nodes - purple -const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - orange -const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey -const Color kNodeRingColorPendingDeletion = Color(0xC0F44336); // Node pending deletion - red, slightly transparent - -// Direction slider control buttons configuration +const Color kNodeRingColorReal = Color( + 0xFF3036F0, +); // Real nodes from OSM - blue +const Color kNodeRingColorStale = Color( + 0xFF6F4E37, +); // Real but stale OSM nodes - brown +const Color kNodeRingColorMock = Color( + 0xD0FFFFFF, +); // Add node mock point - white +const Color kNodeRingColorPending = Color( + 0xD09C27B0, +); // Submitted/pending nodes - purple +const Color kNodeRingColorEditing = Color( + 0xD0FF9800, +); // Node being edited - orange +const Color kNodeRingColorPendingEdit = Color( + 0xD0757575, +); // Original node with pending edit - grey +const Color kNodeRingColorPendingDeletion = Color( + 0xC0F44336, +); // Node pending deletion - red, slightly transparent + +// Direction slider control buttons configuration const double kDirectionButtonMinWidth = 22.0; const double kDirectionButtonMinHeight = 32.0; // Helper functions for pixel-ratio scaling double getDirectionConeBorderWidth(BuildContext context) { -// return _kDirectionConeBorderWidthBase * MediaQuery.of(context).devicePixelRatio; + // return _kDirectionConeBorderWidthBase * MediaQuery.of(context).devicePixelRatio; return _kDirectionConeBorderWidthBase; } double getNodeRingThickness(BuildContext context) { -// return _kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio; + // return _kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio; return _kNodeRingThicknessBase; } - diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 423a52cd..adf3efe2 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -47,6 +47,7 @@ "download": "Download", "settings": "Settings", "edit": "Edit", + "verify": "Verify", "delete": "Delete", "cancel": "Cancel", "ok": "OK", @@ -112,7 +113,8 @@ "advancedSettingsSubtitle": "Performance, alerts, and tile provider settings", "proximityAlerts": "Proximity Alerts", "networkStatusIndicator": "Network Status Indicator", - "keepScreenAwake": "Keep Screen Awake" + "keepScreenAwake": "Keep Screen Awake", + "dataFreshness": "Data Freshness" }, "keepScreenAwake": { "title": "Keep screen awake while using app", @@ -130,6 +132,13 @@ "alertDistance": "Alert distance: ", "rangeInfo": "Range: {}-{} {} (default: {})" }, + "staleness": { + "showIndicator": "Show stale node indicator", + "showIndicatorExplanation": "Highlight nodes that haven't been checked recently", + "threshold": "Consider stale after", + "daysCount": "{} days", + "explanation": "Nodes without a check_date are never marked stale." + }, "node": { "title": "Node #{}", "tagSheetTitle": "Surveillance Device Tags", diff --git a/lib/models/osm_node.dart b/lib/models/osm_node.dart index efceedd6..ae65a618 100644 --- a/lib/models/osm_node.dart +++ b/lib/models/osm_node.dart @@ -12,7 +12,8 @@ class OsmNode { required this.id, required this.coord, required this.tags, - this.isConstrained = false, // Default to unconstrained for backward compatibility + this.isConstrained = + false, // Default to unconstrained for backward compatibility }); Map toJson() => { @@ -31,13 +32,32 @@ class OsmNode { }); } return OsmNode( - id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0, - coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()), + id: json['id'] is int + ? json['id'] as int + : int.tryParse(json['id'].toString()) ?? 0, + coord: LatLng( + (json['lat'] as num).toDouble(), + (json['lon'] as num).toDouble(), + ), tags: tags, - isConstrained: json['isConstrained'] as bool? ?? false, // Default to false for backward compatibility + isConstrained: + json['isConstrained'] as bool? ?? + false, // Default to false for backward compatibility ); } + /// Parsed check_date tag, if present and valid. Null for nodes with no + /// check_date (treated as "unknown age", never stale). + DateTime? get checkDate => DateTime.tryParse(tags['check_date'] ?? ''); + + /// Whether this node's check_date is older than [staleDays]. + /// Nodes without a check_date are never considered stale. + bool isStale(int staleDays) { + final date = checkDate; + if (date == null) return false; + return DateTime.now().difference(date).inDays > staleDays; + } + bool get hasDirection => directionFovPairs.isNotEmpty; /// Get direction and FOV pairs, supporting range notation like "90-270" or "10-45;90-125;290" @@ -47,26 +67,39 @@ class OsmNode { // Compass direction to degree mapping const compassDirections = { - 'N': 0.0, 'NNE': 22.5, 'NE': 45.0, 'ENE': 67.5, - 'E': 90.0, 'ESE': 112.5, 'SE': 135.0, 'SSE': 157.5, - 'S': 180.0, 'SSW': 202.5, 'SW': 225.0, 'WSW': 247.5, - 'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5, + 'N': 0.0, + 'NNE': 22.5, + 'NE': 45.0, + 'ENE': 67.5, + 'E': 90.0, + 'ESE': 112.5, + 'SE': 135.0, + 'SSE': 157.5, + 'S': 180.0, + 'SSW': 202.5, + 'SW': 225.0, + 'WSW': 247.5, + 'W': 270.0, + 'WNW': 292.5, + 'NW': 315.0, + 'NNW': 337.5, }; final directionFovList = []; final parts = raw.split(';'); - + for (final part in parts) { final trimmed = part.trim(); if (trimmed.isEmpty) continue; - + // Check if this part contains a range (e.g., "90-270") - if (trimmed.contains('-') && RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) { + if (trimmed.contains('-') && + RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) { final rangeParts = trimmed.split('-'); if (rangeParts.length == 2) { final start = double.tryParse(rangeParts[0]); final end = double.tryParse(rangeParts[1]); - + if (start != null && end != null) { final normalized = _calculateRangeCenter(start, end); directionFovList.add(normalized); @@ -74,17 +107,19 @@ class OsmNode { } } } - + // Not a range, handle as single direction final trimmedUpper = trimmed.toUpperCase(); - + // First try compass direction lookup if (compassDirections.containsKey(trimmedUpper)) { final degrees = compassDirections[trimmedUpper]!; - directionFovList.add(DirectionFov(degrees, kDirectionConeHalfAngle * 2)); + directionFovList.add( + DirectionFov(degrees, kDirectionConeHalfAngle * 2), + ); continue; } - + // Then try numeric parsing final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(trimmed); if (match == null) continue; @@ -95,9 +130,11 @@ class OsmNode { // Normalize: wrap negative or >360 into 0‑359 range final normalized = ((val % 360) + 360) % 360; - directionFovList.add(DirectionFov(normalized, kDirectionConeHalfAngle * 2)); + directionFovList.add( + DirectionFov(normalized, kDirectionConeHalfAngle * 2), + ); } - + return directionFovList; } @@ -106,24 +143,24 @@ class OsmNode { // Normalize start and end to 0-359 range start = ((start % 360) + 360) % 360; end = ((end % 360) + 360) % 360; - + // Special case: if start equals end, this represents 360° FOV if (start == end) { return DirectionFov(start, 360.0); } - + double width, center; - + if (start > end) { // Wrapping case: 270-90 width = (end + 360) - start; center = ((start + end + 360) / 2) % 360; } else { - // Normal case: 90-270 + // Normal case: 90-270 width = end - start; center = (start + end) / 2; } - + return DirectionFov(center, width); } @@ -131,4 +168,4 @@ class OsmNode { List get directionDeg { return directionFovPairs.map((df) => df.centerDegrees).toList(); } -} \ No newline at end of file +} diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index 2465bab6..b01da45a 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -7,13 +7,13 @@ import '../dev_config.dart'; enum UploadOperation { create, modify, delete, extract } -enum UploadState { - pending, // Not started yet +enum UploadState { + pending, // Not started yet creatingChangeset, // Creating changeset - uploading, // Node operation (create/modify/delete) - closingChangeset, // Closing changeset - error, // Upload failed (needs user retry) OR changeset not found - complete // Everything done + uploading, // Node operation (create/modify/delete) + closingChangeset, // Closing changeset + error, // Upload failed (needs user retry) OR changeset not found + complete, // Everything done } class PendingUpload { @@ -21,25 +21,37 @@ class PendingUpload { final dynamic direction; // Can be double or String for multiple directions final NodeProfile? profile; final OperatorProfile? operatorProfile; - final Map refinedTags; // User-selected values for empty profile tags - final Map additionalExistingTags; // Tags that exist on node but not in profile + final Map + refinedTags; // User-selected values for empty profile tags + final Map + additionalExistingTags; // Tags that exist on node but not in profile + final String + lifecycleStatus; // User-editable lifecycle status (e.g., 'active', 'removed', etc.) 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 - int? submittedNodeId; // The actual node ID returned by OSM after successful submission - int? tempNodeId; // ID of temporary node created in cache (for specific cleanup) + 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 + int? + submittedNodeId; // The actual node ID returned by OSM after successful submission + int? + tempNodeId; // ID of temporary node created in cache (for specific cleanup) int attempts; bool error; // DEPRECATED: Use uploadState instead String? errorMessage; // Detailed error message for debugging bool completing; // DEPRECATED: Use uploadState instead UploadState uploadState; // Current state in the upload pipeline String? changesetId; // ID of changeset that needs closing - DateTime? nodeOperationCompletedAt; // When node operation completed (start of 59-minute countdown) + DateTime? + nodeOperationCompletedAt; // When node operation completed (start of 59-minute countdown) int changesetCloseAttempts; // Number of changeset close attempts - DateTime? lastChangesetCloseAttemptAt; // When we last tried to close changeset (for retry timing) - int nodeSubmissionAttempts; // Number of node submission attempts (separate from overall attempts) - DateTime? lastNodeSubmissionAttemptAt; // When we last tried to submit node (for retry timing) + DateTime? + lastChangesetCloseAttemptAt; // When we last tried to close changeset (for retry timing) + int + nodeSubmissionAttempts; // Number of node submission attempts (separate from overall attempts) + DateTime? + lastNodeSubmissionAttemptAt; // When we last tried to submit node (for retry timing) PendingUpload({ required this.coord, @@ -48,6 +60,7 @@ class PendingUpload { this.operatorProfile, Map? refinedTags, Map? additionalExistingTags, + this.lifecycleStatus = 'active', required this.changesetComment, required this.uploadMode, required this.operation, @@ -69,33 +82,37 @@ class PendingUpload { additionalExistingTags = additionalExistingTags ?? {}, assert( (operation == UploadOperation.create && originalNodeId == null) || - (operation == UploadOperation.create) || (originalNodeId != null), - 'originalNodeId must be null for create operations and non-null for modify/delete/extract operations' + (operation == UploadOperation.create) || + (originalNodeId != null), + 'originalNodeId must be null for create operations and non-null for modify/delete/extract operations', ), assert( (operation == UploadOperation.delete) || (profile != null), - 'profile is required for create, modify, and extract operations' + 'profile is required for create, modify, and extract operations', ); // True if this is an edit of an existing node, false if it's a new node bool get isEdit => operation == UploadOperation.modify; - + // True if this is a deletion of an existing node bool get isDeletion => operation == UploadOperation.delete; - + // True if this is an extract operation (new node with tags from constrained node) bool get isExtraction => operation == UploadOperation.extract; - + // New state-based helpers bool get needsUserRetry => uploadState == UploadState.error; - bool get isActivelyProcessing => uploadState == UploadState.creatingChangeset || uploadState == UploadState.uploading || uploadState == UploadState.closingChangeset; + bool get isActivelyProcessing => + uploadState == UploadState.creatingChangeset || + uploadState == UploadState.uploading || + uploadState == UploadState.closingChangeset; bool get isComplete => uploadState == UploadState.complete; bool get isPending => uploadState == UploadState.pending; bool get isCreatingChangeset => uploadState == UploadState.creatingChangeset; bool get isUploading => uploadState == UploadState.uploading; bool get isClosingChangeset => uploadState == UploadState.closingChangeset; - - // Calculate time until OSM auto-closes changeset (for UI display) + + // Calculate time until OSM auto-closes changeset (for UI display) // This uses nodeOperationCompletedAt (when changeset was created) as the reference Duration? get timeUntilAutoClose { if (nodeOperationCompletedAt == null) return null; @@ -103,33 +120,41 @@ class PendingUpload { final remaining = kChangesetAutoCloseTimeout - elapsed; return remaining.isNegative ? Duration.zero : remaining; } - + // Check if the 59-minute window has expired (for phases 2 & 3) // This uses nodeOperationCompletedAt (when changeset was created) as the reference bool get hasChangesetExpired { if (nodeOperationCompletedAt == null) return false; - return DateTime.now().difference(nodeOperationCompletedAt!) >= kChangesetAutoCloseTimeout; + return DateTime.now().difference(nodeOperationCompletedAt!) >= + kChangesetAutoCloseTimeout; } - + // Legacy method name for backward compatibility bool get shouldGiveUpOnChangeset => hasChangesetExpired; - + // Calculate next retry delay for changeset close using exponential backoff Duration get nextChangesetCloseRetryDelay { final delay = Duration( - milliseconds: (kChangesetCloseInitialRetryDelay.inMilliseconds * - math.pow(kChangesetCloseBackoffMultiplier, changesetCloseAttempts)).round() + milliseconds: + (kChangesetCloseInitialRetryDelay.inMilliseconds * + math.pow( + kChangesetCloseBackoffMultiplier, + changesetCloseAttempts, + )) + .round(), ); - return delay > kChangesetCloseMaxRetryDelay - ? kChangesetCloseMaxRetryDelay - : delay; + return delay > kChangesetCloseMaxRetryDelay + ? kChangesetCloseMaxRetryDelay + : delay; } // Check if it's time to retry changeset close bool get isReadyForChangesetCloseRetry { if (lastChangesetCloseAttemptAt == null) return true; // First attempt - - final nextRetryTime = lastChangesetCloseAttemptAt!.add(nextChangesetCloseRetryDelay); + + final nextRetryTime = lastChangesetCloseAttemptAt!.add( + nextChangesetCloseRetryDelay, + ); return DateTime.now().isAfter(nextRetryTime); } @@ -178,7 +203,8 @@ class PendingUpload { void markChangesetCreated(String csId) { uploadState = UploadState.uploading; changesetId = csId; - nodeOperationCompletedAt = DateTime.now(); // Track when changeset was created for 59-minute timeout + nodeOperationCompletedAt = + DateTime.now(); // Track when changeset was created for 59-minute timeout } // Mark node operation as complete, start changeset close phase @@ -211,19 +237,26 @@ class PendingUpload { // Calculate next retry delay for node submission using exponential backoff Duration get nextNodeSubmissionRetryDelay { final delay = Duration( - milliseconds: (kChangesetCloseInitialRetryDelay.inMilliseconds * - math.pow(kChangesetCloseBackoffMultiplier, nodeSubmissionAttempts)).round() + milliseconds: + (kChangesetCloseInitialRetryDelay.inMilliseconds * + math.pow( + kChangesetCloseBackoffMultiplier, + nodeSubmissionAttempts, + )) + .round(), ); - return delay > kChangesetCloseMaxRetryDelay - ? kChangesetCloseMaxRetryDelay - : delay; + return delay > kChangesetCloseMaxRetryDelay + ? kChangesetCloseMaxRetryDelay + : delay; } // Check if it's time to retry node submission bool get isReadyForNodeSubmissionRetry { if (lastNodeSubmissionAttemptAt == null) return true; // First attempt - - final nextRetryTime = lastNodeSubmissionAttemptAt!.add(nextNodeSubmissionRetryDelay); + + final nextRetryTime = lastNodeSubmissionAttemptAt!.add( + nextNodeSubmissionRetryDelay, + ); return DateTime.now().isAfter(nextRetryTime); } @@ -233,28 +266,29 @@ class PendingUpload { if (operation == UploadOperation.delete || profile == null) { return {}; } - + final tags = Map.from(profile!.tags); - + // Add additional existing tags first (these have lower precedence) tags.addAll(additionalExistingTags); - + // Apply profile tags again to ensure they take precedence over additional existing tags tags.addAll(profile!.tags); - + // Apply refined tags (these fill in empty values from the profile) for (final entry in refinedTags.entries) { // Only apply refined tags if the profile tag value is empty - if (profile!.tags.containsKey(entry.key) && profile!.tags[entry.key]?.trim().isEmpty == true) { + if (profile!.tags.containsKey(entry.key) && + profile!.tags[entry.key]?.trim().isEmpty == true) { tags[entry.key] = entry.value; } } - + // Add operator profile tags (they override everything if there are conflicts) if (operatorProfile != null) { tags.addAll(operatorProfile!.tags); } - + // Add direction if required if (profile!.requiresDirection) { if (direction is String) { @@ -265,92 +299,124 @@ class PendingUpload { tags['direction'] = '0'; } } - + + // Add current check_date:surveillance tag for all operations except delete (still there) + if (operation != UploadOperation.delete) { + tags['check_date:surveillance'] = DateTime.now() + .toIso8601String() + .split('T') + .first; + } + + // Apply lifecycle prefix (e.g. 'destroyed:') to identity tags if status is not active + // Applied to all identity tags in accordance with OSM guidelines for lifecycle tagging + if (lifecycleStatus != 'active') { + const identityKeys = ['man_made', 'surveillance:type']; + for (final key in identityKeys) { + if (tags.containsKey(key)) { + final value = tags.remove(key)!; + tags['$lifecycleStatus:$key'] = value; + } + } + } + // Filter out any tags that are still empty after refinement // Empty tags in profiles are fine for refinement UI, but shouldn't be submitted to OSM tags.removeWhere((key, value) => value.trim().isEmpty); - + return tags; } Map toJson() => { - 'lat': coord.latitude, - 'lon': coord.longitude, - 'dir': direction, - 'profile': profile?.toJson(), - 'operatorProfile': operatorProfile?.toJson(), - 'refinedTags': refinedTags, - 'additionalExistingTags': additionalExistingTags, - 'changesetComment': changesetComment, - 'uploadMode': uploadMode.index, - 'operation': operation.index, - 'originalNodeId': originalNodeId, - 'submittedNodeId': submittedNodeId, - 'tempNodeId': tempNodeId, - 'attempts': attempts, - 'error': error, - 'errorMessage': errorMessage, - 'completing': completing, - 'uploadState': uploadState.index, - 'changesetId': changesetId, - 'nodeOperationCompletedAt': nodeOperationCompletedAt?.millisecondsSinceEpoch, - 'changesetCloseAttempts': changesetCloseAttempts, - 'lastChangesetCloseAttemptAt': lastChangesetCloseAttemptAt?.millisecondsSinceEpoch, - 'nodeSubmissionAttempts': nodeSubmissionAttempts, - 'lastNodeSubmissionAttemptAt': lastNodeSubmissionAttemptAt?.millisecondsSinceEpoch, - }; + 'lat': coord.latitude, + 'lon': coord.longitude, + 'dir': direction, + 'profile': profile?.toJson(), + 'operatorProfile': operatorProfile?.toJson(), + 'refinedTags': refinedTags, + 'additionalExistingTags': additionalExistingTags, + 'lifecycleStatus': lifecycleStatus, + 'changesetComment': changesetComment, + 'uploadMode': uploadMode.index, + 'operation': operation.index, + 'originalNodeId': originalNodeId, + 'submittedNodeId': submittedNodeId, + 'tempNodeId': tempNodeId, + 'attempts': attempts, + 'error': error, + 'errorMessage': errorMessage, + 'completing': completing, + 'uploadState': uploadState.index, + 'changesetId': changesetId, + 'nodeOperationCompletedAt': + nodeOperationCompletedAt?.millisecondsSinceEpoch, + 'changesetCloseAttempts': changesetCloseAttempts, + 'lastChangesetCloseAttemptAt': + lastChangesetCloseAttemptAt?.millisecondsSinceEpoch, + 'nodeSubmissionAttempts': nodeSubmissionAttempts, + 'lastNodeSubmissionAttemptAt': + lastNodeSubmissionAttemptAt?.millisecondsSinceEpoch, + }; factory PendingUpload.fromJson(Map j) => PendingUpload( - coord: LatLng(j['lat'], j['lon']), - direction: j['dir'], - profile: j['profile'] is Map - ? NodeProfile.fromJson(j['profile']) - : null, // Profile is optional for deletions - operatorProfile: j['operatorProfile'] != null - ? OperatorProfile.fromJson(j['operatorProfile']) - : null, - refinedTags: j['refinedTags'] != null - ? Map.from(j['refinedTags']) - : {}, // Default empty map for legacy entries - additionalExistingTags: j['additionalExistingTags'] != null - ? Map.from(j['additionalExistingTags']) - : {}, // 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 - operation: j['operation'] != null - ? UploadOperation.values[j['operation']] - : (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create), // Legacy compatibility - originalNodeId: j['originalNodeId'], - submittedNodeId: j['submittedNodeId'], - tempNodeId: j['tempNodeId'], - attempts: j['attempts'] ?? 0, - error: j['error'] ?? false, - errorMessage: j['errorMessage'], // Can be null for legacy entries - completing: j['completing'] ?? false, // Default to false for legacy entries - uploadState: j['uploadState'] != null - ? UploadState.values[j['uploadState']] - : _migrateFromLegacyFields(j), // Migrate from legacy error/completing fields - changesetId: j['changesetId'], - nodeOperationCompletedAt: j['nodeOperationCompletedAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(j['nodeOperationCompletedAt']) - : null, - changesetCloseAttempts: j['changesetCloseAttempts'] ?? 0, - lastChangesetCloseAttemptAt: j['lastChangesetCloseAttemptAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(j['lastChangesetCloseAttemptAt']) - : null, - nodeSubmissionAttempts: j['nodeSubmissionAttempts'] ?? 0, - lastNodeSubmissionAttemptAt: j['lastNodeSubmissionAttemptAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(j['lastNodeSubmissionAttemptAt']) - : null, - ); + coord: LatLng(j['lat'], j['lon']), + direction: j['dir'], + profile: j['profile'] is Map + ? NodeProfile.fromJson(j['profile']) + : null, // Profile is optional for deletions + operatorProfile: j['operatorProfile'] != null + ? OperatorProfile.fromJson(j['operatorProfile']) + : null, + refinedTags: j['refinedTags'] != null + ? Map.from(j['refinedTags']) + : {}, // Default empty map for legacy entries + additionalExistingTags: j['additionalExistingTags'] != null + ? Map.from(j['additionalExistingTags']) + : {}, // Default empty map for legacy entries + lifecycleStatus: + j['lifecycleStatus'] ?? 'active', // Default 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 + operation: j['operation'] != null + ? UploadOperation.values[j['operation']] + : (j['originalNodeId'] != null + ? UploadOperation.modify + : UploadOperation.create), // Legacy compatibility + originalNodeId: j['originalNodeId'], + submittedNodeId: j['submittedNodeId'], + tempNodeId: j['tempNodeId'], + attempts: j['attempts'] ?? 0, + error: j['error'] ?? false, + errorMessage: j['errorMessage'], // Can be null for legacy entries + completing: j['completing'] ?? false, // Default to false for legacy entries + uploadState: j['uploadState'] != null + ? UploadState.values[j['uploadState']] + : _migrateFromLegacyFields( + j, + ), // Migrate from legacy error/completing fields + changesetId: j['changesetId'], + nodeOperationCompletedAt: j['nodeOperationCompletedAt'] != null + ? DateTime.fromMillisecondsSinceEpoch(j['nodeOperationCompletedAt']) + : null, + changesetCloseAttempts: j['changesetCloseAttempts'] ?? 0, + lastChangesetCloseAttemptAt: j['lastChangesetCloseAttemptAt'] != null + ? DateTime.fromMillisecondsSinceEpoch(j['lastChangesetCloseAttemptAt']) + : null, + nodeSubmissionAttempts: j['nodeSubmissionAttempts'] ?? 0, + lastNodeSubmissionAttemptAt: j['lastNodeSubmissionAttemptAt'] != null + ? DateTime.fromMillisecondsSinceEpoch(j['lastNodeSubmissionAttemptAt']) + : null, + ); // Helper to migrate legacy queue items to new state system static UploadState _migrateFromLegacyFields(Map j) { final error = j['error'] ?? false; final completing = j['completing'] ?? false; - + if (completing) return UploadState.complete; if (error) return UploadState.error; return UploadState.pending; @@ -360,10 +426,12 @@ class PendingUpload { static String _generateLegacyComment(Map j) { final operation = j['operation'] != null ? UploadOperation.values[j['operation']] - : (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create); - + : (j['originalNodeId'] != null + ? UploadOperation.modify + : UploadOperation.create); + final profileName = j['profile']?['name'] ?? 'surveillance'; - + switch (operation) { case UploadOperation.create: return 'Add $profileName surveillance node'; @@ -376,4 +444,3 @@ class PendingUpload { } } } - diff --git a/lib/screens/advanced_settings_screen.dart b/lib/screens/advanced_settings_screen.dart index 275f11ad..29b15307 100644 --- a/lib/screens/advanced_settings_screen.dart +++ b/lib/screens/advanced_settings_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'settings/sections/max_nodes_section.dart'; import 'settings/sections/proximity_alerts_section.dart'; +import 'settings/sections/staleness_indicator_section.dart'; import 'settings/sections/suspected_locations_section.dart'; import 'settings/sections/tile_provider_section.dart'; import 'settings/sections/keep_screen_awake_section.dart'; @@ -12,18 +13,16 @@ class AdvancedSettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { final locService = LocalizationService.instance; - + return AnimatedBuilder( animation: LocalizationService.instance, builder: (context, child) => Scaffold( - appBar: AppBar( - title: Text(locService.t('settings.advancedSettings')), - ), + appBar: AppBar(title: Text(locService.t('settings.advancedSettings'))), body: ListView( padding: EdgeInsets.fromLTRB( - 16, - 16, - 16, + 16, + 16, + 16, 16 + MediaQuery.of(context).padding.bottom, ), children: const [ @@ -31,6 +30,8 @@ class AdvancedSettingsScreen extends StatelessWidget { Divider(), ProximityAlertsSection(), Divider(), + StalenessIndicatorSection(), + Divider(), SuspectedLocationsSection(), Divider(), KeepScreenAwakeSection(), @@ -43,4 +44,4 @@ class AdvancedSettingsScreen extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/settings/sections/staleness_indicator_section.dart b/lib/screens/settings/sections/staleness_indicator_section.dart new file mode 100644 index 00000000..21add8a7 --- /dev/null +++ b/lib/screens/settings/sections/staleness_indicator_section.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../app_state.dart'; +import '../../../../../../../services/localization_service.dart'; + +/// Settings section for the stale-node visual indicator. +/// Threshold is a discrete set of days, not an arbitrary number, +/// per the original spec. +class StalenessIndicatorSection extends StatelessWidget { + const StalenessIndicatorSection({super.key}); + + static const List _thresholdOptions = [30, 60, 180, 365]; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, appState, child) { + final locService = LocalizationService.instance; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + locService.t('settings.dataFreshness'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + + // Enable/disable toggle + SwitchListTile( + title: Text(locService.t('staleness.showIndicator')), + subtitle: Text( + locService.t('staleness.showIndicatorExplanation'), + style: const TextStyle(fontSize: 12), + ), + value: appState.stalenessIndicatorEnabled, + onChanged: (enabled) { + appState.setStalenessIndicatorEnabled(enabled); + }, + contentPadding: EdgeInsets.zero, + ), + + // Threshold picker (only shown when enabled) + if (appState.stalenessIndicatorEnabled) ...[ + const SizedBox(height: 12), + Row( + children: [ + Text(locService.t('staleness.threshold')), + const SizedBox(width: 12), + DropdownButton( + value: + _thresholdOptions.contains( + appState.stalenessThresholdDays, + ) + ? appState.stalenessThresholdDays + : _thresholdOptions.last, + items: _thresholdOptions + .map( + (days) => DropdownMenuItem( + value: days, + child: Text( + locService.t( + 'staleness.daysCount', + params: [days.toString()], + ), + ), + ), + ) + .toList(), + onChanged: (days) { + if (days != null) { + appState.setStalenessThresholdDays(days); + } + }, + ), + ], + ), + const SizedBox(height: 8), + Text( + locService.t('staleness.explanation'), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), + ), + ), + ], + ], + ); + }, + ); + } +} diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index 6925295f..018f3d09 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -11,14 +11,17 @@ class AddNodeSession { NodeProfile? profile; OperatorProfile? operatorProfile; LatLng? target; - 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 - Map additionalExistingTags; // For consistency (always empty for new nodes) - String changesetComment; // User-editable changeset comment - + 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 + Map + additionalExistingTags; // For consistency (always empty for new nodes) + String changesetComment; // User-editable changeset comment + AddNodeSession({ - this.profile, + this.profile, double initialDirection = 0, this.operatorProfile, this.target, @@ -28,12 +31,14 @@ class AddNodeSession { }) : directions = [initialDirection], currentDirectionIndex = 0, refinedTags = refinedTags ?? {}, - additionalExistingTags = additionalExistingTags ?? {}, // Always empty for new nodes + additionalExistingTags = + additionalExistingTags ?? {}, // Always empty for new nodes changesetComment = changesetComment ?? ''; - + // Slider always shows the current direction being edited - double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0 - ? directions[currentDirectionIndex] + double get directionDegrees => + directions.isNotEmpty && currentDirectionIndex >= 0 + ? directions[currentDirectionIndex] : 0.0; set directionDegrees(double value) { if (directions.isNotEmpty && currentDirectionIndex >= 0) { @@ -49,13 +54,17 @@ class EditNodeSession { NodeProfile? profile; OperatorProfile? operatorProfile; LatLng target; // Current position (can be dragged) - List directions; // All directions [90, 180, 270] - int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°) + List directions; // All directions [90, 180, 270] + 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 - Map additionalExistingTags; // Tags that exist on node but not in profile - String changesetComment; // User-editable changeset comment - + Map + refinedTags; // User-selected values for empty profile tags + Map + additionalExistingTags; // Tags that exist on node but not in profile + String lifecycleStatus; // Lifecycle prefix status: 'active', 'removed', etc. + String changesetComment; // User-editable changeset comment + EditNodeSession({ required this.originalNode, required this.originalHadDirections, @@ -66,16 +75,19 @@ class EditNodeSession { this.extractFromWay = false, Map? refinedTags, Map? additionalExistingTags, + String? lifecycleStatus, String? changesetComment, }) : directions = [initialDirection], currentDirectionIndex = 0, refinedTags = refinedTags ?? {}, additionalExistingTags = additionalExistingTags ?? {}, + lifecycleStatus = lifecycleStatus ?? 'active', changesetComment = changesetComment ?? ''; - + // Slider always shows the current direction being edited - double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0 - ? directions[currentDirectionIndex] + double get directionDegrees => + directions.isNotEmpty && currentDirectionIndex >= 0 + ? directions[currentDirectionIndex] : 0.0; set directionDegrees(double value) { if (directions.isNotEmpty && currentDirectionIndex >= 0) { @@ -96,31 +108,48 @@ class SessionState extends ChangeNotifier { void startAddSession(List enabledProfiles) { // Start with no profile selected - force user to choose _session = AddNodeSession( - changesetComment: 'Add surveillance node', // Default comment, will be updated when profile is selected + changesetComment: + 'Add surveillance node', // Default comment, will be updated when profile is selected ); _editSession = null; // Clear any edit session notifyListeners(); } - void startEditSession(OsmNode node, List enabledProfiles, List operatorProfiles) { + void startEditSession( + OsmNode node, + List enabledProfiles, + List operatorProfiles, + ) { // Always create and pre-select the temporary "existing tags" profile (now empty) final existingTagsProfile = NodeProfile.createExistingTagsProfile(node); - + // Detect and store operator profile (persists across profile changes) - _detectedOperatorProfile = OperatorProfile.createExistingOperatorProfile(node, operatorProfiles); - + _detectedOperatorProfile = OperatorProfile.createExistingOperatorProfile( + node, + operatorProfiles, + ); + // Initialize edit session with all existing directions, or empty list if none - final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : []; - final initialDirection = existingDirections.isNotEmpty ? existingDirections.first : 0.0; + final existingDirections = node.directionDeg.isNotEmpty + ? node.directionDeg + : []; + final initialDirection = existingDirections.isNotEmpty + ? existingDirections.first + : 0.0; final originalHadDirections = existingDirections.isNotEmpty; - - // Since the "existing tags" profile is now empty, all existing node tags + + // Since the "existing tags" profile is now empty, all existing node tags // (minus special ones) should go into additionalExistingTags - final initialAdditionalTags = _calculateAdditionalExistingTags(existingTagsProfile, node); - + final initialAdditionalTags = _calculateAdditionalExistingTags( + existingTagsProfile, + node, + ); + // Auto-populate refined tags (empty profile means no refined tags initially) final initialRefinedTags = _calculateRefinedTags(existingTagsProfile, node); - + + final initialLifecycleStatus = _detectLifecycleStatus(node); + _editSession = EditNodeSession( originalNode: node, originalHadDirections: originalHadDirections, @@ -130,57 +159,67 @@ class SessionState extends ChangeNotifier { target: node.coord, additionalExistingTags: initialAdditionalTags, refinedTags: initialRefinedTags, - changesetComment: 'Update a surveillance node', // Default comment for existing tags profile + lifecycleStatus: initialLifecycleStatus, + changesetComment: + 'Update a surveillance node', // Default comment for existing tags profile ); - + // Replace the default single direction with all existing directions (or empty list) _editSession!.directions = List.from(existingDirections); - _editSession!.currentDirectionIndex = existingDirections.isNotEmpty ? 0 : -1; // -1 indicates no directions + _editSession!.currentDirectionIndex = existingDirections.isNotEmpty + ? 0 + : -1; // -1 indicates no directions _session = null; // Clear any add session notifyListeners(); } /// Calculate additional existing tags for a given profile change - Map _calculateAdditionalExistingTags(NodeProfile? newProfile, OsmNode originalNode) { + Map _calculateAdditionalExistingTags( + NodeProfile? newProfile, + OsmNode originalNode, + ) { final additionalTags = {}; - + // Skip if no profile if (newProfile == null) { return additionalTags; } - + // Get tags from the original node that are not in the selected profile final profileTagKeys = newProfile.tags.keys.toSet(); final originalTags = originalNode.tags; - + for (final entry in originalTags.entries) { final key = entry.key; final value = entry.value; - + // Skip tags that are handled elsewhere if (_shouldSkipTag(key)) continue; - + // Skip tags that exist in the selected profile if (profileTagKeys.contains(key)) continue; - + // Include this tag as an additional existing tag additionalTags[key] = value; } - + return additionalTags; } - + /// Auto-populate refined tags with existing values from the original node - Map _calculateRefinedTags(NodeProfile? profile, OsmNode originalNode) { + Map _calculateRefinedTags( + NodeProfile? profile, + OsmNode originalNode, + ) { final refinedTags = {}; - + if (profile == null) return refinedTags; - + // For each empty-value tag in the profile, check if original node has a value for (final entry in profile.tags.entries) { final tagKey = entry.key; final profileValue = entry.value; - + // Only auto-populate if profile tag value is empty if (profileValue.trim().isEmpty) { final existingValue = originalNode.tags[tagKey]; @@ -189,21 +228,43 @@ class SessionState extends ChangeNotifier { } } } - + return refinedTags; } - + + String _detectLifecycleStatus(OsmNode node) { + const identityKeys = ['man_made', 'surveillance:type']; + for (final tagKey in node.tags.keys) { + for (final identityKey in identityKeys) { + if (tagKey.endsWith(':$identityKey') && tagKey != identityKey) { + final prefix = tagKey.substring( + 0, + tagKey.length - identityKey.length - 1, + ); + return prefix; + } + } + } + return 'active'; + } + /// Check if a tag should be skipped from additional existing tags bool _shouldSkipTag(String key) { // Skip direction tags (handled separately) if (key == 'direction' || key == 'camera:direction') return true; - + // Skip operator tags (handled by operator profile) if (key == 'operator' || key.startsWith('operator:')) return true; - + // Skip internal cache tags if (key.startsWith('_')) return true; - + + // Skip lifecycle-prefixed identity tags — handled via lifecycleStatus + const identityKeys = ['man_made', 'surveillance:type']; + for (final identityKey in identityKeys) { + if (key.endsWith(':$identityKey') && key != identityKey) return true; + } + return false; } @@ -247,7 +308,9 @@ class SessionState extends ChangeNotifier { dirty = true; } if (additionalExistingTags != null) { - _session!.additionalExistingTags = Map.from(additionalExistingTags); + _session!.additionalExistingTags = Map.from( + additionalExistingTags, + ); dirty = true; } if (changesetComment != null) { @@ -263,6 +326,7 @@ class SessionState extends ChangeNotifier { OperatorProfile? operatorProfile, LatLng? target, bool? extractFromWay, + String? lifecycleStatus, Map? refinedTags, Map? additionalExistingTags, String? changesetComment, @@ -273,47 +337,57 @@ class SessionState extends ChangeNotifier { bool dirty = false; bool snapBackRequired = false; LatLng? snapBackTarget; - - if (directionDeg != null && directionDeg != _editSession!.directionDegrees) { + + if (directionDeg != null && + directionDeg != _editSession!.directionDegrees) { _editSession!.directionDegrees = directionDeg; dirty = true; } if (profile != null && profile != _editSession!.profile) { final oldProfile = _editSession!.profile; _editSession!.profile = profile; - + // Handle direction requirements when profile changes _handleDirectionRequirementsOnProfileChange(oldProfile, profile); - + // When profile changes and operator profile not being explicitly updated, // restore the detected operator profile (if any) if (!updateOperatorProfile && _detectedOperatorProfile != null) { _editSession!.operatorProfile = _detectedOperatorProfile; } - + // Calculate additional existing tags for non-existing-tags profiles // Only do this if additionalExistingTags wasn't explicitly provided if (additionalExistingTags == null) { - _editSession!.additionalExistingTags = _calculateAdditionalExistingTags(profile, _editSession!.originalNode); + _editSession!.additionalExistingTags = _calculateAdditionalExistingTags( + profile, + _editSession!.originalNode, + ); } - + // Auto-populate refined tags with existing values for empty profile tags // Only do this if refinedTags wasn't explicitly provided if (refinedTags == null) { - _editSession!.refinedTags = _calculateRefinedTags(profile, _editSession!.originalNode); + _editSession!.refinedTags = _calculateRefinedTags( + profile, + _editSession!.originalNode, + ); } - + // Regenerate changeset comment when profile changes - final operation = _editSession!.extractFromWay ? UploadOperation.extract : UploadOperation.modify; + final operation = _editSession!.extractFromWay + ? UploadOperation.extract + : UploadOperation.modify; _editSession!.changesetComment = _generateDefaultChangesetComment( profile: profile, operation: operation, ); - + dirty = true; } // Only update operator profile when explicitly requested - if (updateOperatorProfile && operatorProfile != _editSession!.operatorProfile) { + if (updateOperatorProfile && + operatorProfile != _editSession!.operatorProfile) { _editSession!.operatorProfile = operatorProfile; // This can be null dirty = true; } @@ -321,7 +395,8 @@ class SessionState extends ChangeNotifier { _editSession!.target = target; dirty = true; } - if (extractFromWay != null && extractFromWay != _editSession!.extractFromWay) { + if (extractFromWay != null && + extractFromWay != _editSession!.extractFromWay) { _editSession!.extractFromWay = extractFromWay; // When extract is unchecked, snap back to original location if (!extractFromWay) { @@ -331,21 +406,30 @@ class SessionState extends ChangeNotifier { } dirty = true; } + + if (lifecycleStatus != null && + lifecycleStatus != _editSession!.lifecycleStatus) { + _editSession!.lifecycleStatus = lifecycleStatus; + dirty = true; + } + if (refinedTags != null) { _editSession!.refinedTags = Map.from(refinedTags); dirty = true; } if (additionalExistingTags != null) { - _editSession!.additionalExistingTags = Map.from(additionalExistingTags); + _editSession!.additionalExistingTags = Map.from( + additionalExistingTags, + ); dirty = true; } if (changesetComment != null) { _editSession!.changesetComment = changesetComment; dirty = true; } - + if (dirty) notifyListeners(); - + // Store snap back info for map view to pick up if (snapBackRequired && snapBackTarget != null) { _pendingSnapBack = snapBackTarget; @@ -387,13 +471,15 @@ class SessionState extends ChangeNotifier { } else if (_editSession != null && _editSession!.directions.isNotEmpty) { // For edit sessions, use minimum calculation final minDirections = _getMinimumDirections(); - + if (_editSession!.directions.length > minDirections) { _editSession!.directions.removeAt(_editSession!.currentDirectionIndex); if (_editSession!.directions.isEmpty) { _editSession!.currentDirectionIndex = -1; // No directions - } else if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) { - _editSession!.currentDirectionIndex = _editSession!.directions.length - 1; + } else if (_editSession!.currentDirectionIndex >= + _editSession!.directions.length) { + _editSession!.currentDirectionIndex = + _editSession!.directions.length - 1; } notifyListeners(); } @@ -403,16 +489,19 @@ class SessionState extends ChangeNotifier { // Cycle to next direction void cycleDirection() { if (_session != null && _session!.directions.length > 1) { - _session!.currentDirectionIndex = (_session!.currentDirectionIndex + 1) % _session!.directions.length; + _session!.currentDirectionIndex = + (_session!.currentDirectionIndex + 1) % _session!.directions.length; notifyListeners(); - } else if (_editSession != null && _editSession!.directions.length > 1 && _editSession!.currentDirectionIndex >= 0) { - _editSession!.currentDirectionIndex = (_editSession!.currentDirectionIndex + 1) % _editSession!.directions.length; + } else if (_editSession != null && + _editSession!.directions.length > 1 && + _editSession!.currentDirectionIndex >= 0) { + _editSession!.currentDirectionIndex = + (_editSession!.currentDirectionIndex + 1) % + _editSession!.directions.length; notifyListeners(); } } - - void cancelSession() { _session = null; notifyListeners(); @@ -426,7 +515,7 @@ class SessionState extends ChangeNotifier { AddNodeSession? commitSession() { if (_session?.target == null || _session?.profile == null) return null; - + final session = _session!; _session = null; notifyListeners(); @@ -435,7 +524,7 @@ class SessionState extends ChangeNotifier { EditNodeSession? commitEditSession() { if (_editSession?.profile == null) return null; - + final session = _editSession!; _editSession = null; _detectedOperatorProfile = null; @@ -446,7 +535,7 @@ class SessionState extends ChangeNotifier { /// Get the minimum number of directions required for current session state int _getMinimumDirections() { if (_editSession == null) return 1; - + // Minimum = 0 only if original node had no directions // Allow preserving the original state (directionless nodes can stay directionless) return _editSession!.originalHadDirections ? 1 : 0; @@ -459,11 +548,14 @@ class SessionState extends ChangeNotifier { } /// Handle direction requirements when profile changes in edit session - void _handleDirectionRequirementsOnProfileChange(NodeProfile? oldProfile, NodeProfile newProfile) { + void _handleDirectionRequirementsOnProfileChange( + NodeProfile? oldProfile, + NodeProfile newProfile, + ) { if (_editSession == null) return; - + final minimum = _getMinimumDirections(); - + // Ensure we meet the minimum (add direction if needed) if (_editSession!.directions.length < minimum) { _editSession!.directions = [0.0]; @@ -478,19 +570,21 @@ class SessionState extends ChangeNotifier { required UploadOperation operation, }) { // Handle temp profiles with brackets by using "a" - final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true + 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'; + 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/settings_state.dart b/lib/state/settings_state.dart index 9fa1268b..b9e1aab9 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -13,14 +13,14 @@ enum UploadMode { production, sandbox, simulate } // Enum for follow-me mode (moved from HomeScreen to centralized state) enum FollowMeMode { - off, // No following - follow, // Follow position, preserve current rotation + off, // No following + follow, // Follow position, preserve current rotation rotating, // Follow position and rotation based on heading } // Enum for distance units enum DistanceUnit { - metric, // kilometers, meters + metric, // kilometers, meters imperial, // miles, feet } @@ -32,12 +32,21 @@ class SettingsState extends ChangeNotifier { static const String _selectedTileTypePrefsKey = 'selected_tile_type'; static const String _legacyTestModePrefsKey = 'test_mode'; static const String _followMeModePrefsKey = 'follow_me_mode'; - static const String _proximityAlertsEnabledPrefsKey = 'proximity_alerts_enabled'; - static const String _proximityAlertDistancePrefsKey = 'proximity_alert_distance'; - static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled'; - static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance'; + static const String _proximityAlertsEnabledPrefsKey = + 'proximity_alerts_enabled'; + static const String _proximityAlertDistancePrefsKey = + 'proximity_alert_distance'; + static const String _stalenessIndicatorEnabledPrefsKey = + 'staleness_indicator_enabled'; + static const String _stalenessThresholdDaysPrefsKey = + 'staleness_threshold_days'; + static const String _networkStatusIndicatorEnabledPrefsKey = + 'network_status_indicator_enabled'; + static const String _suspectedLocationMinDistancePrefsKey = + 'suspected_location_min_distance'; static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing'; - static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance'; + static const String _navigationAvoidanceDistancePrefsKey = + 'navigation_avoidance_distance'; static const String _distanceUnitPrefsKey = 'distance_unit'; static const String _keepScreenAwakePrefsKey = 'keep_screen_awake'; @@ -46,10 +55,14 @@ class SettingsState extends ChangeNotifier { bool _keepScreenAwake = false; int _maxNodes = kDefaultMaxNodes; // Default must account for missing secrets (preview builds) even before init() runs - UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production; + UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) + ? UploadMode.simulate + : UploadMode.production; FollowMeMode _followMeMode = FollowMeMode.follow; bool _proximityAlertsEnabled = false; int _proximityAlertDistance = kProximityAlertDefaultDistance; + bool _stalenessIndicatorEnabled = false; + int _stalenessThresholdDays = 60; // days bool _networkStatusIndicatorEnabled = true; int _suspectedLocationMinDistance = 100; // meters List _tileProviders = []; @@ -65,6 +78,8 @@ class SettingsState extends ChangeNotifier { FollowMeMode get followMeMode => _followMeMode; bool get proximityAlertsEnabled => _proximityAlertsEnabled; int get proximityAlertDistance => _proximityAlertDistance; + bool get stalenessIndicatorEnabled => _stalenessIndicatorEnabled; + int get stalenessThresholdDays => _stalenessThresholdDays; bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled; int get suspectedLocationMinDistance => _suspectedLocationMinDistance; bool get keepScreenAwake => _keepScreenAwake; @@ -72,7 +87,7 @@ class SettingsState extends ChangeNotifier { String get selectedTileTypeId => _selectedTileTypeId; int get navigationAvoidanceDistance => _navigationAvoidanceDistance; DistanceUnit get distanceUnit => _distanceUnit; - + /// Get the currently selected tile type TileType? get selectedTileType { for (final provider in _tileProviders) { @@ -84,7 +99,7 @@ class SettingsState extends ChangeNotifier { } return null; } - + /// Get the provider that contains the selected tile type TileProvider? get selectedTileProvider { for (final provider in _tileProviders) { @@ -94,7 +109,7 @@ class SettingsState extends ChangeNotifier { } return null; } - + /// Get all available tile types from all providers List get allAvailableTileTypes { final types = []; @@ -104,26 +119,26 @@ class SettingsState extends ChangeNotifier { return types; } - - // Initialize settings from preferences Future init() async { final prefs = await SharedPreferences.getInstance(); - + // Load offline mode _offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false; - + // Load queue processing setting - _pauseQueueProcessing = prefs.getBool(_pauseQueueProcessingPrefsKey) ?? false; - + _pauseQueueProcessing = + prefs.getBool(_pauseQueueProcessingPrefsKey) ?? false; + // Load max nodes _maxNodes = prefs.getInt(_maxNodesPrefsKey) ?? kDefaultMaxNodes; // Load navigation avoidance distance if (prefs.containsKey(_navigationAvoidanceDistancePrefsKey)) { - _navigationAvoidanceDistance = prefs.getInt(_navigationAvoidanceDistancePrefsKey) ?? 250; + _navigationAvoidanceDistance = + prefs.getInt(_navigationAvoidanceDistancePrefsKey) ?? 250; } - + // Load distance unit if (prefs.containsKey(_distanceUnitPrefsKey)) { final unitIndex = prefs.getInt(_distanceUnitPrefsKey) ?? 0; @@ -131,20 +146,31 @@ class SettingsState extends ChangeNotifier { _distanceUnit = DistanceUnit.values[unitIndex]; } } - + // Load proximity alerts settings - _proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false; - _proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance; - + _proximityAlertsEnabled = + prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false; + _proximityAlertDistance = + prefs.getInt(_proximityAlertDistancePrefsKey) ?? + kProximityAlertDefaultDistance; + + // Load staleness indicator settings + _stalenessIndicatorEnabled = + prefs.getBool(_stalenessIndicatorEnabledPrefsKey) ?? false; + _stalenessThresholdDays = + prefs.getInt(_stalenessThresholdDaysPrefsKey) ?? 60; + // Load network status indicator setting - _networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? true; - + _networkStatusIndicatorEnabled = + prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? true; + // Load suspected location minimum distance - _suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100; + _suspectedLocationMinDistance = + prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100; // Load keep screen awake setting _keepScreenAwake = prefs.getBool(_keepScreenAwakePrefsKey) ?? false; - + // Load upload mode (including migration from old test_mode bool) if (prefs.containsKey(_uploadModePrefsKey)) { final idx = prefs.getInt(_uploadModePrefsKey) ?? 0; @@ -158,25 +184,31 @@ class SettingsState extends ChangeNotifier { await prefs.remove(_legacyTestModePrefsKey); await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); } - + // Override persisted upload mode when the current build configuration // doesn't support it. This handles two cases: // 1. Preview/PR builds without OAuth secrets — force simulate to avoid crashes // 2. Production builds — force production (prefs may have sandbox/simulate // from a previous dev build on the same device) if (!kHasOsmSecrets && _uploadMode != UploadMode.simulate) { - debugPrint('SettingsState: No OSM secrets available, forcing simulate mode'); + debugPrint( + 'SettingsState: No OSM secrets available, forcing simulate mode', + ); _uploadMode = UploadMode.simulate; await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); - } else if (kHasOsmSecrets && !kEnableDevelopmentModes && _uploadMode != UploadMode.production) { - debugPrint('SettingsState: Development modes disabled, forcing production mode'); + } else if (kHasOsmSecrets && + !kEnableDevelopmentModes && + _uploadMode != UploadMode.production) { + debugPrint( + 'SettingsState: Development modes disabled, forcing production mode', + ); _uploadMode = UploadMode.production; await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); } - + // Load tile providers (default to built-in providers if none saved) await _loadTileProviders(prefs); - + // Load follow-me mode if (prefs.containsKey(_followMeModePrefsKey)) { final modeIndex = prefs.getInt(_followMeModePrefsKey) ?? 0; @@ -184,7 +216,7 @@ class SettingsState extends ChangeNotifier { _followMeMode = FollowMeMode.values[modeIndex]; } } - + // Load selected tile type (default to first available) _selectedTileTypeId = prefs.getString(_selectedTileTypePrefsKey) ?? ''; if (_selectedTileTypeId.isEmpty || selectedTileType == null) { @@ -205,7 +237,7 @@ class SettingsState extends ChangeNotifier { _tileProviders = providersList .map((json) => TileProvider.fromJson(json)) .toList(); - + // Migration: Add any missing built-in providers await _addMissingBuiltinProviders(prefs); } @@ -226,15 +258,17 @@ class SettingsState extends ChangeNotifier { final defaultProviders = DefaultTileProviders.createDefaults(); final existingProviderIds = _tileProviders.map((p) => p.id).toSet(); bool hasUpdates = false; - + for (final defaultProvider in defaultProviders) { if (!existingProviderIds.contains(defaultProvider.id)) { _tileProviders.add(defaultProvider); hasUpdates = true; - debugPrint('SettingsState: Added missing built-in provider: ${defaultProvider.name}'); + debugPrint( + 'SettingsState: Added missing built-in provider: ${defaultProvider.name}', + ); } } - + if (hasUpdates) { await _saveTileProviders(prefs); } @@ -279,7 +313,7 @@ class SettingsState extends ChangeNotifier { // true (gated in osm_account_screen.dart), so no secrets/dev-mode guards // are needed here. The init() method handles forcing the correct mode on // startup for production builds and builds without OAuth secrets. - + _uploadMode = mode; final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_uploadModePrefsKey, mode.index); @@ -304,7 +338,7 @@ class SettingsState extends ChangeNotifier { } else { _tileProviders.add(provider); } - + final prefs = await SharedPreferences.getInstance(); await _saveTileProviders(prefs); notifyListeners(); @@ -314,25 +348,31 @@ class SettingsState extends ChangeNotifier { Future deleteTileProvider(String providerId) async { // Don't allow deleting all providers if (_tileProviders.length <= 1) return; - - final providerToDelete = _tileProviders.firstWhereOrNull((p) => p.id == providerId); + + final providerToDelete = _tileProviders.firstWhereOrNull( + (p) => p.id == providerId, + ); if (providerToDelete == null) return; - + // If selected tile type belongs to this provider, switch to another - if (providerToDelete.tileTypes.any((type) => type.id == _selectedTileTypeId)) { + if (providerToDelete.tileTypes.any( + (type) => type.id == _selectedTileTypeId, + )) { // Find first available tile type from remaining providers - final remainingProviders = _tileProviders.where((p) => p.id != providerId).toList(); + final remainingProviders = _tileProviders + .where((p) => p.id != providerId) + .toList(); final firstAvailable = remainingProviders .expand((p) => p.availableTileTypes) .firstOrNull; - + if (firstAvailable != null) { _selectedTileTypeId = firstAvailable.id; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_selectedTileTypePrefsKey, _selectedTileTypeId); } } - + _tileProviders.removeWhere((p) => p.id == providerId); final prefs = await SharedPreferences.getInstance(); await _saveTileProviders(prefs); @@ -343,7 +383,7 @@ class SettingsState extends ChangeNotifier { Future clearTileProviderCaches(String providerId) async { final provider = _tileProviders.firstWhereOrNull((p) => p.id == providerId); if (provider == null) return; - + // Clear cache for each tile type in this provider for (final tileType in provider.tileTypes) { await ProviderTileCacheManager.deleteCache(providerId, tileType.id); @@ -354,13 +394,13 @@ class SettingsState extends ChangeNotifier { Future setFollowMeMode(FollowMeMode mode) async { if (_followMeMode != mode) { _followMeMode = mode; - + final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_followMeModePrefsKey, mode.index); notifyListeners(); } } - + /// Set proximity alerts enabled/disabled Future setProximityAlertsEnabled(bool enabled) async { if (_proximityAlertsEnabled != enabled) { @@ -373,8 +413,10 @@ class SettingsState extends ChangeNotifier { /// Set proximity alert distance in meters Future setProximityAlertDistance(int distance) async { - if (distance < kProximityAlertMinDistance) distance = kProximityAlertMinDistance; - if (distance > kProximityAlertMaxDistance) distance = kProximityAlertMaxDistance; + if (distance < kProximityAlertMinDistance) + distance = kProximityAlertMinDistance; + if (distance > kProximityAlertMaxDistance) + distance = kProximityAlertMaxDistance; if (_proximityAlertDistance != distance) { _proximityAlertDistance = distance; final prefs = await SharedPreferences.getInstance(); @@ -383,6 +425,26 @@ class SettingsState extends ChangeNotifier { } } + /// Set staleness indicator enabled/disabled + Future setStalenessIndicatorEnabled(bool enabled) async { + if (_stalenessIndicatorEnabled != enabled) { + _stalenessIndicatorEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_stalenessIndicatorEnabledPrefsKey, enabled); + notifyListeners(); + } + } + + /// Set staleness threshold in days + Future setStalenessThresholdDays(int days) async { + if (_stalenessThresholdDays != days) { + _stalenessThresholdDays = days; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_stalenessThresholdDaysPrefsKey, days); + notifyListeners(); + } + } + /// Set network status indicator enabled/disabled Future setNetworkStatusIndicatorEnabled(bool enabled) async { if (_networkStatusIndicatorEnabled != enabled) { @@ -404,14 +466,14 @@ class SettingsState extends ChangeNotifier { } /// Set keep screen awake enabled/disabled - Future setKeepScreenAwake(bool enabled) async { - if (_keepScreenAwake != enabled) { - _keepScreenAwake = enabled; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_keepScreenAwakePrefsKey, enabled); - notifyListeners(); - } + Future setKeepScreenAwake(bool enabled) async { + if (_keepScreenAwake != enabled) { + _keepScreenAwake = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keepScreenAwakePrefsKey, enabled); + notifyListeners(); } + } /// Set distance for avoidance of nodes during navigation Future setNavigationAvoidanceDistance(int distance) async { @@ -432,5 +494,4 @@ class SettingsState extends ChangeNotifier { notifyListeners(); } } - } diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index bd883a8c..3e6ad3d7 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -35,18 +35,24 @@ class UploadQueueState extends ChangeNotifier { // Repopulate the cache with pending nodes from the queue on startup void _repopulateCacheFromQueue() { - debugPrint('[UploadQueue] Repopulating cache from ${_queue.length} queue items'); + debugPrint( + '[UploadQueue] Repopulating cache from ${_queue.length} queue items', + ); final nodesToAdd = []; - + for (final upload in _queue) { // Skip completed uploads - they should already be in OSM and will be fetched normally if (upload.isComplete) { - debugPrint('[UploadQueue] Skipping completed upload at ${upload.coord}'); + debugPrint( + '[UploadQueue] Skipping completed upload at ${upload.coord}', + ); continue; } - - debugPrint('[UploadQueue] Processing ${upload.operation} upload at ${upload.coord}'); - + + debugPrint( + '[UploadQueue] Processing ${upload.operation} upload at ${upload.coord}', + ); + if (upload.isDeletion) { // For deletions: mark the original node as pending deletion if it exists in cache if (upload.originalNodeId != null) { @@ -54,7 +60,7 @@ class UploadQueueState extends ChangeNotifier { if (existingNode != null) { final deletionTags = Map.from(existingNode.tags); deletionTags['_pending_deletion'] = 'true'; - + final nodeWithDeletionTag = OsmNode( id: upload.originalNodeId!, coord: existingNode.coord, @@ -66,23 +72,29 @@ class UploadQueueState extends ChangeNotifier { } else { // For creates, edits, and extracts: recreate temp node if needed // Generate new temp ID if not already stored (for backward compatibility) - final tempId = upload.tempNodeId ?? -DateTime.now().millisecondsSinceEpoch - _queue.indexOf(upload); - + final tempId = + upload.tempNodeId ?? + -DateTime.now().millisecondsSinceEpoch - _queue.indexOf(upload); + final tags = upload.getCombinedTags(); tags['_pending_upload'] = 'true'; tags['_temp_id'] = tempId.toString(); - + // Store temp ID for future cleanup if not already set upload.tempNodeId ??= tempId; - + if (upload.isEdit) { // For edits: also mark original with _pending_edit if it exists if (upload.originalNodeId != null) { - final existingOriginal = _nodeCache.getNodeById(upload.originalNodeId!); + final existingOriginal = _nodeCache.getNodeById( + upload.originalNodeId!, + ); if (existingOriginal != null) { - final originalTags = Map.from(existingOriginal.tags); + final originalTags = Map.from( + existingOriginal.tags, + ); originalTags['_pending_edit'] = 'true'; - + final originalWithEdit = OsmNode( id: upload.originalNodeId!, coord: existingOriginal.coord, @@ -91,82 +103,141 @@ class UploadQueueState extends ChangeNotifier { nodesToAdd.add(originalWithEdit); } } - + // Add connection line marker tags['_original_node_id'] = upload.originalNodeId.toString(); } else if (upload.operation == UploadOperation.extract) { // For extracts: add connection line marker tags['_original_node_id'] = upload.originalNodeId.toString(); } - - final tempNode = OsmNode( - id: tempId, - coord: upload.coord, - tags: tags, - ); + + final tempNode = OsmNode(id: tempId, coord: upload.coord, tags: tags); nodesToAdd.add(tempNode); } } - + if (nodesToAdd.isNotEmpty) { _nodeCache.addOrUpdate(nodesToAdd); - debugPrint('[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue'); - + debugPrint( + '[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue', + ); + // Save queue if we updated any temp IDs for backward compatibility _saveQueue(); - + // Notify node provider to update the map NodeProviderWithCache.instance.notifyListeners(); } } // Add a completed session to the upload queue - void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) { + void addFromSession( + AddNodeSession session, { + required UploadMode uploadMode, + }) { final upload = PendingUpload( coord: session.target!, - direction: _formatDirectionsForSubmission(session.directions, session.profile), - profile: session.profile!, // Safe to use ! because commitSession() checks for null + direction: _formatDirectionsForSubmission( + session.directions, + session.profile, + ), + profile: session + .profile!, // Safe to use ! because commitSession() checks for null operatorProfile: session.operatorProfile, refinedTags: session.refinedTags, - additionalExistingTags: session.additionalExistingTags, // Always empty for new nodes + additionalExistingTags: + session.additionalExistingTags, // Always empty for new nodes changesetComment: session.changesetComment, uploadMode: uploadMode, operation: UploadOperation.create, ); - + _queue.add(upload); _saveQueue(); - + // 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; final tags = upload.getCombinedTags(); - tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction + tags['_pending_upload'] = + 'true'; // Mark as pending for potential UI distinction tags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal - + // Store the temp ID in the upload for cleanup purposes upload.tempNodeId = tempId; - - final tempNode = OsmNode( - id: tempId, - coord: upload.coord, - tags: tags, - ); - + + final tempNode = OsmNode(id: tempId, coord: upload.coord, tags: tags); + _nodeCache.addOrUpdate([tempNode]); // Notify node provider to update the map NodeProviderWithCache.instance.notifyListeners(); - + + notifyListeners(); + } + + // Add a "verify" upload: resubmits a node with all existing tags unchanged, + // only bumping check_date:surveillance to confirm it's still present. + void addFromVerification(OsmNode node, {required UploadMode uploadMode}) { + // Passthrough profile carrying no identity tags of its own - everything + // comes from additionalExistingTags, so nothing is altered or reformatted + final passthroughProfile = NodeProfile.createExistingTagsProfile( + node, + ).copyWith(requiresDirection: false); + + final upload = PendingUpload( + coord: node.coord, + direction: 0.0, //requiresDirection is false + profile: passthroughProfile, + additionalExistingTags: Map.from(node.tags) + ..removeWhere( + (key, _) => key.startsWith('_'), + ), // strip internal cache markers + changesetComment: 'Verify surveillance node still present', + uploadMode: uploadMode, + operation: UploadOperation.modify, + originalNodeId: node.id, + ); + + _queue.add(upload); + _saveQueue(); + + // Same visual pattern as a normal edit: + // merge back into one entry under the same node ID on completion. + final originalTags = Map.from(node.tags); + originalTags['_pending_edit'] = 'true'; + + final originalNode = OsmNode( + id: node.id, + coord: node.coord, + tags: originalTags, + ); + + final tempId = -DateTime.now().millisecondsSinceEpoch; + final verifiedTags = upload.getCombinedTags(); + verifiedTags['_pending_upload'] = 'true'; + verifiedTags['_original_node_id'] = node.id.toString(); + verifiedTags['_temp_id'] = tempId.toString(); + + upload.tempNodeId = tempId; + + final tempNode = OsmNode(id: tempId, coord: node.coord, tags: verifiedTags); + + _nodeCache.addOrUpdate([originalNode, tempNode]); + NodeProviderWithCache.instance.notifyListeners(); + notifyListeners(); } // Add a completed edit session to the upload queue - void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) { + void addFromEditSession( + EditNodeSession session, { + required UploadMode uploadMode, + }) { // Determine operation type and coordinates final UploadOperation operation; final LatLng coordToUse; - + if (session.extractFromWay && session.originalNode.isConstrained) { // Extract operation: create new node at new location operation = UploadOperation.extract; @@ -180,107 +251,125 @@ class UploadQueueState extends ChangeNotifier { operation = UploadOperation.modify; coordToUse = session.target; } - + final upload = PendingUpload( coord: coordToUse, - direction: _formatDirectionsForSubmission(session.directions, session.profile), - profile: session.profile!, // Safe to use ! because commitEditSession() checks for null + direction: _formatDirectionsForSubmission( + session.directions, + session.profile, + ), + profile: session + .profile!, // Safe to use ! because commitEditSession() checks for null operatorProfile: session.operatorProfile, refinedTags: session.refinedTags, additionalExistingTags: session.additionalExistingTags, + lifecycleStatus: session.lifecycleStatus, changesetComment: session.changesetComment, uploadMode: uploadMode, operation: operation, originalNodeId: session.originalNode.id, // Track which node we're editing ); - + _queue.add(upload); _saveQueue(); - + // Create cache entries based on operation type: if (operation == UploadOperation.extract) { // For extract: only create new node, leave original unchanged final tempId = -DateTime.now().millisecondsSinceEpoch; final extractedTags = upload.getCombinedTags(); extractedTags['_pending_upload'] = 'true'; // Mark as pending upload - extractedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing - extractedTags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal - + extractedTags['_original_node_id'] = session.originalNode.id + .toString(); // Track original for line drawing + extractedTags['_temp_id'] = tempId + .toString(); // Store temp ID for specific removal + // Store the temp ID in the upload for cleanup purposes upload.tempNodeId = tempId; - + final extractedNode = OsmNode( id: tempId, coord: upload.coord, // At new location tags: extractedTags, ); - + _nodeCache.addOrUpdate([extractedNode]); } else { // For modify: mark original with grey ring and create new temp node // 1. Mark the original node with _pending_edit (grey ring) at original location final originalTags = Map.from(session.originalNode.tags); - originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit - + originalTags['_pending_edit'] = + 'true'; // Mark original as having pending edit + final originalNode = OsmNode( id: session.originalNode.id, coord: session.originalNode.coord, // Keep at original location tags: originalTags, ); - + // 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 - editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing - editedTags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal - + editedTags['_original_node_id'] = session.originalNode.id + .toString(); // Track original for line drawing + editedTags['_temp_id'] = tempId + .toString(); // Store temp ID for specific removal + // Store the temp ID in the upload for cleanup purposes upload.tempNodeId = tempId; - + final editedNode = OsmNode( id: tempId, coord: upload.coord, // At new location tags: editedTags, ); - + _nodeCache.addOrUpdate([originalNode, editedNode]); } // Notify node provider to update the map NodeProviderWithCache.instance.notifyListeners(); - + notifyListeners(); } // Add a node deletion to the upload queue - void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) { + void addFromNodeDeletion( + OsmNode node, { + required UploadMode uploadMode, + String? changesetComment, + }) { final upload = PendingUpload( coord: node.coord, - direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API + 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: changesetComment ?? 'Delete a surveillance node', // Use provided comment or default + changesetComment: + changesetComment ?? + 'Delete a surveillance node', // Use provided comment or default uploadMode: uploadMode, operation: UploadOperation.delete, originalNodeId: node.id, ); - + _queue.add(upload); _saveQueue(); - + // Mark the original node as pending deletion in the cache final deletionTags = Map.from(node.tags); deletionTags['_pending_deletion'] = 'true'; - + final nodeWithDeletionTag = OsmNode( id: node.id, coord: node.coord, tags: deletionTags, ); - + _nodeCache.addOrUpdate([nodeWithDeletionTag]); // Notify node provider to update the map NodeProviderWithCache.instance.notifyListeners(); - + notifyListeners(); } @@ -289,22 +378,22 @@ class UploadQueueState extends ChangeNotifier { for (final upload in _queue) { _cleanupPendingNodeFromCache(upload); } - + _queue.clear(); _saveQueue(); - + // Notify node provider to update the map NodeProviderWithCache.instance.notifyListeners(); notifyListeners(); } - + void removeFromQueue(PendingUpload upload) { // Clean up pending node from cache before removing from queue _cleanupPendingNodeFromCache(upload); - + _queue.remove(upload); _saveQueue(); - + // Notify node provider to update the map NodeProviderWithCache.instance.notifyListeners(); notifyListeners(); @@ -319,7 +408,7 @@ class UploadQueueState extends ChangeNotifier { // Start the upload processing loop void startUploader({ - required bool offlineMode, + required bool offlineMode, required bool pauseQueueProcessing, required UploadMode uploadMode, required Future Function() getAccessToken, @@ -337,43 +426,63 @@ class UploadQueueState extends ChangeNotifier { // Check if we can start more uploads (concurrency limit check) if (_activeUploadCount >= kMaxConcurrentUploads) { - debugPrint('[UploadQueue] At concurrency limit ($_activeUploadCount/$kMaxConcurrentUploads), waiting for uploads to complete'); + debugPrint( + '[UploadQueue] At concurrency limit ($_activeUploadCount/$kMaxConcurrentUploads), waiting for uploads to complete', + ); return; } // Process any expired items - final uploadingItems = _queue.where((pu) => pu.uploadState == UploadState.uploading).toList(); - final closingItems = _queue.where((pu) => pu.uploadState == UploadState.closingChangeset).toList(); - + final uploadingItems = _queue + .where((pu) => pu.uploadState == UploadState.uploading) + .toList(); + final closingItems = _queue + .where((pu) => pu.uploadState == UploadState.closingChangeset) + .toList(); + for (final uploadingItem in uploadingItems) { if (uploadingItem.hasChangesetExpired) { - debugPrint('[UploadQueue] Changeset expired during node submission - marking as failed'); - uploadingItem.setError('Could not submit node within 59 minutes - changeset expired'); + debugPrint( + '[UploadQueue] Changeset expired during node submission - marking as failed', + ); + uploadingItem.setError( + 'Could not submit node within 59 minutes - changeset expired', + ); _saveQueue(); notifyListeners(); } } - + for (final closingItem in closingItems) { if (closingItem.hasChangesetExpired) { - debugPrint('[UploadQueue] Changeset expired during close - trusting OSM auto-close (node was submitted successfully)'); - _markAsCompleting(closingItem, submittedNodeId: closingItem.submittedNodeId!); + debugPrint( + '[UploadQueue] Changeset expired during close - trusting OSM auto-close (node was submitted successfully)', + ); + _markAsCompleting( + closingItem, + submittedNodeId: closingItem.submittedNodeId!, + ); } } // Find next pending item to start - final pendingItems = _queue.where((pu) => pu.uploadState == UploadState.pending).toList(); - + final pendingItems = _queue + .where((pu) => pu.uploadState == UploadState.pending) + .toList(); + if (pendingItems.isEmpty) { // Check if queue is effectively empty - final hasActiveItems = _queue.any((pu) => - pu.uploadState == UploadState.creatingChangeset || - pu.uploadState == UploadState.uploading || - pu.uploadState == UploadState.closingChangeset + final hasActiveItems = _queue.any( + (pu) => + pu.uploadState == UploadState.creatingChangeset || + pu.uploadState == UploadState.uploading || + pu.uploadState == UploadState.closingChangeset, ); - + if (!hasActiveItems) { - debugPrint('[UploadQueue] No active items remaining, stopping uploader'); + debugPrint( + '[UploadQueue] No active items remaining, stopping uploader', + ); _uploadTimer?.cancel(); } return; @@ -385,34 +494,46 @@ class UploadQueueState extends ChangeNotifier { // Start processing the next pending upload final item = pendingItems.first; - debugPrint('[UploadQueue] Starting new upload processing for item at ${item.coord} ($_activeUploadCount/$kMaxConcurrentUploads active)'); - + debugPrint( + '[UploadQueue] Starting new upload processing for item at ${item.coord} ($_activeUploadCount/$kMaxConcurrentUploads active)', + ); + _activeUploadCount++; _processIndividualUpload(item, access); }); } // Process an individual upload through all three stages - Future _processIndividualUpload(PendingUpload item, String accessToken) async { + Future _processIndividualUpload( + PendingUpload item, + String accessToken, + ) async { try { - debugPrint('[UploadQueue] Starting individual upload processing for ${item.operation.name} at ${item.coord}'); - + debugPrint( + '[UploadQueue] Starting individual upload processing for ${item.operation.name} at ${item.coord}', + ); + // Stage 1: Create changeset await _processCreateChangeset(item, accessToken); if (item.uploadState == UploadState.error) return; // Stage 2: Node operation with retry logic bool nodeOperationCompleted = false; - while (!nodeOperationCompleted && !item.hasChangesetExpired && item.uploadState != UploadState.error) { + while (!nodeOperationCompleted && + !item.hasChangesetExpired && + item.uploadState != UploadState.error) { await _processNodeOperation(item, accessToken); - + if (item.uploadState == UploadState.closingChangeset) { // Node operation succeeded nodeOperationCompleted = true; - } else if (item.uploadState == UploadState.uploading && !item.isReadyForNodeSubmissionRetry) { + } else if (item.uploadState == UploadState.uploading && + !item.isReadyForNodeSubmissionRetry) { // Need to wait before retry final delay = item.nextNodeSubmissionRetryDelay; - debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before node submission retry'); + debugPrint( + '[UploadQueue] Waiting ${delay.inSeconds}s before node submission retry', + ); await Future.delayed(delay); } else if (item.uploadState == UploadState.error) { // Failed permanently @@ -422,71 +543,99 @@ class UploadQueueState extends ChangeNotifier { if (!nodeOperationCompleted) return; // Failed or expired - // Stage 3: Close changeset with retry logic + // Stage 3: Close changeset with retry logic bool changesetClosed = false; - while (!changesetClosed && !item.hasChangesetExpired && item.uploadState != UploadState.error) { + while (!changesetClosed && + !item.hasChangesetExpired && + item.uploadState != UploadState.error) { await _processChangesetClose(item, accessToken); - + if (item.uploadState == UploadState.complete) { // Changeset close succeeded changesetClosed = true; - } else if (item.uploadState == UploadState.closingChangeset && !item.isReadyForChangesetCloseRetry) { + } else if (item.uploadState == UploadState.closingChangeset && + !item.isReadyForChangesetCloseRetry) { // Need to wait before retry final delay = item.nextChangesetCloseRetryDelay; - debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before changeset close retry'); + debugPrint( + '[UploadQueue] Waiting ${delay.inSeconds}s before changeset close retry', + ); await Future.delayed(delay); } else if (item.uploadState == UploadState.error) { // Failed permanently return; } } - + if (!changesetClosed && item.hasChangesetExpired) { // Trust OSM auto-close if we ran out of time - debugPrint('[UploadQueue] Upload completed but changeset close timed out - trusting OSM auto-close'); + debugPrint( + '[UploadQueue] Upload completed but changeset close timed out - trusting OSM auto-close', + ); if (item.submittedNodeId != null) { _markAsCompleting(item, submittedNodeId: item.submittedNodeId!); } } - } catch (e) { - debugPrint('[UploadQueue] Unexpected error in individual upload processing: $e'); + debugPrint( + '[UploadQueue] Unexpected error in individual upload processing: $e', + ); item.setError('Unexpected error: $e'); _saveQueue(); notifyListeners(); } finally { // Always decrement the active upload count _activeUploadCount--; - debugPrint('[UploadQueue] Individual upload processing finished ($_activeUploadCount/$kMaxConcurrentUploads active)'); + debugPrint( + '[UploadQueue] Individual upload processing finished ($_activeUploadCount/$kMaxConcurrentUploads active)', + ); } } // Process changeset creation (step 1 of 3) - Future _processCreateChangeset(PendingUpload item, String access) async { + Future _processCreateChangeset( + PendingUpload item, + String access, + ) async { item.markAsCreatingChangeset(); _saveQueue(); notifyListeners(); // Show "Creating changeset..." immediately - + if (item.uploadMode == UploadMode.simulate) { // Simulate successful upload without calling real API - debugPrint('[UploadQueue] Simulating changeset creation (no real API call)'); - await Future.delayed(const Duration(milliseconds: 500)); // Simulate network delay - + debugPrint( + '[UploadQueue] Simulating changeset creation (no real API call)', + ); + await Future.delayed( + const Duration(milliseconds: 500), + ); // Simulate network delay + // Move to node operation phase - item.markChangesetCreated('simulate-changeset-${DateTime.now().millisecondsSinceEpoch}'); + item.markChangesetCreated( + 'simulate-changeset-${DateTime.now().millisecondsSinceEpoch}', + ); _saveQueue(); notifyListeners(); return; } - + // Real changeset creation - debugPrint('[UploadQueue] Creating changeset for ${item.operation.name} operation'); - final up = Uploader(access, (nodeId) {}, (errorMessage) {}, uploadMode: item.uploadMode); + debugPrint( + '[UploadQueue] Creating changeset for ${item.operation.name} operation', + ); + final up = Uploader( + access, + (nodeId) {}, + (errorMessage) {}, + uploadMode: item.uploadMode, + ); final result = await up.createChangeset(item); - + if (result.success) { // Changeset created successfully - move to node operation phase - debugPrint('[UploadQueue] Changeset ${result.changesetId} created successfully'); + debugPrint( + '[UploadQueue] Changeset ${result.changesetId} created successfully', + ); item.markChangesetCreated(result.changesetId!); _saveQueue(); notifyListeners(); // Show "Uploading node..." next @@ -495,9 +644,11 @@ class UploadQueueState extends ChangeNotifier { item.attempts++; _saveQueue(); notifyListeners(); // Show attempt count immediately - + if (item.attempts >= 3) { - item.setError(result.errorMessage ?? 'Changeset creation failed after 3 attempts'); + item.setError( + result.errorMessage ?? 'Changeset creation failed after 3 attempts', + ); _saveQueue(); notifyListeners(); // Show error state immediately } else { @@ -522,20 +673,28 @@ class UploadQueueState extends ChangeNotifier { // Check if 59-minute window has expired if (item.hasChangesetExpired) { - debugPrint('[UploadQueue] Changeset expired, could not submit node within 59 minutes'); - item.setError('Could not submit node within 59 minutes - changeset expired'); + debugPrint( + '[UploadQueue] Changeset expired, could not submit node within 59 minutes', + ); + item.setError( + 'Could not submit node within 59 minutes - changeset expired', + ); _saveQueue(); notifyListeners(); return; } - - debugPrint('[UploadQueue] Processing node operation with changeset ${item.changesetId} (attempt ${item.nodeSubmissionAttempts + 1})'); - + + debugPrint( + '[UploadQueue] Processing node operation with changeset ${item.changesetId} (attempt ${item.nodeSubmissionAttempts + 1})', + ); + if (item.uploadMode == UploadMode.simulate) { // Simulate successful node operation without calling real API debugPrint('[UploadQueue] Simulating node operation (no real API call)'); - await Future.delayed(const Duration(milliseconds: 500)); // Simulate network delay - + await Future.delayed( + const Duration(milliseconds: 500), + ); // Simulate network delay + // Store appropriate simulated node ID based on operation type if (item.operation == UploadOperation.modify) { // For edits, keep the original node ID (same as production behavior) @@ -549,43 +708,58 @@ class UploadQueueState extends ChangeNotifier { notifyListeners(); return; } - + // Real node operation - final up = Uploader(access, (nodeId) { - // This callback is called when node operation succeeds - item.submittedNodeId = nodeId; - }, (errorMessage) { - // Error handling is done below - }, uploadMode: item.uploadMode); - + final up = Uploader( + access, + (nodeId) { + // This callback is called when node operation succeeds + item.submittedNodeId = nodeId; + }, + (errorMessage) { + // Error handling is done below + }, + uploadMode: item.uploadMode, + ); + final result = await up.performNodeOperation(item, item.changesetId!); - + item.incrementNodeSubmissionAttempts(); // Record this attempt _saveQueue(); notifyListeners(); // Show attempt count immediately - + if (result.success) { // Node operation succeeded - move to changeset close phase - debugPrint('[UploadQueue] Node operation succeeded after ${item.nodeSubmissionAttempts} attempts, node ID: ${result.nodeId}'); + debugPrint( + '[UploadQueue] Node operation succeeded after ${item.nodeSubmissionAttempts} attempts, node ID: ${result.nodeId}', + ); item.submittedNodeId = result.nodeId; item.markNodeOperationComplete(); _saveQueue(); notifyListeners(); // Show "Closing changeset..." next } else { // Node operation failed - will retry within 59-minute window - debugPrint('[UploadQueue] Node operation failed (attempt ${item.nodeSubmissionAttempts}): ${result.errorMessage}'); - + debugPrint( + '[UploadQueue] Node operation failed (attempt ${item.nodeSubmissionAttempts}): ${result.errorMessage}', + ); + // Check if we have time for another retry if (item.hasChangesetExpired) { - debugPrint('[UploadQueue] Changeset expired during retry, marking as failed'); - item.setError('Could not submit node within 59 minutes - ${result.errorMessage}'); + debugPrint( + '[UploadQueue] Changeset expired during retry, marking as failed', + ); + item.setError( + 'Could not submit node within 59 minutes - ${result.errorMessage}', + ); _saveQueue(); notifyListeners(); } else { // Still have time, will retry after backoff delay final nextDelay = item.nextNodeSubmissionRetryDelay; final timeLeft = item.timeUntilAutoClose; - debugPrint('[UploadQueue] Will retry node submission in $nextDelay, ${timeLeft?.inMinutes}m remaining'); + debugPrint( + '[UploadQueue] Will retry node submission in $nextDelay, ${timeLeft?.inMinutes}m remaining', + ); // No state change needed - attempt count was already updated above } } @@ -603,39 +777,54 @@ class UploadQueueState extends ChangeNotifier { // Check if 59-minute window has expired - if so, mark as complete (trust OSM auto-close) if (item.hasChangesetExpired) { - debugPrint('[UploadQueue] Changeset expired - trusting OSM auto-close (node was submitted successfully)'); + debugPrint( + '[UploadQueue] Changeset expired - trusting OSM auto-close (node was submitted successfully)', + ); _markAsCompleting(item, submittedNodeId: item.submittedNodeId!); return; } - - debugPrint('[UploadQueue] Attempting to close changeset ${item.changesetId} (attempt ${item.changesetCloseAttempts + 1})'); - + + debugPrint( + '[UploadQueue] Attempting to close changeset ${item.changesetId} (attempt ${item.changesetCloseAttempts + 1})', + ); + if (item.uploadMode == UploadMode.simulate) { // Simulate successful changeset close without calling real API debugPrint('[UploadQueue] Simulating changeset close (no real API call)'); - await Future.delayed(const Duration(milliseconds: 300)); // Simulate network delay - + await Future.delayed( + const Duration(milliseconds: 300), + ); // Simulate network delay + // Mark as complete _markAsCompleting(item, submittedNodeId: item.submittedNodeId!); return; } - + // Real changeset close - final up = Uploader(access, (nodeId) {}, (errorMessage) {}, uploadMode: item.uploadMode); + final up = Uploader( + access, + (nodeId) {}, + (errorMessage) {}, + uploadMode: item.uploadMode, + ); final result = await up.closeChangeset(item.changesetId!); - + item.incrementChangesetCloseAttempts(); // This records the attempt time _saveQueue(); notifyListeners(); // Show attempt count immediately - + if (result.success) { // Changeset closed successfully - debugPrint('[UploadQueue] Changeset close succeeded after ${item.changesetCloseAttempts} attempts'); + debugPrint( + '[UploadQueue] Changeset close succeeded after ${item.changesetCloseAttempts} attempts', + ); _markAsCompleting(item, submittedNodeId: item.submittedNodeId!); // _markAsCompleting handles its own save/notify } else if (result.changesetNotFound) { - // Changeset not found - this suggests the upload may not have worked, start over with full retry - debugPrint('[UploadQueue] Changeset not found during close, marking for full retry'); + // Changeset not found - this suggests the upload may not have worked, start over with full retry + debugPrint( + '[UploadQueue] Changeset not found during close, marking for full retry', + ); item.setError(result.errorMessage ?? 'Changeset not found'); _saveQueue(); notifyListeners(); // Show error state immediately @@ -644,7 +833,9 @@ class UploadQueueState extends ChangeNotifier { // Note: This will NEVER error out - will keep trying until 59-minute window expires final nextDelay = item.nextChangesetCloseRetryDelay; final timeLeft = item.timeUntilAutoClose; - debugPrint('[UploadQueue] Changeset close failed (attempt ${item.changesetCloseAttempts}), will retry in $nextDelay, ${timeLeft?.inMinutes}m remaining'); + debugPrint( + '[UploadQueue] Changeset close failed (attempt ${item.changesetCloseAttempts}), will retry in $nextDelay, ${timeLeft?.inMinutes}m remaining', + ); debugPrint('[UploadQueue] Error: ${result.errorMessage}'); // No additional state change needed - attempt count was already updated above } @@ -655,37 +846,50 @@ class UploadQueueState extends ChangeNotifier { } // Mark an item as completing (shows checkmark) and schedule removal after 1 second - void _markAsCompleting(PendingUpload item, {int? submittedNodeId, int? simulatedNodeId}) { + void _markAsCompleting( + PendingUpload item, { + int? submittedNodeId, + int? simulatedNodeId, + }) { item.markAsComplete(); - + // Store the submitted node ID for cleanup purposes if (submittedNodeId != null) { item.submittedNodeId = submittedNodeId; - + if (item.isDeletion) { - debugPrint('[UploadQueue] Deletion successful, removing node ID: $submittedNodeId from cache'); + debugPrint( + '[UploadQueue] Deletion successful, removing node ID: $submittedNodeId from cache', + ); _handleSuccessfulDeletion(item); } else { - debugPrint('[UploadQueue] Upload successful, OSM assigned node ID: $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 + } else if (simulatedNodeId != null && + item.uploadMode == UploadMode.simulate) { + // For simulate mode, use a fake but positive ID item.submittedNodeId = simulatedNodeId; if (item.isDeletion) { - debugPrint('[UploadQueue] Simulated deletion, removing fake node ID: $simulatedNodeId from cache'); + debugPrint( + '[UploadQueue] Simulated deletion, removing fake node ID: $simulatedNodeId from cache', + ); _handleSuccessfulDeletion(item); } else { - debugPrint('[UploadQueue] Simulated upload successful, updating cache with fake node ID: $simulatedNodeId'); + debugPrint( + '[UploadQueue] Simulated upload successful, updating cache with fake node ID: $simulatedNodeId', + ); // Update cache with simulated node ID, same as production mode _updateCacheWithRealNodeId(item, simulatedNodeId); } } - + _saveQueue(); notifyListeners(); - + // Remove the item after 1 second Timer(const Duration(seconds: 1), () { _queue.remove(item); @@ -693,26 +897,26 @@ 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 = OsmNode( id: realNodeId, coord: item.coord, tags: tags, // Clean tags without _pending_upload markers ); - + // Add/update the cache with the real node _nodeCache.addOrUpdate([realNode]); - + // Clean up the specific temp node for this upload if (item.tempNodeId != null) { _nodeCache.removeTempNodeById(item.tempNodeId!); } - + // For modify operations, clean up the original node's _pending_edit marker // For extract operations, we don't modify the original node so leave it unchanged if (item.isEdit && item.originalNodeId != null) { @@ -720,7 +924,7 @@ class UploadQueueState extends ChangeNotifier { // The next Overpass fetch will provide the authoritative data anyway _nodeCache.removePendingEditMarker(item.originalNodeId!); } - + // Notify node provider to update the map NodeProviderWithCache.instance.notifyListeners(); } @@ -730,25 +934,28 @@ class UploadQueueState extends ChangeNotifier { if (item.originalNodeId != null) { // Remove the node from cache entirely _nodeCache.removeNodeById(item.originalNodeId!); - + // Notify node provider to update the map NodeProviderWithCache.instance.notifyListeners(); } } // Helper method to format multiple directions for submission, supporting profile FOV - dynamic _formatDirectionsForSubmission(List directions, NodeProfile? profile) { + dynamic _formatDirectionsForSubmission( + List directions, + NodeProfile? profile, + ) { if (directions.isEmpty) return 0.0; - + // If profile has FOV, convert center directions to range notation if (profile?.fov != null && profile!.fov! > 0) { - final ranges = directions.map((center) => - _formatDirectionWithFov(center, profile.fov!) - ).toList(); - + final ranges = directions + .map((center) => _formatDirectionWithFov(center, profile.fov!)) + .toList(); + return ranges.length == 1 ? ranges.first : ranges.join(';'); } - + // No profile FOV: use original format (single number or semicolon-separated) if (directions.length == 1) return directions.first; return directions.map((d) => d.round().toString()).join(';'); @@ -760,11 +967,11 @@ class UploadQueueState extends ChangeNotifier { if (fov >= 360) { return '0-360'; } - + final halfFov = fov / 2; final start = (center - halfFov + 360) % 360; final end = (center + halfFov) % 360; - + return '${start.round()}-${end.round()}'; } @@ -829,4 +1036,4 @@ class UploadQueueState extends ChangeNotifier { _uploadTimer?.cancel(); super.dispose(); } -} \ No newline at end of file +} diff --git a/lib/widgets/camera_icon.dart b/lib/widgets/camera_icon.dart index 0637936d..1066a6e1 100644 --- a/lib/widgets/camera_icon.dart +++ b/lib/widgets/camera_icon.dart @@ -2,24 +2,27 @@ import 'package:flutter/material.dart'; import '../dev_config.dart'; enum CameraIconType { - real, // Blue ring - real cameras from OSM - mock, // White ring - add camera mock point - pending, // Purple ring - submitted/pending cameras - editing, // Orange ring - camera being edited - pendingEdit, // Grey ring - original camera with pending edit + real, // Blue ring - real cameras from OSM + stale, // Brown ring - stale cameras from OSM + mock, // White ring - add camera mock point + pending, // Purple ring - submitted/pending cameras + editing, // Orange ring - camera being edited + pendingEdit, // Grey ring - original camera with pending edit pendingDeletion, // Red ring - camera pending deletion } /// Simple camera icon with grey dot and colored ring class CameraIcon extends StatelessWidget { final CameraIconType type; - + const CameraIcon({super.key, required this.type}); Color get _ringColor { switch (type) { case CameraIconType.real: return kNodeRingColorReal; + case CameraIconType.stale: + return kNodeRingColorStale; case CameraIconType.mock: return kNodeRingColorMock; case CameraIconType.pending: @@ -48,4 +51,4 @@ class CameraIcon extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index afe4cf1e..11541f9b 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -45,13 +45,14 @@ class _EditNodeSheetState extends State { } Future _checkTutorialStatus() async { - final hasCompleted = await ChangelogService().hasCompletedPositioningTutorial(); + 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(); @@ -73,7 +74,7 @@ class _EditNodeSheetState extends State { void dispose() { // Remove listener NodeDataManager().removeListener(_onCacheUpdated); - + // Clear tutorial callback when widget is disposed if (_showTutorial) { try { @@ -85,11 +86,19 @@ class _EditNodeSheetState extends State { super.dispose(); } - void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) { + void _checkProximityAndCommit( + BuildContext context, + AppState appState, + LocalizationService locService, + ) { _checkSubmissionGuideAndProceed(context, appState, locService); } - void _checkSubmissionGuideAndProceed(BuildContext context, AppState appState, LocalizationService locService) async { + void _checkSubmissionGuideAndProceed( + BuildContext context, + AppState appState, + LocalizationService locService, + ) async { // Check if user has seen the submission guide final hasSeenGuide = await ChangelogService().hasSeenSubmissionGuide(); if (!context.mounted) return; @@ -113,14 +122,18 @@ class _EditNodeSheetState extends State { _checkProximityOnly(context, appState, locService); } - void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService 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, + widget.session.target, kNodeProximityWarningDistance, excludeNodeId: widget.session.originalNode.id, ); - + if (nearbyNodes.isNotEmpty) { // Show proximity warning dialog showDialog( @@ -143,7 +156,11 @@ class _EditNodeSheetState extends State { } } - void _commitWithoutCheck(BuildContext context, AppState appState, LocalizationService locService) { + void _commitWithoutCheck( + BuildContext context, + AppState appState, + LocalizationService locService, + ) { appState.commitEditSession(); Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( @@ -155,26 +172,32 @@ class _EditNodeSheetState extends State { bool _hasActualChanges(EditNodeSession session) { // Extract operation is always a change if (session.extractFromWay) 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) { + if ((session.target.latitude - session.originalNode.coord.latitude).abs() > + tolerance || + (session.target.longitude - session.originalNode.coord.longitude) + .abs() > + tolerance) { return true; } - + // Check direction changes - if (!_directionsEqual(session.directions, session.originalNode.directionDeg)) { + if (!_directionsEqual( + session.directions, + session.originalNode.directionDeg, + )) { return true; } - + // Check tag changes (including operator profile and additional existing tags) final originalTags = session.originalNode.tags; final newTags = _getSessionCombinedTags(session); if (!_tagsEqual(originalTags, newTags)) { return true; } - + return false; } @@ -183,7 +206,7 @@ class _EditNodeSheetState extends State { // 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) { @@ -191,13 +214,13 @@ class _EditNodeSheetState extends State { // 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; } @@ -205,31 +228,31 @@ class _EditNodeSheetState extends State { 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, @@ -237,18 +260,24 @@ class _EditNodeSheetState extends State { profile: session.profile, operatorProfile: session.operatorProfile, refinedTags: session.refinedTags, - additionalExistingTags: session.additionalExistingTags, // Include additional existing tags! + additionalExistingTags: + session.additionalExistingTags, // Include additional existing tags! + lifecycleStatus: session.lifecycleStatus, changesetComment: session.changesetComment, // Required parameter - uploadMode: UploadMode.production, // Mode doesn't matter for tag combination + 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) { + void _showNoChangesDialog( + BuildContext context, + LocalizationService locService, + ) { showDialog( context: context, builder: (context) => AlertDialog( @@ -264,20 +293,27 @@ class _EditNodeSheetState extends State { ); } - Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) { - final requiresDirection = session.profile != null && session.profile!.requiresDirection; + 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 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) { @@ -296,31 +332,43 @@ class _EditNodeSheetState extends State { 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, + 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( - fontWeight: isEven ? FontWeight.normal : FontWeight.bold, + fontStyle: FontStyle.italic, + color: Colors.grey, ), - ); - }) - else - const TextSpan( - text: 'None', - style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey), - ), - ], + ), + ], + ), + ) + : Text( + locService.t( + 'editNode.direction', + params: [session.directionDegrees.round().toString()], + ), ), - ) - : Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])), subtitle: Row( children: [ // Slider takes most of the space @@ -331,7 +379,9 @@ class _EditNodeSheetState extends State { divisions: 359, value: session.directionDegrees, label: session.directionDegrees.round().toString(), - onChanged: enableDirectionControls ? (v) => appState.updateEditSession(directionDeg: v) : null, + onChanged: enableDirectionControls + ? (v) => appState.updateEditSession(directionDeg: v) + : null, ), ), // Direction control buttons - always show but grey out when direction not required @@ -339,52 +389,78 @@ class _EditNodeSheetState extends State { // Remove button IconButton( icon: Icon( - Icons.remove, + Icons.remove, size: 20, - color: enableDirectionControls && appState.canRemoveDirection ? null : Theme.of(context).disabledColor, + color: enableDirectionControls && appState.canRemoveDirection + ? null + : Theme.of(context).disabledColor, ), - onPressed: enableDirectionControls && appState.canRemoveDirection - ? () => appState.removeDirection() + onPressed: + enableDirectionControls && appState.canRemoveDirection + ? () => appState.removeDirection() : null, - tooltip: requiresDirection - ? (hasDirections - ? (appState.canRemoveDirection ? 'Remove current direction' : 'Cannot remove - minimum reached') - : 'No directions to remove') + 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), + constraints: const BoxConstraints( + minWidth: kDirectionButtonMinWidth, + minHeight: kDirectionButtonMinHeight, + ), ), // Add button IconButton( icon: Icon( - Icons.add, + Icons.add, size: 20, - color: enableAddButton && session.directions.length < 8 ? null : Theme.of(context).disabledColor, + 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') + 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), + constraints: const BoxConstraints( + minWidth: kDirectionButtonMinWidth, + minHeight: kDirectionButtonMinHeight, + ), ), // Cycle button IconButton( icon: Icon( - Icons.repeat, + Icons.repeat, size: 20, - color: enableDirectionControls && session.directions.length > 1 ? null : Theme.of(context).disabledColor, + color: + enableDirectionControls && session.directions.length > 1 + ? null + : Theme.of(context).disabledColor, ), - onPressed: enableDirectionControls && session.directions.length > 1 - ? () => appState.cycleDirection() + 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') + 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), + constraints: const BoxConstraints( + minWidth: kDirectionButtonMinWidth, + minHeight: kDirectionButtonMinHeight, + ), ), ], ), @@ -450,34 +526,43 @@ class _EditNodeSheetState extends State { } final session = widget.session; - final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList(); + final submittableProfiles = appState.enabledProfiles + .where((p) => p.isSubmittable) + .toList(); // 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), + 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, + 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 && + + final allowSubmit = + kEnableNodeEdits && + appState.isLoggedIn && + submittableProfiles.isNotEmpty && + session.profile != null && session.profile!.isSubmittable && hasGoodCoverage; - + void navigateToLogin() { Navigator.pushNamed(context, '/settings/osm-account'); } @@ -492,7 +577,9 @@ class _EditNodeSheetState extends State { currentRefinedTags: session.refinedTags, currentAdditionalExistingTags: session.additionalExistingTags, originalNodeTags: session.originalNode.tags, - operation: session.extractFromWay ? UploadOperation.extract : UploadOperation.modify, + operation: session.extractFromWay + ? UploadOperation.extract + : UploadOperation.modify, ), fullscreenDialog: true, ), @@ -513,228 +600,310 @@ class _EditNodeSheetState extends State { 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), + 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), + 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, + ), + ), + ListTile( + title: const Text( + 'Status', + ), //switch to locservice to fit standard streamline + trailing: _buildLifecycleStatusDropdown( + context, + appState, + session, + ), + ), + // 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) ...[ + // 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: [ - const Icon(Icons.info_outline, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text( - locService.t('editNode.cannotMoveConstrainedNode'), - style: Theme.of(context).textTheme.bodyMedium, + 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), ), ), ], ), - 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), + if (!kEnableNodeEdits) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + const Icon( + Icons.construction, + color: Colors.orange, + size: 20, ), - ), - ], - ), - ) - 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), + const SizedBox(width: 6), + Expanded( + child: Text( + locService.t('editNode.temporarilyDisabled'), + style: const TextStyle( + color: Colors.orange, + 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 (!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, + ), + ), + ), + ], + ), ), - ) - else if (session.profile == null) + const SizedBox(height: 8), 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), - ), - ), - ], + 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')), + ), ), - ) - else if (!session.profile!.isSubmittable) + ), + const SizedBox(height: 8), Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + padding: const EdgeInsets.symmetric(horizontal: 16.0), 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), + child: OutlinedButton( + onPressed: cancel, + child: Text(locService.cancel), ), ), - ], - ), - ) - 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), + const SizedBox(width: 12), Expanded( - child: Text( - locService.t('editNode.loadingAreaData'), - style: const TextStyle(color: Colors.blue, fontSize: 13), + child: ElevatedButton( + onPressed: !appState.isLoggedIn + ? navigateToLogin + : (allowSubmit ? commit : null), + child: Text( + !appState.isLoggedIn + ? locService.t('actions.logIn') + : locService.t('actions.saveEdit'), + ), ), ), ], ), ), - 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), - ], + 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(), - ), + Positioned.fill(child: PositioningTutorialOverlay()), ], ); }, ); } - Widget _buildProfileDropdown(BuildContext context, AppState appState, EditNodeSession session, List submittableProfiles, LocalizationService locService) { + 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'); @@ -743,7 +912,7 @@ class _EditNodeSheetState extends State { } return profile.name; } - + return PopupMenuButton( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -773,8 +942,7 @@ class _EditNodeSheetState extends State { child: Text(locService.t('editNode.existingTags')), ), // Divider after existing tags profile - if (submittableProfiles.isNotEmpty) - const PopupMenuDivider(), + if (submittableProfiles.isNotEmpty) const PopupMenuDivider(), // Regular profiles ...submittableProfiles.map( (profile) => PopupMenuItem( @@ -794,9 +962,7 @@ class _EditNodeSheetState extends State { const SizedBox(width: 8), Text( locService.t('profiles.getMore'), - style: const TextStyle( - fontStyle: FontStyle.italic, - ), + style: const TextStyle(fontStyle: FontStyle.italic), ), ], ), @@ -807,20 +973,84 @@ class _EditNodeSheetState extends State { _openIdentifyWebsite(context); } else if (value == 'existing_tags') { // Re-create and select the existing tags profile - final existingTagsProfile = NodeProfile.createExistingTagsProfile(session.originalNode); + 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); + final profile = submittableProfiles.firstWhere( + (p) => p.id == profileId, + ); appState.updateEditSession(profile: profile); } }, ); } + Widget _buildLifecycleStatusDropdown( + BuildContext context, + AppState appState, + EditNodeSession session, + ) { + //return to add more lifecycle toggle options per OSM + String getDisplayName(String status) { + switch (status) { + case 'removed': + return 'Removed'; + case 'destroyed': + return 'Destroyed'; + case 'construction': + return 'Construction'; + case 'proposed': + return 'Proposed'; + case 'was': + return 'Was'; + case 'active': + default: + return 'Active'; + } + } + + 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.lifecycleStatus), + style: const TextStyle(fontSize: 16), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 20), + ], + ), + ), + itemBuilder: (context) => const [ + PopupMenuItem(value: 'active', child: Text('Active')), + PopupMenuItem(value: 'proposed', child: Text('Proposed')), + PopupMenuItem( + value: 'construction', + child: Text('Construction'), + ), + PopupMenuItem(value: 'destroyed', child: Text('Destroyed')), + PopupMenuItem(value: 'removed', child: Text('Removed')), + PopupMenuItem(value: 'was', child: Text('Was')), + ], + onSelected: (value) { + appState.updateEditSession(lifecycleStatus: value); + }, + ); + } + void _openIdentifyWebsite(BuildContext context) async { const url = 'https://deflock.me/identify'; - + try { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { @@ -842,10 +1072,7 @@ class _EditNodeSheetState extends State { void _showErrorSnackBar(BuildContext context, String message) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - backgroundColor: Colors.red, - ), + SnackBar(content: Text(message), backgroundColor: Colors.red), ); } @@ -853,7 +1080,8 @@ class _EditNodeSheetState extends State { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => AdvancedEditOptionsSheet(node: widget.session.originalNode), + builder: (context) => + AdvancedEditOptionsSheet(node: widget.session.originalNode), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/map/marker_layer_builder.dart b/lib/widgets/map/marker_layer_builder.dart index 26528b40..4201ce0e 100644 --- a/lib/widgets/map/marker_layer_builder.dart +++ b/lib/widgets/map/marker_layer_builder.dart @@ -18,9 +18,9 @@ enum PinType { start, end } /// Simple location pin widget for route visualization class LocationPin extends StatelessWidget { final PinType type; - + const LocationPin({super.key, required this.type}); - + @override Widget build(BuildContext context) { return Container( @@ -43,7 +43,6 @@ class LocationPin extends StatelessWidget { /// Builds all marker layers for the map including surveillance nodes, suspected locations, /// session markers, navigation pins, and route visualization. class MarkerLayerBuilder { - /// Build complete marker layers for the map static Widget buildMarkerLayers({ required List nodesToRender, @@ -60,15 +59,16 @@ class MarkerLayerBuilder { }) { return LayoutBuilder( builder: (context, constraints) { - // Determine if nodes should be dimmed and/or disabled - final shouldDimNodes = appState.selectedSuspectedLocation != null || - appState.isInSearchMode || - appState.showingOverview; - + final shouldDimNodes = + appState.selectedSuspectedLocation != null || + appState.isInSearchMode || + appState.showingOverview; + // Disable node interactions when navigation is in conflicting state - final shouldDisableNodeTaps = appState.isInSearchMode || appState.showingOverview; - + final shouldDisableNodeTaps = + appState.isInSearchMode || appState.showingOverview; + final markers = NodeMarkersBuilder.buildNodeMarkers( nodes: nodesToRender, mapController: mapController.mapController, @@ -77,36 +77,46 @@ class MarkerLayerBuilder { onNodeTap: onNodeTap, // Keep the original callback shouldDim: shouldDimNodes, enabled: !shouldDisableNodeTaps, // Use enabled parameter instead + stalenessIndicatorEnabled: appState.stalenessIndicatorEnabled, + stalenessThresholdDays: appState.stalenessThresholdDays, ); // Build suspected location markers (respect same zoom and count limits as nodes) final suspectedLocationMarkers = []; - if (appState.suspectedLocationsEnabled && mapBounds != null && - currentZoom >= (appState.uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel)) { + if (appState.suspectedLocationsEnabled && + mapBounds != null && + currentZoom >= + (appState.uploadMode == UploadMode.sandbox + ? kOsmApiMinZoomLevel + : kNodeMinZoomLevel)) { final suspectedLocations = appState.getSuspectedLocationsInBoundsSync( north: mapBounds.north, south: mapBounds.south, east: mapBounds.east, west: mapBounds.west, ); - + // Apply same node count limit as surveillance nodes final maxNodes = appState.maxNodes; - final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList(); - + final limitedSuspectedLocations = suspectedLocations + .take(maxNodes) + .toList(); + // Filter out suspected locations that are too close to real nodes - final filteredSuspectedLocations = _filterSuspectedLocationsByProximity( - suspectedLocations: limitedSuspectedLocations, - realNodes: nodesToRender, - minDistance: appState.suspectedLocationMinDistance, - ); - + final filteredSuspectedLocations = + _filterSuspectedLocationsByProximity( + suspectedLocations: limitedSuspectedLocations, + realNodes: nodesToRender, + minDistance: appState.suspectedLocationMinDistance, + ); + suspectedLocationMarkers.addAll( SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers( locations: filteredSuspectedLocations, mapController: mapController.mapController, selectedLocationId: appState.selectedSuspectedLocation?.ticketNo, - onLocationTap: onSuspectedLocationTap, // Keep the original callback + onLocationTap: + onSuspectedLocationTap, // Keep the original callback shouldDimAll: shouldDisableNodeTaps, enabled: !shouldDisableNodeTaps, // Use enabled parameter instead ), @@ -128,12 +138,12 @@ class MarkerLayerBuilder { return MarkerLayer( markers: [ - ...suspectedLocationMarkers, - ...markers, + ...suspectedLocationMarkers, + ...markers, ...centerMarkers, ...navigationMarkers, ...routeMarkers, - ] + ], ); }, ); @@ -155,7 +165,9 @@ class MarkerLayerBuilder { width: kNodeIconDiameter, height: kNodeIconDiameter, child: CameraIcon( - type: editSession != null ? CameraIconType.editing : CameraIconType.mock, + type: editSession != null + ? CameraIconType.editing + : CameraIconType.mock, ), ), ); @@ -169,7 +181,8 @@ class MarkerLayerBuilder { /// Build provisional pin for navigation/search mode static List _buildNavigationMarkers(AppState appState) { final markers = []; - if (appState.showProvisionalPin && appState.provisionalPinLocation != null) { + if (appState.showProvisionalPin && + appState.provisionalPinLocation != null) { markers.add( Marker( point: appState.provisionalPinLocation!, @@ -185,7 +198,9 @@ class MarkerLayerBuilder { /// Build start/end pins for route visualization static List _buildRouteMarkers(AppState appState) { final markers = []; - if (appState.showingOverview || appState.isInRouteMode || appState.isSettingSecondPoint) { + if (appState.showingOverview || + appState.isInRouteMode || + appState.isSettingSecondPoint) { if (appState.routeStart != null) { markers.add( Marker( @@ -217,31 +232,31 @@ class MarkerLayerBuilder { required int minDistance, // in meters }) { if (minDistance <= 0) return suspectedLocations; - + const distance = Distance(); final filteredLocations = []; - + for (final suspected in suspectedLocations) { bool tooClose = false; - + for (final realNode in realNodes) { final distanceMeters = distance.as( LengthUnit.Meter, suspected.centroid, realNode.coord, ); - + if (distanceMeters < minDistance) { tooClose = true; break; } } - + if (!tooClose) { filteredLocations.add(suspected); } } - + return filteredLocations; } -} \ No newline at end of file +} diff --git a/lib/widgets/map/node_markers.dart b/lib/widgets/map/node_markers.dart index 7c386bef..746a4345 100644 --- a/lib/widgets/map/node_markers.dart +++ b/lib/widgets/map/node_markers.dart @@ -14,12 +14,16 @@ class NodeMapMarker extends StatefulWidget { final MapController mapController; final void Function(OsmNode)? onNodeTap; final bool enabled; - + final bool stalenessIndicatorEnabled; + final int stalenessThresholdDays; + const NodeMapMarker({ - required this.node, - required this.mapController, + required this.node, + required this.mapController, this.onNodeTap, this.enabled = true, + this.stalenessIndicatorEnabled = false, + this.stalenessThresholdDays = 60, super.key, }); @@ -34,17 +38,19 @@ class _NodeMapMarkerState extends State { void _onTap() { if (!widget.enabled) return; // Don't respond to taps when disabled - + _tapTimer = Timer(tapTimeout, () { // Don't center immediately - let the sheet opening handle the coordinated animation - + // Use callback if provided, otherwise fallback to direct modal if (widget.onNodeTap != null) { widget.onNodeTap!(widget.node); } else { // Fallback: This should not happen if callbacks are properly provided, // but if it does, at least open the sheet (without map coordination) - debugPrint('[NodeMapMarker] Warning: onNodeTap callback not provided, using fallback'); + debugPrint( + '[NodeMapMarker] Warning: onNodeTap callback not provided, using fallback', + ); showModalBottomSheet( context: context, builder: (_) => NodeTagSheet(node: widget.node), @@ -56,9 +62,12 @@ class _NodeMapMarkerState extends State { void _onDoubleTap() { if (!widget.enabled) return; // Don't respond to double taps when disabled - + _tapTimer?.cancel(); - widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta); + widget.mapController.move( + widget.node.coord, + widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta, + ); } @override @@ -70,13 +79,16 @@ class _NodeMapMarkerState extends State { @override Widget build(BuildContext context) { // Check node state - final isPendingUpload = widget.node.tags.containsKey('_pending_upload') && - widget.node.tags['_pending_upload'] == 'true'; - final isPendingEdit = widget.node.tags.containsKey('_pending_edit') && - widget.node.tags['_pending_edit'] == 'true'; - final isPendingDeletion = widget.node.tags.containsKey('_pending_deletion') && - widget.node.tags['_pending_deletion'] == 'true'; - + final isPendingUpload = + widget.node.tags.containsKey('_pending_upload') && + widget.node.tags['_pending_upload'] == 'true'; + final isPendingEdit = + widget.node.tags.containsKey('_pending_edit') && + widget.node.tags['_pending_edit'] == 'true'; + final isPendingDeletion = + widget.node.tags.containsKey('_pending_deletion') && + widget.node.tags['_pending_deletion'] == 'true'; + CameraIconType iconType; if (isPendingDeletion) { iconType = CameraIconType.pendingDeletion; @@ -84,10 +96,13 @@ class _NodeMapMarkerState extends State { iconType = CameraIconType.pending; } else if (isPendingEdit) { iconType = CameraIconType.pendingEdit; + } else if (widget.stalenessIndicatorEnabled && + widget.node.isStale(widget.stalenessThresholdDays)) { + iconType = CameraIconType.stale; } else { iconType = CameraIconType.real; } - + return GestureDetector( onTap: _onTap, onDoubleTap: _onDoubleTap, @@ -106,32 +121,35 @@ class NodeMarkersBuilder { void Function(OsmNode)? onNodeTap, bool shouldDim = false, bool enabled = true, + bool stalenessIndicatorEnabled = false, + int stalenessThresholdDays = 60, }) { final markers = [ // Node markers - ...nodes - .where(_isValidNodeCoordinate) - .map((n) { - // Check if this node should be highlighted (selected) or dimmed - final isSelected = selectedNodeId == n.id; - final shouldDimNode = shouldDim || (selectedNodeId != null && !isSelected); - - return Marker( - point: n.coord, - width: kNodeIconDiameter, - height: kNodeIconDiameter, - child: Opacity( - opacity: shouldDimNode ? 0.5 : 1.0, - child: NodeMapMarker( - node: n, - mapController: mapController, - onNodeTap: onNodeTap, - enabled: enabled, - ), + ...nodes.where(_isValidNodeCoordinate).map((n) { + // Check if this node should be highlighted (selected) or dimmed + final isSelected = selectedNodeId == n.id; + final shouldDimNode = + shouldDim || (selectedNodeId != null && !isSelected); + + return Marker( + point: n.coord, + width: kNodeIconDiameter, + height: kNodeIconDiameter, + child: Opacity( + opacity: shouldDimNode ? 0.5 : 1.0, + child: NodeMapMarker( + node: n, + mapController: mapController, + onNodeTap: onNodeTap, + enabled: enabled, + stalenessIndicatorEnabled: stalenessIndicatorEnabled, + stalenessThresholdDays: stalenessThresholdDays, ), - ); - }), - + ), + ); + }), + // User location marker if (userLocation != null) Marker( @@ -147,7 +165,7 @@ class NodeMarkersBuilder { static bool _isValidNodeCoordinate(OsmNode node) { return (node.coord.latitude != 0 || node.coord.longitude != 0) && - node.coord.latitude.abs() <= 90 && - node.coord.longitude.abs() <= 180; + node.coord.latitude.abs() <= 90 && + node.coord.longitude.abs() <= 180; } -} \ No newline at end of file +} diff --git a/lib/widgets/node_provider_with_cache.dart b/lib/widgets/node_provider_with_cache.dart index 09ff7cef..26cc979c 100644 --- a/lib/widgets/node_provider_with_cache.dart +++ b/lib/widgets/node_provider_with_cache.dart @@ -11,7 +11,8 @@ import '../app_state.dart'; /// 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 NodeProviderWithCache extends ChangeNotifier { - static final NodeProviderWithCache instance = NodeProviderWithCache._internal(); + static final NodeProviderWithCache instance = + NodeProviderWithCache._internal(); factory NodeProviderWithCache() => instance; NodeProviderWithCache._internal(); @@ -23,10 +24,10 @@ class NodeProviderWithCache extends ChangeNotifier { // Use the same cache instance as NodeDataManager final allNodes = NodeSpatialCache().getNodesFor(bounds); final enabledProfiles = AppState.instance.enabledProfiles; - + // If no profiles are enabled, show no nodes if (enabledProfiles.isEmpty) return []; - + // Filter nodes to only show those matching enabled profiles return allNodes.where((node) { return _matchesAnyProfile(node, enabledProfiles); @@ -41,7 +42,7 @@ class NodeProviderWithCache extends ChangeNotifier { }) { // Serve cached immediately notifyListeners(); - + // Debounce rapid panning/zooming _debounceTimer?.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 400), () async { @@ -52,10 +53,9 @@ class NodeProviderWithCache extends ChangeNotifier { uploadMode: uploadMode, isUserInitiated: true, ); - + // Notify UI of new data notifyListeners(); - } catch (e) { debugPrint('[NodeProviderWithCache] Node fetch failed: $e'); // Cache already holds whatever is available for the view @@ -100,12 +100,30 @@ class NodeProviderWithCache extends ChangeNotifier { /// Check if a node matches a specific profile (all non-empty profile tags must match) bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) { + const lifecyclePrefixes = [ + 'active', + 'removed', + 'destroyed', + 'construction', + 'proposed', + 'was', + ]; + for (final entry in profile.tags.entries) { // Skip empty values - they are used for refinement UI, not matching if (entry.value.trim().isEmpty) continue; - - if (node.tags[entry.key] != entry.value) return false; + + final key = entry.key; + final expectedValue = entry.value; + + // Match either the bare key or any lifecycle-prefixed variant + final directMatch = node.tags[key] == expectedValue; + final prefixedMatch = lifecyclePrefixes.any( + (prefix) => node.tags['$prefix:$key'] == expectedValue, + ); + + if (!directMatch && !prefixedMatch) return false; } return true; } -} \ No newline at end of file +} diff --git a/lib/widgets/node_tag_sheet.dart b/lib/widgets/node_tag_sheet.dart index 1af48143..cc54d800 100644 --- a/lib/widgets/node_tag_sheet.dart +++ b/lib/widgets/node_tag_sheet.dart @@ -14,8 +14,8 @@ class NodeTagSheet extends StatelessWidget { final bool isNodeLimitActive; const NodeTagSheet({ - super.key, - required this.node, + super.key, + required this.node, this.onEditPressed, this.isNodeLimitActive = false, }); @@ -27,26 +27,28 @@ class NodeTagSheet extends StatelessWidget { builder: (context, child) { final appState = context.watch(); final locService = LocalizationService.instance; - + // Check if this device is editable (not a pending upload, pending edit, or pending deletion) - final isEditable = (!node.tags.containsKey('_pending_upload') || - node.tags['_pending_upload'] != 'true') && - (!node.tags.containsKey('_pending_edit') || - node.tags['_pending_edit'] != 'true') && - (!node.tags.containsKey('_pending_deletion') || - node.tags['_pending_deletion'] != 'true'); - + final isEditable = + (!node.tags.containsKey('_pending_upload') || + node.tags['_pending_upload'] != 'true') && + (!node.tags.containsKey('_pending_edit') || + node.tags['_pending_edit'] != 'true') && + (!node.tags.containsKey('_pending_deletion') || + node.tags['_pending_deletion'] != 'true'); + // Check if this is a real OSM node (not pending) - for "View on OSM" button - final isRealOSMNode = !node.tags.containsKey('_pending_upload') && - node.id > 0; // Real OSM nodes have positive IDs - + final isRealOSMNode = + !node.tags.containsKey('_pending_upload') && + node.id > 0; // Real OSM nodes have positive IDs + void openEditSheet() { // Check if node limit is active and warn user if (isNodeLimitActive) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - locService.t('nodeLimitIndicator.editingDisabledMessage') + locService.t('nodeLimitIndicator.editingDisabledMessage'), ), duration: const Duration(seconds: 4), behavior: SnackBarBehavior.floating, @@ -54,16 +56,26 @@ class NodeTagSheet extends StatelessWidget { ); return; } - + if (onEditPressed != null) { onEditPressed!(); // Use callback if provided } else { // Fallback behavior Navigator.pop(context); // Close this sheet first - appState.startEditSession(node); // HomeScreen will auto-show the edit sheet + appState.startEditSession( + node, + ); // HomeScreen will auto-show the edit sheet } } + void verifyNode() { + Navigator.pop(context); // Close this sheet first + appState.verifyNode(node); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('node.verifyQueuedForUpload'))), + ); + } + void deleteNode() async { final result = await showDialog<({bool confirmed, String comment})>( context: context, @@ -77,7 +89,9 @@ class NodeTagSheet extends StatelessWidget { Navigator.pop(context); // Close this sheet first appState.deleteNode(node, changesetComment: result!.comment); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))), + SnackBar( + content: Text(locService.t('node.deleteQueuedForUpload')), + ), ); } } @@ -91,14 +105,22 @@ class NodeTagSheet extends StatelessWidget { } else { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))), + SnackBar( + content: Text( + locService.t('advancedEdit.couldNotOpenOSMWebsite'), + ), + ), ); } } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))), + SnackBar( + content: Text( + locService.t('advancedEdit.couldNotOpenOSMWebsite'), + ), + ), ); } } @@ -116,132 +138,179 @@ class NodeTagSheet extends StatelessWidget { builder: (context, constraints) { return SafeArea( child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - locService.t('node.title').replaceAll('{}', node.id.toString()), - style: Theme.of(context).textTheme.titleLarge, + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 20, ), - const SizedBox(height: 12), - - // Tag list with flexible height constraint - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context), - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ...node.tags.entries.map( - (e) => Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - e.key, - style: TextStyle( - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurface, - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + locService + .t('node.title') + .replaceAll('{}', node.id.toString()), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + + // Tag list with flexible height constraint + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + MediaQuery.of(context).size.height * + getTagListHeightRatio(context), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...node.tags.entries.map( + (e) => Padding( + padding: const EdgeInsets.symmetric( + vertical: 2, ), - const SizedBox(width: 8), - Expanded( - child: Linkify( - onOpen: (link) async { - final uri = Uri.parse(link.url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } else if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')), - ); - } - }, - text: e.value, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.key, + style: TextStyle( + fontWeight: FontWeight.w500, + color: Theme.of( + context, + ).colorScheme.onSurface, + ), ), - linkStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, + const SizedBox(width: 8), + Expanded( + child: Linkify( + onOpen: (link) async { + final uri = Uri.parse(link.url); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: LaunchMode + .externalApplication, + ); + } else if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + '${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}', + ), + ), + ); + } + }, + text: e.value, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + ), + linkStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.primary, + decoration: TextDecoration.underline, + ), + options: const LinkifyOptions( + humanize: false, + ), + ), ), - options: const LinkifyOptions(humanize: false), - ), + ], ), - ], + ), ), - ), + ], ), + ), + ), + const SizedBox(height: 16), + // First row: View and Advanced buttons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (isRealOSMNode) ...[ + TextButton.icon( + onPressed: () => viewOnOSM(), + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(locService.t('actions.viewOnOSM')), + ), + const SizedBox(width: 8), + ], + if (isEditable) ...[ + OutlinedButton.icon( + onPressed: openAdvancedEdit, + icon: const Icon(Icons.open_in_new, size: 18), + label: Text(locService.t('actions.advanced')), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 36), + ), + ), + ], ], ), - ), - ), - const SizedBox(height: 16), - // First row: View and Advanced buttons - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (isRealOSMNode) ...[ - TextButton.icon( - onPressed: () => viewOnOSM(), - icon: const Icon(Icons.open_in_new, size: 16), - label: Text(locService.t('actions.viewOnOSM')), - ), - const SizedBox(width: 8), - ], - if (isEditable) ...[ - OutlinedButton.icon( - onPressed: openAdvancedEdit, - icon: const Icon(Icons.open_in_new, size: 18), - label: Text(locService.t('actions.advanced')), - style: OutlinedButton.styleFrom( - minimumSize: const Size(0, 36), - ), - ), - ], - ], - ), - const SizedBox(height: 8), - // Second row: Edit, Delete, and Close buttons - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (isEditable) ...[ - ElevatedButton.icon( - onPressed: openEditSheet, - icon: const Icon(Icons.edit, size: 18), - label: Text(locService.edit), - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 36), - ), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: node.isConstrained ? null : deleteNode, - icon: const Icon(Icons.delete, size: 18), - label: Text(locService.t('actions.delete')), - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 36), - foregroundColor: node.isConstrained ? null : Colors.red, - ), + const SizedBox(height: 8), + // Second row: Edit, Delete, and Close buttons + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (isEditable) ...[ + ElevatedButton.icon( + onPressed: openEditSheet, + icon: const Icon(Icons.edit, size: 18), + label: Text(locService.edit), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 36), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: verifyNode, + icon: const Icon( + Icons.check_circle_outline, + size: 18, + ), + label: Text(locService.t('actions.verify')), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 36), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: node.isConstrained ? null : deleteNode, + icon: const Icon(Icons.delete, size: 18), + label: Text(locService.t('actions.delete')), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 36), + foregroundColor: node.isConstrained + ? null + : Colors.red, + ), + ), + const SizedBox(width: 12), + ], + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(locService.t('actions.close')), + ), + ], ), - const SizedBox(width: 12), - ], - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(locService.t('actions.close')), ), ], ), - ], - ), - ), - ); + ), + ); }, ); }, @@ -253,10 +322,7 @@ class _DeleteNodeDialog extends StatefulWidget { final String nodeId; final LocalizationService locService; - const _DeleteNodeDialog({ - required this.nodeId, - required this.locService, - }); + const _DeleteNodeDialog({required this.nodeId, required this.locService}); @override State<_DeleteNodeDialog> createState() => _DeleteNodeDialogState(); @@ -285,7 +351,12 @@ class _DeleteNodeDialogState extends State<_DeleteNodeDialog> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(widget.locService.t('node.confirmDeleteMessage', params: [widget.nodeId])), + Text( + widget.locService.t( + 'node.confirmDeleteMessage', + params: [widget.nodeId], + ), + ), const SizedBox(height: 16), Text( widget.locService.t('node.deleteReasonLabel'), @@ -306,13 +377,14 @@ class _DeleteNodeDialogState extends State<_DeleteNodeDialog> { ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop((confirmed: false, comment: '')), + onPressed: () => + Navigator.of(context).pop((confirmed: false, comment: '')), child: Text(widget.locService.cancel), ), TextButton( onPressed: () { final comment = _commentController.text.trim(); - final finalComment = comment.isEmpty + final finalComment = comment.isEmpty ? 'Delete a surveillance node' : 'Delete a surveillance node: $comment'; Navigator.of(context).pop((confirmed: true, comment: finalComment)); @@ -323,4 +395,4 @@ class _DeleteNodeDialogState extends State<_DeleteNodeDialog> { ], ); } -} \ No newline at end of file +} From 097ab24792572a0e08c1403d2230f3110e15ef91 Mon Sep 17 00:00:00 2001 From: Asiya Kadivar Date: Tue, 14 Jul 2026 21:59:36 -0700 Subject: [PATCH 02/10] Fix analyze lint issues and add translations for de, es, fr, it, nl, pl, pt, tr, uk, zh --- lib/app_state.dart | 3 ++- lib/localizations/de.json | 9 +++++++++ lib/localizations/es.json | 9 +++++++++ lib/localizations/fr.json | 9 +++++++++ lib/localizations/it.json | 9 +++++++++ lib/localizations/nl.json | 9 +++++++++ lib/localizations/pl.json | 9 +++++++++ lib/localizations/pt.json | 9 +++++++++ lib/localizations/tr.json | 9 +++++++++ lib/localizations/uk.json | 9 +++++++++ lib/localizations/zh.json | 9 +++++++++ lib/state/settings_state.dart | 6 ++++-- 12 files changed, 96 insertions(+), 3 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index e2cf4236..f8f93e05 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -549,8 +549,9 @@ class AppState extends ChangeNotifier { } void _checkTutorialCompletion(LatLng newPosition) { - if (_tutorialCompletionCallback == null || _tutorialStartPosition == null) + if (_tutorialCompletionCallback == null || _tutorialStartPosition == null) { return; + } // Calculate distance moved final distance = Distance(); diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 580496df..c3e48de3 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -10,6 +10,7 @@ "download": "Herunterladen", "settings": "Einstellungen", "edit": "Bearbeiten", + "verify": "Überprüfen", "delete": "Löschen", "cancel": "Abbrechen", "ok": "OK", @@ -75,6 +76,7 @@ "advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen", "proximityAlerts": "Näherungswarnungen", "keepScreenAwake": "Bildschirm Aktiv Lassen", + "dataFreshness": "Datenaktualität", "networkStatusIndicator": "Netzwerkstatus-Anzeige" }, "keepScreenAwake": { @@ -93,6 +95,13 @@ "alertDistance": "Warnentfernung: ", "rangeInfo": "Bereich: {}-{} {} (Standard: {})" }, + "staleness": { + "showIndicator": "Veraltete Knoten anzeigen", + "showIndicatorExplanation": "Knoten hervorheben, die nicht kürzlich überprüft wurden", + "threshold": "Als veraltet betrachten nach", + "daysCount": "{} Tage", + "explanation": "Knoten ohne check_date werden nie als veraltet markiert." + }, "node": { "title": "Knoten #{}", "tagSheetTitle": "Gerät-Tags", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index eb8e0354..d63990db 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -47,6 +47,7 @@ "download": "Descargar", "settings": "Configuración", "edit": "Editar", + "verify": "Verificar", "delete": "Eliminar", "cancel": "Cancelar", "ok": "Aceptar", @@ -112,6 +113,7 @@ "advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas", "proximityAlerts": "Alertas de Proximidad", "keepScreenAwake": "Mantener la Pantalla Encendida", + "dataFreshness": "Actualidad de los datos", "networkStatusIndicator": "Indicador de Estado de Red" }, "keepScreenAwake": { @@ -130,6 +132,13 @@ "alertDistance": "Distancia de alerta: ", "rangeInfo": "Rango: {}-{} {} (predeterminado: {})" }, + "staleness": { + "showIndicator": "Mostrar indicador de nodos obsoletos", + "showIndicatorExplanation": "Resaltar nodos que no se han verificado recientemente", + "threshold": "Considerar obsoleto después de", + "daysCount": "{} días", + "explanation": "Los nodos sin check_date nunca se marcan como obsoletos." + }, "node": { "title": "Nodo #{}", "tagSheetTitle": "Etiquetas del Dispositivo", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index c68f1f46..3109a026 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -47,6 +47,7 @@ "download": "Télécharger", "settings": "Paramètres", "edit": "Modifier", + "verify": "Vérifier", "delete": "Supprimer", "cancel": "Annuler", "ok": "OK", @@ -112,6 +113,7 @@ "advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles", "proximityAlerts": "Alertes de Proximité", "keepScreenAwake": "Maintenir l'écran Allumé", + "dataFreshness": "Fraîcheur des données", "networkStatusIndicator": "Indicateur de Statut Réseau" }, "keepScreenAwake": { @@ -130,6 +132,13 @@ "alertDistance": "Distance d'alerte : ", "rangeInfo": "Plage : {}-{} {} (par défaut : {})" }, + "staleness": { + "showIndicator": "Afficher l'indicateur de nœuds obsolètes", + "showIndicatorExplanation": "Mettre en évidence les nœuds qui n'ont pas été vérifiés récemment", + "threshold": "Considérer comme obsolète après", + "daysCount": "{} jours", + "explanation": "Les nœuds sans check_date ne sont jamais marqués comme obsolètes." + }, "node": { "title": "Nœud #{}", "tagSheetTitle": "Balises du Dispositif", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 51888bfe..ac1b55b3 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -47,6 +47,7 @@ "download": "Scarica", "settings": "Impostazioni", "edit": "Modifica", + "verify": "Verifica", "delete": "Elimina", "cancel": "Annulla", "ok": "OK", @@ -112,6 +113,7 @@ "advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere", "proximityAlerts": "Avvisi di Prossimità", "keepScreenAwake": "Mantieni lo Schermo Attivo", + "dataFreshness": "Aggiornamento dei dati", "networkStatusIndicator": "Indicatore di Stato di Rete" }, "keepScreenAwake": { @@ -130,6 +132,13 @@ "alertDistance": "Distanza di avviso: ", "rangeInfo": "Intervallo: {}-{} {} (predefinito: {})" }, + "staleness": { + "showIndicator": "Mostra indicatore nodi obsoleti", + "showIndicatorExplanation": "Evidenzia i nodi non controllati di recente", + "threshold": "Considera obsoleto dopo", + "daysCount": "{} giorni", + "explanation": "I nodi senza check_date non vengono mai contrassegnati come obsoleti." + }, "node": { "title": "Nodo #{}", "tagSheetTitle": "Tag Dispositivo di Sorveglianza", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 54f88e51..74f9f713 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -47,6 +47,7 @@ "download": "Download", "settings": "Instellingen", "edit": "Bewerken", + "verify": "Verifiëren", "delete": "Verwijderen", "cancel": "Annuleren", "ok": "OK", @@ -112,6 +113,7 @@ "advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen", "proximityAlerts": "Nabijheids Waarschuwingen", "keepScreenAwake": "Scherm Actief Houden", + "dataFreshness": "Gegevensversheid", "networkStatusIndicator": "Netwerk Status Indicator" }, "keepScreenAwake": { @@ -130,6 +132,13 @@ "alertDistance": "Waarschuwingsafstand: ", "rangeInfo": "Bereik: {}-{} {} (standaard: {})" }, + "staleness": { + "showIndicator": "Verouderde knooppuntindicator tonen", + "showIndicatorExplanation": "Knooppunten markeren die niet recent zijn gecontroleerd", + "threshold": "Als verouderd beschouwen na", + "daysCount": "{} dagen", + "explanation": "Knooppunten zonder check_date worden nooit als verouderd gemarkeerd." + }, "node": { "title": "Node #{}", "tagSheetTitle": "Surveillance Apparaat Tags", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 1c62056a..42a98b3f 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -47,6 +47,7 @@ "download": "Pobierz", "settings": "Ustawienia", "edit": "Edytuj", + "verify": "Zweryfikuj", "delete": "Usuń", "cancel": "Anuluj", "ok": "OK", @@ -112,6 +113,7 @@ "advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków", "proximityAlerts": "Alerty Bliskości", "keepScreenAwake": "Nie Wygaszaj Ekranu", + "dataFreshness": "Aktualność danych", "networkStatusIndicator": "Wskaźnik Stanu Sieci" }, "keepScreenAwake": { @@ -130,6 +132,13 @@ "alertDistance": "Odległość alertu: ", "rangeInfo": "Zakres: {}-{} {} (domyślnie: {})" }, + "staleness": { + "showIndicator": "Pokaż wskaźnik nieaktualnych węzłów", + "showIndicatorExplanation": "Wyróżnij węzły, które nie były ostatnio sprawdzane", + "threshold": "Uznaj za nieaktualne po", + "daysCount": "{} dni", + "explanation": "Węzły bez check_date nigdy nie są oznaczane jako nieaktualne." + }, "node": { "title": "Węzeł #{}", "tagSheetTitle": "Tagi Urządzenia Nadzoru", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 9f83132a..973622b7 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -47,6 +47,7 @@ "download": "Baixar", "settings": "Configurações", "edit": "Editar", + "verify": "Verificar", "delete": "Excluir", "cancel": "Cancelar", "ok": "OK", @@ -112,6 +113,7 @@ "advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas", "proximityAlerts": "Alertas de Proximidade", "keepScreenAwake": "Manter Ecrã Ativa", + "dataFreshness": "Atualidade dos dados", "networkStatusIndicator": "Indicador de Status de Rede" }, "keepScreenAwake": { @@ -130,6 +132,13 @@ "alertDistance": "Distância de alerta: ", "rangeInfo": "Faixa: {}-{} {} (padrão: {})" }, + "staleness": { + "showIndicator": "Mostrar indicador de nós desatualizados", + "showIndicatorExplanation": "Destacar nós que não foram verificados recentemente", + "threshold": "Considerar desatualizado após", + "daysCount": "{} dias", + "explanation": "Nós sem check_date nunca são marcados como desatualizados." + }, "node": { "title": "Nó #{}", "tagSheetTitle": "Tags do Dispositivo de Vigilância", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 74a1d527..d2847829 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -47,6 +47,7 @@ "download": "İndir", "settings": "Ayarlar", "edit": "Düzenle", + "verify": "Doğrula", "delete": "Sil", "cancel": "İptal", "ok": "Tamam", @@ -112,6 +113,7 @@ "advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları", "proximityAlerts": "Yakınlık Uyarıları", "keepScreenAwake": "Ekranı Açık Tut", + "dataFreshness": "Veri Tazeliği", "networkStatusIndicator": "Ağ Durumu Göstergesi" }, "keepScreenAwake": { @@ -130,6 +132,13 @@ "alertDistance": "Uyarı mesafesi: ", "rangeInfo": "Aralık: {}-{} {} (varsayılan: {})" }, + "staleness": { + "showIndicator": "Eski düğüm göstergesini göster", + "showIndicatorExplanation": "Son zamanlarda kontrol edilmemiş düğümleri vurgula", + "threshold": "Şu süreden sonra eski say", + "daysCount": "{} gün", + "explanation": "check_date değeri olmayan düğümler asla eski olarak işaretlenmez." + }, "node": { "title": "Düğüm #{}", "tagSheetTitle": "Gözetleme Cihazı Etiketleri", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index c588cd58..c237ce3c 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -47,6 +47,7 @@ "download": "Завантажити", "settings": "Налаштування", "edit": "Редагувати", + "verify": "Перевірити", "delete": "Видалити", "cancel": "Скасувати", "ok": "ОК", @@ -112,6 +113,7 @@ "advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток", "proximityAlerts": "Сповіщення Про Близькість", "keepScreenAwake": "Тримати екран активним", + "dataFreshness": "Актуальність даних", "networkStatusIndicator": "Індикатор Стану Мережі" }, "keepScreenAwake": { @@ -130,6 +132,13 @@ "alertDistance": "Відстань сповіщення: ", "rangeInfo": "Діапазон: {}-{} {} (за замовчуванням: {})" }, + "staleness": { + "showIndicator": "Показувати індикатор застарілих вузлів", + "showIndicatorExplanation": "Виділяти вузли, які давно не перевірялися", + "threshold": "Вважати застарілим після", + "daysCount": "{} днів", + "explanation": "Вузли без check_date ніколи не позначаються як застарілі." + }, "node": { "title": "Вузол #{}", "tagSheetTitle": "Теги Пристрою Спостереження", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index d73750d3..cc8f7075 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -47,6 +47,7 @@ "download": "下载", "settings": "设置", "edit": "编辑", + "verify": "验证", "delete": "删除", "cancel": "取消", "ok": "确定", @@ -112,6 +113,7 @@ "advancedSettingsSubtitle": "性能、警报和地图提供商设置", "proximityAlerts": "邻近警报", "keepScreenAwake": "保持屏幕常亮", + "dataFreshness": "数据新鲜度", "networkStatusIndicator": "网络状态指示器" }, "keepScreenAwake": { @@ -130,6 +132,13 @@ "alertDistance": "警报距离:", "rangeInfo": "范围:{}-{} {}(默认:{})" }, + "staleness": { + "showIndicator": "显示过期节点指示器", + "showIndicatorExplanation": "高亮显示最近未检查的节点", + "threshold": "视为过期的天数", + "daysCount": "{} 天", + "explanation": "没有 check_date 的节点永远不会被标记为过期。" + }, "node": { "title": "节点 #{}", "tagSheetTitle": "监控设备标签", diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index b9e1aab9..f1715133 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -413,10 +413,12 @@ class SettingsState extends ChangeNotifier { /// Set proximity alert distance in meters Future setProximityAlertDistance(int distance) async { - if (distance < kProximityAlertMinDistance) + if (distance < kProximityAlertMinDistance) { distance = kProximityAlertMinDistance; - if (distance > kProximityAlertMaxDistance) + } + if (distance > kProximityAlertMaxDistance) { distance = kProximityAlertMaxDistance; + } if (_proximityAlertDistance != distance) { _proximityAlertDistance = distance; final prefs = await SharedPreferences.getInstance(); From 42c9e28b5610d0b0aa08c972e4ea9b2f1e39ae46 Mon Sep 17 00:00:00 2001 From: Asiya Kadivar Date: Fri, 17 Jul 2026 13:23:39 -0700 Subject: [PATCH 03/10] Re-add kNodeRingColorStale constant dropped in merge from main --- lib/dev_config.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 7402c37f..624bb8e6 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -223,6 +223,7 @@ const double kNodeIconDiameter = 17.0; const double _kNodeRingThicknessBase = 2.6; const double kNodeDotOpacity = 0.28; // Opacity for the grey dot interior const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue +const Color kNodeRingColorStale = Color(0xFF6F4E37); // Real but stale OSM nodes - brown const Color kNodeRingColorMock = Color(0xE044BB55); // Add node mock point - white const Color kNodeRingColorPending = Color(0xD09C27B0); // Submitted/pending nodes - purple const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - orange From fa09dd22688900aaa8b35da49cda8039423cc9e9 Mon Sep 17 00:00:00 2001 From: Asiya Kadivar Date: Fri, 17 Jul 2026 19:42:18 -0700 Subject: [PATCH 04/10] Retrigger CI From 8031223f8f60b8e168ccd53a90f050294418285f Mon Sep 17 00:00:00 2001 From: Asiya Kadivar Date: Sat, 1 Aug 2026 17:20:48 -0400 Subject: [PATCH 05/10] fade, check_date auto-initialization, re-added verify button --- lib/app_state.dart | 6 -- lib/dev_config.dart | 8 ++- lib/localizations/de.json | 3 +- lib/localizations/en.json | 3 +- lib/localizations/es.json | 3 +- lib/localizations/fr.json | 3 +- lib/localizations/it.json | 3 +- lib/localizations/nl.json | 3 +- lib/localizations/pl.json | 3 +- lib/localizations/pt.json | 3 +- lib/localizations/tr.json | 3 +- lib/localizations/uk.json | 3 +- lib/localizations/zh.json | 3 +- lib/models/osm_node.dart | 22 ++++--- lib/models/pending_upload.dart | 4 +- .../sections/staleness_indicator_section.dart | 48 -------------- .../map_data_submodules/nodes_from_local.dart | 9 ++- .../nodes_from_osm_api.dart | 5 ++ lib/services/overpass_service.dart | 7 +- lib/state/settings_state.dart | 16 ----- lib/state/upload_queue_state.dart | 2 +- lib/widgets/camera_icon.dart | 17 ++++- lib/widgets/map/marker_layer_builder.dart | 3 +- lib/widgets/map/node_markers.dart | 25 +++---- lib/widgets/node_tag_sheet.dart | 66 +++++++++++-------- lib/widgets/refine_tags_sheet.dart | 3 + 26 files changed, 128 insertions(+), 146 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index 81df2115..0761370c 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -180,7 +180,6 @@ class AppState extends ChangeNotifier { _settingsState.suspectedLocationMinDistance; bool get stalenessIndicatorEnabled => _settingsState.stalenessIndicatorEnabled; - int get stalenessThresholdDays => _settingsState.stalenessThresholdDays; // Messages state int? get unreadMessageCount => _messagesState.unreadCount; @@ -852,11 +851,6 @@ class AppState extends ChangeNotifier { await _settingsState.setStalenessIndicatorEnabled(enabled); } - /// Set staleness threshold in days - Future setStalenessThresholdDays(int days) async { - await _settingsState.setStalenessThresholdDays(days); - } - /// Set navigation avoidance distance Future setNavigationAvoidanceDistance(int distance) async { await _settingsState.setNavigationAvoidanceDistance(distance); diff --git a/lib/dev_config.dart b/lib/dev_config.dart index ea1c2de9..c9d96ba0 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -102,7 +102,7 @@ const String kSuspectedLocationsCsvUrl = // 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 + true; // Set to false to hide sandbox/simulate modes and force production mode // Navigation features - set to false to hide navigation UI elements while in development const bool kEnableNavigationFeatures = @@ -142,6 +142,12 @@ const Duration kMapLongPressTimeout = Duration( ); // Duration to trigger "add node here" on empty map area const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); +//Release date for stale node detection feature (used as default check_date for nodes without a check_date) +final kFeatureReleaseDate = DateTime.utc(2025, 11, 14); // adjust to actual release date +//Node staleness thresholds (in days) for visual indicator +const int kAgingNodeThresholdDays = 60; // Threshold for stale node to begin fading -- "aging" node +const int kStaleNodeThresholdDays = 360; // Threshold node to be fully stale + // Pre-fetch area configuration const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching const double kNodeRenderingBoundsExpansion = 1.3; // Expand visible bounds by this factor for node rendering to prevent edge blinking diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 6a993cbe..ddf5c291 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -109,8 +109,7 @@ "showIndicator": "Veraltete Knoten anzeigen", "showIndicatorExplanation": "Knoten hervorheben, die nicht kürzlich überprüft wurden", "threshold": "Als veraltet betrachten nach", - "daysCount": "{} Tage", - "explanation": "Knoten ohne check_date werden nie als veraltet markiert." + "daysCount": "{} Tage" }, "node": { "title": "Knoten #{}", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 9c4538f9..4fe6f380 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -146,8 +146,7 @@ "showIndicator": "Show stale node indicator", "showIndicatorExplanation": "Highlight nodes that haven't been checked recently", "threshold": "Consider stale after", - "daysCount": "{} days", - "explanation": "Nodes without a check_date are never marked stale." + "daysCount": "{} days" }, "node": { "title": "Node #{}", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 247c2de1..b98dd45a 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -146,8 +146,7 @@ "showIndicator": "Mostrar indicador de nodos obsoletos", "showIndicatorExplanation": "Resaltar nodos que no se han verificado recientemente", "threshold": "Considerar obsoleto después de", - "daysCount": "{} días", - "explanation": "Los nodos sin check_date nunca se marcan como obsoletos." + "daysCount": "{} días" }, "node": { "title": "Nodo #{}", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 13a0cbf7..1a871014 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -146,8 +146,7 @@ "showIndicator": "Afficher l'indicateur de nœuds obsolètes", "showIndicatorExplanation": "Mettre en évidence les nœuds qui n'ont pas été vérifiés récemment", "threshold": "Considérer comme obsolète après", - "daysCount": "{} jours", - "explanation": "Les nœuds sans check_date ne sont jamais marqués comme obsolètes." + "daysCount": "{} jours" }, "node": { "title": "Nœud #{}", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 81950071..7738b492 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -146,8 +146,7 @@ "showIndicator": "Mostra indicatore nodi obsoleti", "showIndicatorExplanation": "Evidenzia i nodi non controllati di recente", "threshold": "Considera obsoleto dopo", - "daysCount": "{} giorni", - "explanation": "I nodi senza check_date non vengono mai contrassegnati come obsoleti." + "daysCount": "{} giorni" }, "node": { "title": "Nodo #{}", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 29c08b8c..3192b8c3 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -146,8 +146,7 @@ "showIndicator": "Verouderde knooppuntindicator tonen", "showIndicatorExplanation": "Knooppunten markeren die niet recent zijn gecontroleerd", "threshold": "Als verouderd beschouwen na", - "daysCount": "{} dagen", - "explanation": "Knooppunten zonder check_date worden nooit als verouderd gemarkeerd." + "daysCount": "{} dagen" }, "node": { "title": "Node #{}", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 09768490..4ee48639 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -146,8 +146,7 @@ "showIndicator": "Pokaż wskaźnik nieaktualnych węzłów", "showIndicatorExplanation": "Wyróżnij węzły, które nie były ostatnio sprawdzane", "threshold": "Uznaj za nieaktualne po", - "daysCount": "{} dni", - "explanation": "Węzły bez check_date nigdy nie są oznaczane jako nieaktualne." + "daysCount": "{} dni" }, "node": { "title": "Węzeł #{}", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 3850125b..e0924f3f 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -146,8 +146,7 @@ "showIndicator": "Mostrar indicador de nós desatualizados", "showIndicatorExplanation": "Destacar nós que não foram verificados recentemente", "threshold": "Considerar desatualizado após", - "daysCount": "{} dias", - "explanation": "Nós sem check_date nunca são marcados como desatualizados." + "daysCount": "{} dias" }, "node": { "title": "Nó #{}", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 565e4799..e13d0d9d 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -146,8 +146,7 @@ "showIndicator": "Eski düğüm göstergesini göster", "showIndicatorExplanation": "Son zamanlarda kontrol edilmemiş düğümleri vurgula", "threshold": "Şu süreden sonra eski say", - "daysCount": "{} gün", - "explanation": "check_date değeri olmayan düğümler asla eski olarak işaretlenmez." + "daysCount": "{} gün" }, "node": { "title": "Düğüm #{}", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index 29602c9b..8471936d 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -146,8 +146,7 @@ "showIndicator": "Показувати індикатор застарілих вузлів", "showIndicatorExplanation": "Виділяти вузли, які давно не перевірялися", "threshold": "Вважати застарілим після", - "daysCount": "{} днів", - "explanation": "Вузли без check_date ніколи не позначаються як застарілі." + "daysCount": "{} днів" }, "node": { "title": "Вузол #{}", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index a4756536..1c913b80 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -146,8 +146,7 @@ "showIndicator": "显示过期节点指示器", "showIndicatorExplanation": "高亮显示最近未检查的节点", "threshold": "视为过期的天数", - "daysCount": "{} 天", - "explanation": "没有 check_date 的节点永远不会被标记为过期。" + "daysCount": "{} 天" }, "node": { "title": "节点 #{}", diff --git a/lib/models/osm_node.dart b/lib/models/osm_node.dart index ae65a618..955e22ed 100644 --- a/lib/models/osm_node.dart +++ b/lib/models/osm_node.dart @@ -31,6 +31,11 @@ class OsmNode { tags[k.toString()] = v.toString(); }); } + + if (!tags.containsKey('check_date') || (tags['check_date'] ?? '').isEmpty) { + tags['check_date'] = kFeatureReleaseDate.toIso8601String().split('T')[0]; + } + return OsmNode( id: json['id'] is int ? json['id'] as int @@ -46,16 +51,17 @@ class OsmNode { ); } - /// Parsed check_date tag, if present and valid. Null for nodes with no - /// check_date (treated as "unknown age", never stale). - DateTime? get checkDate => DateTime.tryParse(tags['check_date'] ?? ''); + /// Parsed check_date tag, if present and valid. Set to feature release date by default + DateTime? get checkDate => DateTime.tryParse(tags['check_date'] ?? '') ?? kFeatureReleaseDate; - /// Whether this node's check_date is older than [staleDays]. - /// Nodes without a check_date are never considered stale. - bool isStale(int staleDays) { + /// Whether this node's check_date is older than staleDays + double get stalenessProgress { final date = checkDate; - if (date == null) return false; - return DateTime.now().difference(date).inDays > staleDays; + if (date == null) return 0.0; + final ageDays = DateTime.now().difference(date).inDays; + if (ageDays <= kAgingNodeThresholdDays) return 0.0; + if (ageDays >= kStaleNodeThresholdDays) return 1.0; + return (ageDays - kAgingNodeThresholdDays) / (kStaleNodeThresholdDays - kAgingNodeThresholdDays); } bool get hasDirection => directionFovPairs.isNotEmpty; diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index 409cf262..73a03636 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -287,9 +287,9 @@ class PendingUpload { } } - // Add current check_date:surveillance tag for all operations except delete (still there) + // Add current check_date tag for all operations except delete (still there) if (operation != UploadOperation.delete) { - tags['check_date:surveillance'] = DateTime.now() + tags['check_date'] = DateTime.now() .toIso8601String() .split('T') .first; diff --git a/lib/screens/settings/sections/staleness_indicator_section.dart b/lib/screens/settings/sections/staleness_indicator_section.dart index 21add8a7..80167828 100644 --- a/lib/screens/settings/sections/staleness_indicator_section.dart +++ b/lib/screens/settings/sections/staleness_indicator_section.dart @@ -10,8 +10,6 @@ import '../../../../../../../services/localization_service.dart'; class StalenessIndicatorSection extends StatelessWidget { const StalenessIndicatorSection({super.key}); - static const List _thresholdOptions = [30, 60, 180, 365]; - @override Widget build(BuildContext context) { return Consumer( @@ -40,52 +38,6 @@ class StalenessIndicatorSection extends StatelessWidget { }, contentPadding: EdgeInsets.zero, ), - - // Threshold picker (only shown when enabled) - if (appState.stalenessIndicatorEnabled) ...[ - const SizedBox(height: 12), - Row( - children: [ - Text(locService.t('staleness.threshold')), - const SizedBox(width: 12), - DropdownButton( - value: - _thresholdOptions.contains( - appState.stalenessThresholdDays, - ) - ? appState.stalenessThresholdDays - : _thresholdOptions.last, - items: _thresholdOptions - .map( - (days) => DropdownMenuItem( - value: days, - child: Text( - locService.t( - 'staleness.daysCount', - params: [days.toString()], - ), - ), - ), - ) - .toList(), - onChanged: (days) { - if (days != null) { - appState.setStalenessThresholdDays(days); - } - }, - ), - ], - ), - const SizedBox(height: 8), - Text( - locService.t('staleness.explanation'), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), - ), - ), - ], ], ); }, diff --git a/lib/services/map_data_submodules/nodes_from_local.dart b/lib/services/map_data_submodules/nodes_from_local.dart index 4ca37288..5d80c895 100644 --- a/lib/services/map_data_submodules/nodes_from_local.dart +++ b/lib/services/map_data_submodules/nodes_from_local.dart @@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; import '../../models/osm_node.dart'; import '../../models/node_profile.dart'; +import '../../dev_config.dart'; import '../offline_area_service.dart'; import '../offline_areas/offline_area_models.dart'; @@ -58,7 +59,13 @@ Future> _loadAreaNodes(OfflineArea area) async { try { final str = await fileToLoad.readAsString(); final jsonList = jsonDecode(str) as List; - return jsonList.map((e) => OsmNode.fromJson(e)).toList(); + final nodes = jsonList.map((e) => OsmNode.fromJson(e)).toList(); + for (final node in nodes) { + if (!node.tags.containsKey('check_date') || (node.tags['check_date'] ?? '').isEmpty) { + node.tags['check_date'] = kFeatureReleaseDate.toIso8601String().split('T')[0]; + } + } + return nodes; } catch (e) { debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e'); } diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index 0c21dada..b908b9c8 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -7,6 +7,7 @@ import 'package:xml/xml.dart'; import '../../models/node_profile.dart'; import '../../models/osm_node.dart'; import '../../app_state.dart'; +import '../../dev_config.dart'; import '../http_client.dart'; import '../service_policy.dart'; @@ -160,6 +161,10 @@ List _parseOsmApiResponseWithConstraints(XmlDocument document, List.from(element['tags'] ?? {}); + + if (!tags.containsKey('check_date') || (tags['check_date'] ?? '').isEmpty) { + tags['check_date'] = kFeatureReleaseDate.toIso8601String().split('T')[0]; + } return OsmNode( id: nodeId, coord: LatLng(element['lat'], element['lon']), - tags: Map.from(element['tags'] ?? {}), + tags: tags, isConstrained: constrainedNodeIds.contains(nodeId), ); }).toList(); diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 5dcc9141..65061922 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -38,8 +38,6 @@ class SettingsState extends ChangeNotifier { 'proximity_alert_distance'; static const String _stalenessIndicatorEnabledPrefsKey = 'staleness_indicator_enabled'; - static const String _stalenessThresholdDaysPrefsKey = - 'staleness_threshold_days'; static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled'; static const String _suspectedLocationMinDistancePrefsKey = @@ -67,7 +65,6 @@ class SettingsState extends ChangeNotifier { bool _proximityAlertsEnabled = false; int _proximityAlertDistance = kProximityAlertDefaultDistance; bool _stalenessIndicatorEnabled = false; - int _stalenessThresholdDays = 60; // days bool _networkStatusIndicatorEnabled = true; int _suspectedLocationMinDistance = 100; // meters List _tileProviders = []; @@ -84,7 +81,6 @@ class SettingsState extends ChangeNotifier { bool get proximityAlertsEnabled => _proximityAlertsEnabled; int get proximityAlertDistance => _proximityAlertDistance; bool get stalenessIndicatorEnabled => _stalenessIndicatorEnabled; - int get stalenessThresholdDays => _stalenessThresholdDays; bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled; int get suspectedLocationMinDistance => _suspectedLocationMinDistance; bool get keepScreenAwake => _keepScreenAwake; @@ -166,8 +162,6 @@ class SettingsState extends ChangeNotifier { // Load staleness indicator settings _stalenessIndicatorEnabled = prefs.getBool(_stalenessIndicatorEnabledPrefsKey) ?? false; - _stalenessThresholdDays = - prefs.getInt(_stalenessThresholdDaysPrefsKey) ?? 60; // Load network status indicator setting _networkStatusIndicatorEnabled = @@ -456,16 +450,6 @@ class SettingsState extends ChangeNotifier { } } - /// Set staleness threshold in days - Future setStalenessThresholdDays(int days) async { - if (_stalenessThresholdDays != days) { - _stalenessThresholdDays = days; - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_stalenessThresholdDaysPrefsKey, days); - notifyListeners(); - } - } - /// Set network status indicator enabled/disabled Future setNetworkStatusIndicatorEnabled(bool enabled) async { if (_networkStatusIndicatorEnabled != enabled) { diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 256727f4..7210f501 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -178,7 +178,7 @@ class UploadQueueState extends ChangeNotifier { } // Add a "verify" upload: resubmits a node with all existing tags unchanged, - // only bumping check_date:surveillance to confirm it's still present. + // only bumping check_date to confirm it's still present. PendingUpload addFromVerification(OsmNode node, {required UploadMode uploadMode}) { // Passthrough profile carrying no identity tags of its own - everything // comes from additionalExistingTags, so nothing is altered or reformatted diff --git a/lib/widgets/camera_icon.dart b/lib/widgets/camera_icon.dart index 1066a6e1..07b61510 100644 --- a/lib/widgets/camera_icon.dart +++ b/lib/widgets/camera_icon.dart @@ -3,6 +3,7 @@ import '../dev_config.dart'; enum CameraIconType { real, // Blue ring - real cameras from OSM + aging, // Fading ring - staleness in progress (real -> stale gradient) stale, // Brown ring - stale cameras from OSM mock, // White ring - add camera mock point pending, // Purple ring - submitted/pending cameras @@ -14,8 +15,9 @@ enum CameraIconType { /// Simple camera icon with grey dot and colored ring class CameraIcon extends StatelessWidget { final CameraIconType type; + final double agingProgress; - const CameraIcon({super.key, required this.type}); + const CameraIcon({super.key, required this.type, this.agingProgress = 0.0}); Color get _ringColor { switch (type) { @@ -23,6 +25,8 @@ class CameraIcon extends StatelessWidget { return kNodeRingColorReal; case CameraIconType.stale: return kNodeRingColorStale; + case CameraIconType.aging: + return _computeAgingColor(agingProgress); case CameraIconType.mock: return kNodeRingColorMock; case CameraIconType.pending: @@ -36,6 +40,17 @@ class CameraIcon extends StatelessWidget { } } + /// Interpolates between the real (fresh) ring color and the stale ring + /// color based on aging progress (0.0 = just aged past the fresh window, + /// 1.0 = fully stale). + Color _computeAgingColor(double progress) { + return Color.lerp( + kNodeRingColorReal, + kNodeRingColorStale, + progress.clamp(0.0, 1.0), + )!; + } + @override Widget build(BuildContext context) { return Container( diff --git a/lib/widgets/map/marker_layer_builder.dart b/lib/widgets/map/marker_layer_builder.dart index 4201ce0e..0a917a17 100644 --- a/lib/widgets/map/marker_layer_builder.dart +++ b/lib/widgets/map/marker_layer_builder.dart @@ -77,8 +77,7 @@ class MarkerLayerBuilder { onNodeTap: onNodeTap, // Keep the original callback shouldDim: shouldDimNodes, enabled: !shouldDisableNodeTaps, // Use enabled parameter instead - stalenessIndicatorEnabled: appState.stalenessIndicatorEnabled, - stalenessThresholdDays: appState.stalenessThresholdDays, + stalenessIndicatorEnabled: appState.stalenessIndicatorEnabled ); // Build suspected location markers (respect same zoom and count limits as nodes) diff --git a/lib/widgets/map/node_markers.dart b/lib/widgets/map/node_markers.dart index 746a4345..0e2b2ede 100644 --- a/lib/widgets/map/node_markers.dart +++ b/lib/widgets/map/node_markers.dart @@ -15,7 +15,6 @@ class NodeMapMarker extends StatefulWidget { final void Function(OsmNode)? onNodeTap; final bool enabled; final bool stalenessIndicatorEnabled; - final int stalenessThresholdDays; const NodeMapMarker({ required this.node, @@ -23,7 +22,6 @@ class NodeMapMarker extends StatefulWidget { this.onNodeTap, this.enabled = true, this.stalenessIndicatorEnabled = false, - this.stalenessThresholdDays = 60, super.key, }); @@ -90,23 +88,30 @@ class _NodeMapMarkerState extends State { widget.node.tags['_pending_deletion'] == 'true'; CameraIconType iconType; + double agingProgress = 0.0; if (isPendingDeletion) { iconType = CameraIconType.pendingDeletion; } else if (isPendingUpload) { iconType = CameraIconType.pending; } else if (isPendingEdit) { iconType = CameraIconType.pendingEdit; - } else if (widget.stalenessIndicatorEnabled && - widget.node.isStale(widget.stalenessThresholdDays)) { - iconType = CameraIconType.stale; - } else { + } else if (widget.stalenessIndicatorEnabled) { + final progress = widget.node.stalenessProgress; + if (progress >= 1.0) { + iconType = CameraIconType.stale; + } else if (progress > 0.0) { + iconType = CameraIconType.aging; + agingProgress = progress; + } else { + iconType = CameraIconType.real; // ← Add this fallback + } } else { iconType = CameraIconType.real; } return GestureDetector( onTap: _onTap, onDoubleTap: _onDoubleTap, - child: CameraIcon(type: iconType), + child: CameraIcon(type: iconType, agingProgress: agingProgress), ); } } @@ -121,8 +126,7 @@ class NodeMarkersBuilder { void Function(OsmNode)? onNodeTap, bool shouldDim = false, bool enabled = true, - bool stalenessIndicatorEnabled = false, - int stalenessThresholdDays = 60, + bool stalenessIndicatorEnabled = false }) { final markers = [ // Node markers @@ -143,8 +147,7 @@ class NodeMarkersBuilder { mapController: mapController, onNodeTap: onNodeTap, enabled: enabled, - stalenessIndicatorEnabled: stalenessIndicatorEnabled, - stalenessThresholdDays: stalenessThresholdDays, + stalenessIndicatorEnabled: stalenessIndicatorEnabled ), ), ); diff --git a/lib/widgets/node_tag_sheet.dart b/lib/widgets/node_tag_sheet.dart index 1703173f..bf3ba4f8 100644 --- a/lib/widgets/node_tag_sheet.dart +++ b/lib/widgets/node_tag_sheet.dart @@ -160,7 +160,7 @@ class _NodeTagSheetState extends State { void verifyNode() { Navigator.pop(context); // Close this sheet first - appState.verifyNode(node); + appState.verifyNode(displayNode); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(locService.t('node.verifyQueuedForUpload'))), ); @@ -497,35 +497,49 @@ class _NodeTagSheetState extends State { ), const SizedBox(height: 8), // Second row: Edit, Delete, and Close buttons - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (isEditable) ...[ - ElevatedButton.icon( - onPressed: openEditSheet, - icon: const Icon(Icons.edit, size: 18), - label: Text(locService.edit), - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 36), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (isEditable) ...[ + ElevatedButton.icon( + onPressed: openEditSheet, + icon: const Icon(Icons.edit, size: 18), + label: Text(locService.edit), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 36), + ), ), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: displayNode.isConstrained ? null : deleteNode, - icon: const Icon(Icons.delete, size: 18), - label: Text(locService.t('actions.delete')), - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 36), - foregroundColor: displayNode.isConstrained ? null : Colors.red, + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: verifyNode, + icon: const Icon(Icons.check_circle_outline, size: 18), + label: Text(locService.t('actions.verify')), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 36), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: displayNode.isConstrained ? null : deleteNode, + icon: const Icon(Icons.delete, size: 18), + label: Text(locService.t('actions.delete')), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 36), + foregroundColor: displayNode.isConstrained ? null : Colors.red, + ), ), + const SizedBox(width: 12), + ], + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(locService.t('actions.close')), ), - const SizedBox(width: 12), ], - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(locService.t('actions.close')), - ), - ], + ), ), ], ), diff --git a/lib/widgets/refine_tags_sheet.dart b/lib/widgets/refine_tags_sheet.dart index 5ee9e5d7..26884964 100644 --- a/lib/widgets/refine_tags_sheet.dart +++ b/lib/widgets/refine_tags_sheet.dart @@ -141,6 +141,9 @@ class _RefineTagsSheetState extends State { // Skip internal cache tags if (key.startsWith('_')) return true; + + // Skip check_date - handled automatically by the app on each upload + if (key == 'check_date') return true; return false; } From eb3aa969dc0717d112cccd4b46315adfa0ae71a6 Mon Sep 17 00:00:00 2001 From: Asiya Kadivar Date: Sat, 1 Aug 2026 17:38:17 -0400 Subject: [PATCH 06/10] fade, check_date auto-initialization, re-added verify button, set aging/stale thresholds --- lib/dev_config.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index c9d96ba0..44f1d166 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -143,7 +143,7 @@ const Duration kMapLongPressTimeout = Duration( const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); //Release date for stale node detection feature (used as default check_date for nodes without a check_date) -final kFeatureReleaseDate = DateTime.utc(2025, 11, 14); // adjust to actual release date +final kFeatureReleaseDate = DateTime.utc(2026, 8, 1); // adjust to actual release date //Node staleness thresholds (in days) for visual indicator const int kAgingNodeThresholdDays = 60; // Threshold for stale node to begin fading -- "aging" node const int kStaleNodeThresholdDays = 360; // Threshold node to be fully stale From a32de9c4b0823a28ad5a8a19a4234c45daea75af Mon Sep 17 00:00:00 2001 From: Asiya Kadivar Date: Wed, 5 Aug 2026 11:53:15 -0400 Subject: [PATCH 07/10] style cleanup --- lib/app_state.dart | 68 ++--- lib/dev_config.dart | 140 +++------ lib/models/osm_node.dart | 40 +-- lib/models/pending_upload.dart | 73 ++--- lib/state/session_state.dart | 128 +++----- lib/state/settings_state.dart | 80 ++--- lib/state/upload_queue_state.dart | 346 ++++++---------------- lib/widgets/edit_node_sheet.dart | 301 +++++-------------- lib/widgets/map/marker_layer_builder.dart | 47 +-- lib/widgets/map/node_markers.dart | 32 +- lib/widgets/node_provider_with_cache.dart | 3 +- lib/widgets/node_tag_sheet.dart | 31 +- 12 files changed, 353 insertions(+), 936 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index 0761370c..0d73dc92 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -61,8 +61,7 @@ class AppState extends ChangeNotifier { // Positioning tutorial state LatLng? _tutorialStartPosition; // Track where the tutorial started - VoidCallback? - _tutorialCompletionCallback; // Callback when tutorial is completed + VoidCallback? _tutorialCompletionCallback; // Callback when tutorial is completed Timer? _messageCheckTimer; // Node ID to auto-focus (open the details sheet for) after a submit/edit/delete @@ -128,8 +127,7 @@ class AppState extends ChangeNotifier { bool get isSettingSecondPoint => _navigationState.isSettingSecondPoint; bool get areRoutePointsTooClose => _navigationState.areRoutePointsTooClose; double? get distanceFromFirstPoint => _navigationState.distanceFromFirstPoint; - bool get distanceExceedsWarningThreshold => - _navigationState.distanceExceedsWarningThreshold; + bool get distanceExceedsWarningThreshold => _navigationState.distanceExceedsWarningThreshold; bool get isCalculating => _navigationState.isCalculating; bool get showingOverview => _navigationState.showingOverview; String? get routingError => _navigationState.routingError; @@ -137,10 +135,8 @@ class AppState extends ChangeNotifier { // Navigation search state bool get isNavigationSearchLoading => _navigationState.isSearchLoading; - List get navigationSearchResults => - _navigationState.searchResults; - int get navigationAvoidanceDistance => - _settingsState.navigationAvoidanceDistance; + List get navigationSearchResults => _navigationState.searchResults; + int get navigationAvoidanceDistance => _settingsState.navigationAvoidanceDistance; DistanceUnit get distanceUnit => _settingsState.distanceUnit; // Profile state @@ -174,12 +170,9 @@ class AppState extends ChangeNotifier { bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled; int get proximityAlertDistance => _settingsState.proximityAlertDistance; - bool get networkStatusIndicatorEnabled => - _settingsState.networkStatusIndicatorEnabled; - int get suspectedLocationMinDistance => - _settingsState.suspectedLocationMinDistance; - bool get stalenessIndicatorEnabled => - _settingsState.stalenessIndicatorEnabled; + bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled; + int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance; + bool get stalenessIndicatorEnabled => _settingsState.stalenessIndicatorEnabled; // Messages state int? get unreadMessageCount => _messagesState.unreadCount; @@ -203,14 +196,11 @@ class AppState extends ChangeNotifier { // Suspected location state - SuspectedLocation? get selectedSuspectedLocation => - _suspectedLocationState.selectedLocation; + SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation; bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled; bool get suspectedLocationsLoading => _suspectedLocationState.isLoading; - double? get suspectedLocationsDownloadProgress => - _suspectedLocationState.downloadProgress; - Future get suspectedLocationsLastFetch => - _suspectedLocationState.lastFetchTime; + double? get suspectedLocationsDownloadProgress => _suspectedLocationState.downloadProgress; + Future get suspectedLocationsLastFetch => _suspectedLocationState.lastFetchTime; void _onStateChanged() { notifyListeners(); @@ -236,8 +226,7 @@ class AppState extends ChangeNotifier { final existingOperatorProfiles = await OperatorProfileService().load(); final existingNodeProfiles = await ProfileService().load(); - final shouldAddOperatorDefaults = - isFirstLaunch || existingOperatorProfiles.isEmpty; + final shouldAddOperatorDefaults = isFirstLaunch || existingOperatorProfiles.isEmpty; final shouldAddNodeDefaults = isFirstLaunch || existingNodeProfiles.isEmpty; await _operatorProfileState.init(addDefaults: shouldAddOperatorDefaults); @@ -298,11 +287,14 @@ class AppState extends ChangeNotifier { _messageCheckTimer?.cancel(); // Check messages every 10 minutes when logged in - _messageCheckTimer = Timer.periodic(const Duration(minutes: 10), (timer) { - if (isLoggedIn) { - checkMessages(); + _messageCheckTimer = Timer.periodic( + const Duration(minutes: 10), + (timer) { + if (isLoggedIn) { + checkMessages(); + } } - }); + ); } // ---------- Auth Methods ---------- @@ -563,17 +555,11 @@ class AppState extends ChangeNotifier { } void _checkTutorialCompletion(LatLng newPosition) { - if (_tutorialCompletionCallback == null || _tutorialStartPosition == null) { - return; - } + if (_tutorialCompletionCallback == null || _tutorialStartPosition == null) return; // Calculate distance moved final distance = Distance(); - final distanceMoved = distance.as( - LengthUnit.Meter, - _tutorialStartPosition!, - newPosition, - ); + final distanceMoved = distance.as(LengthUnit.Meter, _tutorialStartPosition!, newPosition); if (distanceMoved >= kPositioningTutorialMinMovementMeters) { // Tutorial completed! Mark as complete and notify callback immediately @@ -636,11 +622,7 @@ class AppState extends ChangeNotifier { } void deleteNode(OsmNode node, {String? changesetComment}) { - _uploadQueueState.addFromNodeDeletion( - node, - uploadMode: uploadMode, - changesetComment: changesetComment, - ); + _uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment); _startUploader(); if (kAutoOpenNodeSheetAfterSubmit) { _pendingFocusNodeId = node.id; @@ -685,9 +667,7 @@ class AppState extends ChangeNotifier { } void startRoutePlanning({required bool thisLocationIsStart}) { - _navigationState.startRoutePlanning( - thisLocationIsStart: thisLocationIsStart, - ); + _navigationState.startRoutePlanning(thisLocationIsStart: thisLocationIsStart); } void selectSecondRoutePoint() { @@ -937,9 +917,7 @@ class AppState extends ChangeNotifier { required UploadOperation operation, }) { // Handle temp profiles with brackets by using "a" - final profileName = - profile?.name.startsWith('<') == true && - profile?.name.endsWith('>') == true + final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true ? 'a' : profile?.name ?? 'surveillance'; diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 44f1d166..33ef92cf 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -20,29 +20,18 @@ const double kDirectionConeOpacity = 0.5; // Fill opacity for FOV cones const double _kDirectionConeBorderWidthBase = 1.6; // Bottom button bar positioning -const double kBottomButtonBarOffset = - 4.0; // Distance from screen bottom (above safe area) +const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area) const double kButtonBarHeight = 60.0; // Button height (48) + padding (12) // Map overlay spacing relative to button bar top -const double kAttributionSpacingAboveButtonBar = - 10.0; // Attribution above button bar top -const double kZoomIndicatorSpacingAboveButtonBar = - 40.0; // Zoom indicator above button bar top -const double kScaleBarSpacingAboveButtonBar = - 70.0; // Scale bar above button bar top -const double kZoomControlsSpacingAboveButtonBar = - 20.0; // Zoom controls above button bar top +const double kAttributionSpacingAboveButtonBar = 10.0; // Attribution above button bar top +const double kZoomIndicatorSpacingAboveButtonBar = 40.0; // Zoom indicator above button bar top +const double kScaleBarSpacingAboveButtonBar = 70.0; // Scale bar above button bar top +const double kZoomControlsSpacingAboveButtonBar = 20.0; // Zoom controls above button bar top // Helper to calculate bottom position relative to button bar -double bottomPositionFromButtonBar( - double spacingAboveButtonBar, - double safeAreaBottom, -) { - return safeAreaBottom + - kBottomButtonBarOffset + - kButtonBarHeight + - spacingAboveButtonBar; +double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeAreaBottom) { + return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar; } // Helper to get left positioning that accounts for safe area (for landscape mode) @@ -67,21 +56,12 @@ const String kContactEmail = 'admin@stopflock.com'; const String kHomepageUrl = 'https://deflock.org'; // Upload and changeset configuration -const Duration kUploadHttpTimeout = Duration( - seconds: 30, -); // HTTP request timeout for uploads -const Duration kUploadQueueProcessingInterval = Duration( - seconds: 5, -); // How often to check for new uploads to start -const int kMaxConcurrentUploads = - 5; // Maximum number of uploads processing simultaneously +const Duration kUploadHttpTimeout = Duration(seconds: 30); // HTTP request timeout for uploads +const Duration kUploadQueueProcessingInterval = Duration(seconds: 5); // How often to check for new uploads to start +const int kMaxConcurrentUploads = 5; // Maximum number of uploads processing simultaneously const Duration kChangesetCloseInitialRetryDelay = Duration(seconds: 10); -const Duration kChangesetCloseMaxRetryDelay = Duration( - minutes: 5, -); // Cap at 5 minutes -const Duration kChangesetAutoCloseTimeout = Duration( - minutes: 59, -); // Give up and trust OSM auto-close +const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5 minutes +const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close const double kChangesetCloseBackoffMultiplier = 2.0; // How long to keep the "Submission Complete" confirmation visible in the @@ -92,36 +72,28 @@ const Duration kInFlightUploadCompleteDisplayDuration = Duration(seconds: 2); // Overpass API configuration -const Duration kOverpassQueryTimeout = Duration( - seconds: 45, -); // Timeout for Overpass API queries (was 25s hardcoded) +const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded) // Suspected locations CSV URL -const String kSuspectedLocationsCsvUrl = - 'https://alprwatch.org/suspected-locations/deflock-latest.csv'; +const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv'; // Development/testing features - set to false for production builds -const bool kEnableDevelopmentModes = - true; // 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 // Navigation features - set to false to hide navigation UI elements while in development -const bool kEnableNavigationFeatures = - true; // Hide navigation until fully implemented +const bool kEnableNavigationFeatures = true; // Hide navigation until fully implemented // Node editing features - set to false to temporarily disable editing -const bool kEnableNodeEdits = - true; // Set to false to temporarily disable node editing +const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing // Node extraction features - set to false to hide extract functionality for constrained nodes -const bool kEnableNodeExtraction = - false; // Set to true to enable extract from way/relation feature (WIP) +const bool kEnableNodeExtraction = false; // Set to true to enable extract from way/relation feature (WIP) // Auto-open tags sheet after add/edit/delete, so the user can watch upload status update live. const bool kAutoOpenNodeSheetAfterSubmit = true; // Set to true to enable auto-focus behavior // Profile FOV features - set to false to restrict profiles to 360° FOV only -const bool kEnableNon360FOVs = - false; // Set to true to allow custom FOV values in profiles +const bool kEnableNon360FOVs = false; // Set to true to allow custom FOV values in profiles /// Navigation availability: only dev builds, and only when online bool enableNavigationFeatures({required bool offlineMode}) { @@ -130,20 +102,15 @@ bool enableNavigationFeatures({required bool offlineMode}) { // Marker/node interaction const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass) -const int kOsmApiMinZoomLevel = - 13; // Minimum zoom for OSM API bbox queries (sandbox mode) -const int kMinZoomForNodeEditingSheets = - 16; // Minimum zoom to open add/edit node sheets -const int kMinZoomForOfflineDownload = - 10; // Minimum zoom to download offline areas (prevents large area crashes) +const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode) +const int kMinZoomForNodeEditingSheets = 16; // Minimum zoom to open add/edit node sheets +const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes) const Duration kMarkerTapTimeout = Duration(milliseconds: 250); -const Duration kMapLongPressTimeout = Duration( - milliseconds: 600, -); // Duration to trigger "add node here" on empty map area +const Duration kMapLongPressTimeout = Duration(milliseconds: 600); // Duration to trigger "add node here" on empty map area const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); //Release date for stale node detection feature (used as default check_date for nodes without a check_date) -final kFeatureReleaseDate = DateTime.utc(2026, 8, 1); // adjust to actual release date +final kFeatureReleaseDate = DateTime.utc(2026, 8, 5); // adjust to actual release date //Node staleness thresholds (in days) for visual indicator const int kAgingNodeThresholdDays = 60; // Threshold for stale node to begin fading -- "aging" node const int kStaleNodeThresholdDays = 360; // Threshold node to be fully stale @@ -157,74 +124,53 @@ const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for co const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit // Data refresh configuration -const int kDataRefreshIntervalSeconds = - 60; // Refresh cached data after this many seconds +const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds // Follow-me mode smooth transitions const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600); -const double kMinSpeedForRotationMps = - 1.0; // Minimum speed (m/s) to apply rotation +const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation // Sheet content configuration -const double kMaxTagListHeightRatioPortrait = - 0.3; // Maximum height for tag lists in portrait mode -const double kMaxTagListHeightRatioLandscape = - 0.2; // Maximum height for tag lists in landscape mode +const double kMaxTagListHeightRatioPortrait = 0.3; // Maximum height for tag lists in portrait mode +const double kMaxTagListHeightRatioLandscape = 0.2; // Maximum height for tag lists in landscape mode /// Get appropriate tag list height ratio based on screen orientation double getTagListHeightRatio(BuildContext context) { final size = MediaQuery.of(context).size; final isLandscape = size.width > size.height; - return isLandscape - ? kMaxTagListHeightRatioLandscape - : kMaxTagListHeightRatioPortrait; + return isLandscape ? kMaxTagListHeightRatioLandscape : kMaxTagListHeightRatioPortrait; } // Proximity alerts configuration const int kProximityAlertDefaultDistance = 400; // meters const int kProximityAlertMinDistance = 50; // meters const int kProximityAlertMaxDistance = 1600; // meters -const Duration kProximityAlertCooldown = Duration( - minutes: 10, -); // Cooldown between alerts for same node +const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node // Node proximity warning configuration (for new/edited nodes that are too close to existing ones) -const double kNodeProximityWarningDistance = - 50.0; // meters - distance threshold to show warning +const double kNodeProximityWarningDistance = 50.0; // meters - distance threshold to show warning // Positioning tutorial configuration -const double kPositioningTutorialBlurSigma = - 3.0; // Blur strength for sheet overlay -const double kPositioningTutorialMinMovementMeters = - 1.0; // Minimum map movement to complete tutorial +const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay +const double kPositioningTutorialMinMovementMeters = 1.0; // Minimum map movement to complete tutorial // Navigation route planning configuration -const double kNavigationMinRouteDistance = - 100.0; // meters - minimum distance between start and end points -const double kNavigationDistanceWarningThreshold = - 300000.0; // meters - distance threshold for timeout warning (30km) +const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points +const double kNavigationDistanceWarningThreshold = 300000.0; // meters - distance threshold for timeout warning (30km) // Node display configuration -const int kDefaultMaxNodes = - 500; // Default maximum number of nodes to render on the map at once +const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once // NSI (Name Suggestion Index) configuration -const int kNSIMinimumHitCount = - 500; // Minimum hit count for NSI suggestions to be considered useful -const int kNSIMaxSuggestions = - 10; // Maximum number of tag value suggestions to fetch and display +const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful +const int kNSIMaxSuggestions = 10; // Maximum number of tag value suggestions to fetch and display // Map interaction configuration -const double kNodeDoubleTapZoomDelta = - 1.0; // How much to zoom in when double-tapping nodes (was 1.0) -const double kScrollWheelVelocity = - 0.01; // Mouse scroll wheel zoom speed (default 0.005) -const double kPinchZoomThreshold = - 0.2; // How much pinch required to start zoom (reduced for gesture race) -const double kPinchMoveThreshold = - 30.0; // How much drag required for two-finger pan (default 40.0) -const double kRotationThreshold = - 6.0; // Degrees of rotation required before map actually rotates (Google Maps style) +const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0) +const double kScrollWheelVelocity = 0.01; // Mouse scroll wheel zoom speed (default 0.005) +const double kPinchZoomThreshold = 0.2; // How much pinch required to start zoom (reduced for gesture race) +const double kPinchMoveThreshold = 30.0; // How much drag required for two-finger pan (default 40.0) +const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style) // User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min) const int kMaxUserDownloadZoomSpan = 7; diff --git a/lib/models/osm_node.dart b/lib/models/osm_node.dart index 955e22ed..cb99346f 100644 --- a/lib/models/osm_node.dart +++ b/lib/models/osm_node.dart @@ -12,8 +12,7 @@ class OsmNode { required this.id, required this.coord, required this.tags, - this.isConstrained = - false, // Default to unconstrained for backward compatibility + this.isConstrained = false, // Default to unconstrained for backward compatibility }); Map toJson() => { @@ -37,17 +36,10 @@ class OsmNode { } return OsmNode( - id: json['id'] is int - ? json['id'] as int - : int.tryParse(json['id'].toString()) ?? 0, - coord: LatLng( - (json['lat'] as num).toDouble(), - (json['lon'] as num).toDouble(), - ), + id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0, + coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()), tags: tags, - isConstrained: - json['isConstrained'] as bool? ?? - false, // Default to false for backward compatibility + isConstrained: json['isConstrained'] as bool? ?? false, // Default to false for backward compatibility ); } @@ -73,22 +65,10 @@ class OsmNode { // Compass direction to degree mapping const compassDirections = { - 'N': 0.0, - 'NNE': 22.5, - 'NE': 45.0, - 'ENE': 67.5, - 'E': 90.0, - 'ESE': 112.5, - 'SE': 135.0, - 'SSE': 157.5, - 'S': 180.0, - 'SSW': 202.5, - 'SW': 225.0, - 'WSW': 247.5, - 'W': 270.0, - 'WNW': 292.5, - 'NW': 315.0, - 'NNW': 337.5, + 'N': 0.0, 'NNE': 22.5, 'NE': 45.0, 'ENE': 67.5, + 'E': 90.0, 'ESE': 112.5, 'SE': 135.0, 'SSE': 157.5, + 'S': 180.0, 'SSW': 202.5, 'SW': 225.0, 'WSW': 247.5, + 'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5, }; final directionFovList = []; @@ -120,9 +100,7 @@ class OsmNode { // First try compass direction lookup if (compassDirections.containsKey(trimmedUpper)) { final degrees = compassDirections[trimmedUpper]!; - directionFovList.add( - DirectionFov(degrees, kDirectionConeHalfAngle * 2), - ); + directionFovList.add(DirectionFov(degrees, kDirectionConeHalfAngle * 2)); continue; } diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index 73a03636..bbe2aa0b 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -69,8 +69,7 @@ class PendingUpload { additionalExistingTags = additionalExistingTags ?? {}, assert( (operation == UploadOperation.create && originalNodeId == null) || - (operation == UploadOperation.create) || - (originalNodeId != null), + (operation == UploadOperation.create) || (originalNodeId != null), 'originalNodeId must be null for create operations and non-null for modify/delete/extract operations', ), assert( @@ -89,10 +88,8 @@ class PendingUpload { // New state-based helpers bool get needsUserRetry => uploadState == UploadState.error; - bool get isActivelyProcessing => - uploadState == UploadState.creatingChangeset || - uploadState == UploadState.uploading || - uploadState == UploadState.closingChangeset; + bool get isActivelyProcessing => uploadState == UploadState.creatingChangeset || uploadState == UploadState.uploading || +uploadState == UploadState.closingChangeset; bool get isComplete => uploadState == UploadState.complete; bool get isPending => uploadState == UploadState.pending; bool get isCreatingChangeset => uploadState == UploadState.creatingChangeset; @@ -112,8 +109,7 @@ class PendingUpload { // This uses nodeOperationCompletedAt (when changeset was created) as the reference bool get hasChangesetExpired { if (nodeOperationCompletedAt == null) return false; - return DateTime.now().difference(nodeOperationCompletedAt!) >= - kChangesetAutoCloseTimeout; + return DateTime.now().difference(nodeOperationCompletedAt!) >= kChangesetAutoCloseTimeout; } // Legacy method name for backward compatibility @@ -122,26 +118,19 @@ class PendingUpload { // Calculate next retry delay for changeset close using exponential backoff Duration get nextChangesetCloseRetryDelay { final delay = Duration( - milliseconds: - (kChangesetCloseInitialRetryDelay.inMilliseconds * - math.pow( - kChangesetCloseBackoffMultiplier, - changesetCloseAttempts, - )) - .round(), + milliseconds: (kChangesetCloseInitialRetryDelay.inMilliseconds * + math.pow(kChangesetCloseBackoffMultiplier, changesetCloseAttempts)).round() ); return delay > kChangesetCloseMaxRetryDelay - ? kChangesetCloseMaxRetryDelay - : delay; + ? kChangesetCloseMaxRetryDelay + : delay; } // Check if it's time to retry changeset close bool get isReadyForChangesetCloseRetry { if (lastChangesetCloseAttemptAt == null) return true; // First attempt - final nextRetryTime = lastChangesetCloseAttemptAt!.add( - nextChangesetCloseRetryDelay, - ); + final nextRetryTime = lastChangesetCloseAttemptAt!.add(nextChangesetCloseRetryDelay); return DateTime.now().isAfter(nextRetryTime); } @@ -190,8 +179,7 @@ class PendingUpload { void markChangesetCreated(String csId) { uploadState = UploadState.uploading; changesetId = csId; - nodeOperationCompletedAt = - DateTime.now(); // Track when changeset was created for 59-minute timeout + nodeOperationCompletedAt = DateTime.now(); // Track when changeset was created for 59-minute timeout } // Mark node operation as complete, start changeset close phase @@ -224,26 +212,19 @@ class PendingUpload { // Calculate next retry delay for node submission using exponential backoff Duration get nextNodeSubmissionRetryDelay { final delay = Duration( - milliseconds: - (kChangesetCloseInitialRetryDelay.inMilliseconds * - math.pow( - kChangesetCloseBackoffMultiplier, - nodeSubmissionAttempts, - )) - .round(), + milliseconds: (kChangesetCloseInitialRetryDelay.inMilliseconds * + math.pow(kChangesetCloseBackoffMultiplier, nodeSubmissionAttempts)).round() ); return delay > kChangesetCloseMaxRetryDelay - ? kChangesetCloseMaxRetryDelay - : delay; + ? kChangesetCloseMaxRetryDelay + : delay; } // Check if it's time to retry node submission bool get isReadyForNodeSubmissionRetry { if (lastNodeSubmissionAttemptAt == null) return true; // First attempt - final nextRetryTime = lastNodeSubmissionAttemptAt!.add( - nextNodeSubmissionRetryDelay, - ); + final nextRetryTime = lastNodeSubmissionAttemptAt!.add(nextNodeSubmissionRetryDelay); return DateTime.now().isAfter(nextRetryTime); } @@ -265,8 +246,7 @@ class PendingUpload { // Apply refined tags (these fill in empty values from the profile) for (final entry in refinedTags.entries) { // Only apply refined tags if the profile tag value is empty - if (profile!.tags.containsKey(entry.key) && - profile!.tags[entry.key]?.trim().isEmpty == true) { + if (profile!.tags.containsKey(entry.key) && profile!.tags[entry.key]?.trim().isEmpty == true) { tags[entry.key] = entry.value; } } @@ -322,14 +302,11 @@ class PendingUpload { 'completing': completing, 'uploadState': uploadState.index, 'changesetId': changesetId, - 'nodeOperationCompletedAt': - nodeOperationCompletedAt?.millisecondsSinceEpoch, + 'nodeOperationCompletedAt': nodeOperationCompletedAt?.millisecondsSinceEpoch, 'changesetCloseAttempts': changesetCloseAttempts, - 'lastChangesetCloseAttemptAt': - lastChangesetCloseAttemptAt?.millisecondsSinceEpoch, + 'lastChangesetCloseAttemptAt': lastChangesetCloseAttemptAt?.millisecondsSinceEpoch, 'nodeSubmissionAttempts': nodeSubmissionAttempts, - 'lastNodeSubmissionAttemptAt': - lastNodeSubmissionAttemptAt?.millisecondsSinceEpoch, + 'lastNodeSubmissionAttemptAt': lastNodeSubmissionAttemptAt?.millisecondsSinceEpoch, }; factory PendingUpload.fromJson(Map j) => PendingUpload( @@ -347,17 +324,13 @@ class PendingUpload { additionalExistingTags: j['additionalExistingTags'] != null ? Map.from(j['additionalExistingTags']) : {}, // Default empty map for legacy entries - changesetComment: - j['changesetComment'] ?? - _generateLegacyComment(j), // Default 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 operation: j['operation'] != null ? UploadOperation.values[j['operation']] - : (j['originalNodeId'] != null - ? UploadOperation.modify - : UploadOperation.create), // Legacy compatibility + : (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create), // Legacy compatibility originalNodeId: j['originalNodeId'], submittedNodeId: j['submittedNodeId'], tempNodeId: j['tempNodeId'], @@ -398,9 +371,7 @@ class PendingUpload { static String _generateLegacyComment(Map j) { final operation = j['operation'] != null ? UploadOperation.values[j['operation']] - : (j['originalNodeId'] != null - ? UploadOperation.modify - : UploadOperation.create); + : (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create); final profileName = j['profile']?['name'] ?? 'surveillance'; diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index 85ba6c3e..db46a93a 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -12,12 +12,9 @@ class AddNodeSession { OperatorProfile? operatorProfile; LatLng? target; 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 - Map - additionalExistingTags; // For consistency (always empty for new nodes) + int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°) + Map refinedTags; // User-selected values for empty profile tags + Map additionalExistingTags; // For consistency (always empty for new nodes) String changesetComment; // User-editable changeset comment AddNodeSession({ @@ -31,13 +28,11 @@ class AddNodeSession { }) : directions = [initialDirection], currentDirectionIndex = 0, refinedTags = refinedTags ?? {}, - additionalExistingTags = - additionalExistingTags ?? {}, // Always empty for new nodes + additionalExistingTags = additionalExistingTags ?? {}, // Always empty for new nodes changesetComment = changesetComment ?? ''; // Slider always shows the current direction being edited - double get directionDegrees => - directions.isNotEmpty && currentDirectionIndex >= 0 + double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0 ? directions[currentDirectionIndex] : 0.0; set directionDegrees(double value) { @@ -55,13 +50,10 @@ class EditNodeSession { OperatorProfile? operatorProfile; LatLng target; // Current position (can be dragged) List directions; // All directions [90, 180, 270] - int - currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°) + 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 - Map - additionalExistingTags; // Tags that exist on node but not in profile + Map refinedTags; // User-selected values for empty profile tags + Map additionalExistingTags; // Tags that exist on node but not in profile String changesetComment; // User-editable changeset comment EditNodeSession({ @@ -82,8 +74,7 @@ class EditNodeSession { changesetComment = changesetComment ?? ''; // Slider always shows the current direction being edited - double get directionDegrees => - directions.isNotEmpty && currentDirectionIndex >= 0 + double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0 ? directions[currentDirectionIndex] : 0.0; set directionDegrees(double value) { @@ -105,42 +96,27 @@ class SessionState extends ChangeNotifier { void startAddSession(List enabledProfiles) { // Start with no profile selected - force user to choose _session = AddNodeSession( - changesetComment: - 'Add surveillance node', // Default comment, will be updated when profile is selected + changesetComment: 'Add surveillance node', // Default comment, will be updated when profile is selected ); _editSession = null; // Clear any edit session notifyListeners(); } - void startEditSession( - OsmNode node, - List enabledProfiles, - List operatorProfiles, - ) { + void startEditSession(OsmNode node, List enabledProfiles, List operatorProfiles) { // Always create and pre-select the temporary "existing tags" profile (now empty) final existingTagsProfile = NodeProfile.createExistingTagsProfile(node); // Detect and store operator profile (persists across profile changes) - _detectedOperatorProfile = OperatorProfile.createExistingOperatorProfile( - node, - operatorProfiles, - ); + _detectedOperatorProfile = OperatorProfile.createExistingOperatorProfile(node, operatorProfiles); // Initialize edit session with all existing directions, or empty list if none - final existingDirections = node.directionDeg.isNotEmpty - ? node.directionDeg - : []; - final initialDirection = existingDirections.isNotEmpty - ? existingDirections.first - : 0.0; + final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : []; + final initialDirection = existingDirections.isNotEmpty ? existingDirections.first : 0.0; final originalHadDirections = existingDirections.isNotEmpty; // Since the "existing tags" profile is now empty, all existing node tags // (minus special ones) should go into additionalExistingTags - final initialAdditionalTags = _calculateAdditionalExistingTags( - existingTagsProfile, - node, - ); + final initialAdditionalTags = _calculateAdditionalExistingTags(existingTagsProfile, node); // Auto-populate refined tags (empty profile means no refined tags initially) final initialRefinedTags = _calculateRefinedTags(existingTagsProfile, node); @@ -154,24 +130,18 @@ class SessionState extends ChangeNotifier { target: node.coord, additionalExistingTags: initialAdditionalTags, refinedTags: initialRefinedTags, - changesetComment: - 'Update a surveillance node', // Default comment for existing tags profile + changesetComment: 'Update a surveillance node', // Default comment for existing tags profile ); // Replace the default single direction with all existing directions (or empty list) _editSession!.directions = List.from(existingDirections); - _editSession!.currentDirectionIndex = existingDirections.isNotEmpty - ? 0 - : -1; // -1 indicates no directions + _editSession!.currentDirectionIndex = existingDirections.isNotEmpty ? 0 : -1; // -1 indicates no directions _session = null; // Clear any add session notifyListeners(); } /// Calculate additional existing tags for a given profile change - Map _calculateAdditionalExistingTags( - NodeProfile? newProfile, - OsmNode originalNode, - ) { + Map _calculateAdditionalExistingTags(NodeProfile? newProfile, OsmNode originalNode) { final additionalTags = {}; // Skip if no profile @@ -201,10 +171,7 @@ class SessionState extends ChangeNotifier { } /// Auto-populate refined tags with existing values from the original node - Map _calculateRefinedTags( - NodeProfile? profile, - OsmNode originalNode, - ) { + Map _calculateRefinedTags(NodeProfile? profile, OsmNode originalNode) { final refinedTags = {}; if (profile == null) return refinedTags; @@ -280,9 +247,7 @@ class SessionState extends ChangeNotifier { dirty = true; } if (additionalExistingTags != null) { - _session!.additionalExistingTags = Map.from( - additionalExistingTags, - ); + _session!.additionalExistingTags = Map.from(additionalExistingTags); dirty = true; } if (changesetComment != null) { @@ -309,8 +274,7 @@ class SessionState extends ChangeNotifier { bool snapBackRequired = false; LatLng? snapBackTarget; - if (directionDeg != null && - directionDeg != _editSession!.directionDegrees) { + if (directionDeg != null && directionDeg != _editSession!.directionDegrees) { _editSession!.directionDegrees = directionDeg; dirty = true; } @@ -330,25 +294,17 @@ class SessionState extends ChangeNotifier { // Calculate additional existing tags for non-existing-tags profiles // Only do this if additionalExistingTags wasn't explicitly provided if (additionalExistingTags == null) { - _editSession!.additionalExistingTags = _calculateAdditionalExistingTags( - profile, - _editSession!.originalNode, - ); + _editSession!.additionalExistingTags = _calculateAdditionalExistingTags(profile, _editSession!.originalNode); } // Auto-populate refined tags with existing values for empty profile tags // Only do this if refinedTags wasn't explicitly provided if (refinedTags == null) { - _editSession!.refinedTags = _calculateRefinedTags( - profile, - _editSession!.originalNode, - ); + _editSession!.refinedTags = _calculateRefinedTags(profile, _editSession!.originalNode); } // Regenerate changeset comment when profile changes - final operation = _editSession!.extractFromWay - ? UploadOperation.extract - : UploadOperation.modify; + final operation = _editSession!.extractFromWay ? UploadOperation.extract : UploadOperation.modify; _editSession!.changesetComment = _generateDefaultChangesetComment( profile: profile, operation: operation, @@ -357,8 +313,7 @@ class SessionState extends ChangeNotifier { dirty = true; } // Only update operator profile when explicitly requested - if (updateOperatorProfile && - operatorProfile != _editSession!.operatorProfile) { + if (updateOperatorProfile && operatorProfile != _editSession!.operatorProfile) { _editSession!.operatorProfile = operatorProfile; // This can be null dirty = true; } @@ -366,8 +321,7 @@ class SessionState extends ChangeNotifier { _editSession!.target = target; dirty = true; } - if (extractFromWay != null && - extractFromWay != _editSession!.extractFromWay) { + if (extractFromWay != null && extractFromWay != _editSession!.extractFromWay) { _editSession!.extractFromWay = extractFromWay; // When extract is unchecked, snap back to original location if (!extractFromWay) { @@ -383,9 +337,7 @@ class SessionState extends ChangeNotifier { dirty = true; } if (additionalExistingTags != null) { - _editSession!.additionalExistingTags = Map.from( - additionalExistingTags, - ); + _editSession!.additionalExistingTags = Map.from(additionalExistingTags); dirty = true; } if (changesetComment != null) { @@ -441,10 +393,8 @@ class SessionState extends ChangeNotifier { _editSession!.directions.removeAt(_editSession!.currentDirectionIndex); if (_editSession!.directions.isEmpty) { _editSession!.currentDirectionIndex = -1; // No directions - } else if (_editSession!.currentDirectionIndex >= - _editSession!.directions.length) { - _editSession!.currentDirectionIndex = - _editSession!.directions.length - 1; + } else if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) { + _editSession!.currentDirectionIndex = _editSession!.directions.length - 1; } notifyListeners(); } @@ -454,15 +404,10 @@ class SessionState extends ChangeNotifier { // Cycle to next direction void cycleDirection() { if (_session != null && _session!.directions.length > 1) { - _session!.currentDirectionIndex = - (_session!.currentDirectionIndex + 1) % _session!.directions.length; + _session!.currentDirectionIndex = (_session!.currentDirectionIndex + 1) % _session!.directions.length; notifyListeners(); - } else if (_editSession != null && - _editSession!.directions.length > 1 && - _editSession!.currentDirectionIndex >= 0) { - _editSession!.currentDirectionIndex = - (_editSession!.currentDirectionIndex + 1) % - _editSession!.directions.length; + } else if (_editSession != null && _editSession!.directions.length > 1 && _editSession!.currentDirectionIndex >= 0) { + _editSession!.currentDirectionIndex = (_editSession!.currentDirectionIndex + 1) % _editSession!.directions.length; notifyListeners(); } } @@ -513,10 +458,7 @@ class SessionState extends ChangeNotifier { } /// Handle direction requirements when profile changes in edit session - void _handleDirectionRequirementsOnProfileChange( - NodeProfile? oldProfile, - NodeProfile newProfile, - ) { + void _handleDirectionRequirementsOnProfileChange(NodeProfile? oldProfile, NodeProfile newProfile) { if (_editSession == null) return; final minimum = _getMinimumDirections(); @@ -535,9 +477,7 @@ class SessionState extends ChangeNotifier { required UploadOperation operation, }) { // Handle temp profiles with brackets by using "a" - final profileName = - profile?.name.startsWith('<') == true && - profile?.name.endsWith('>') == true + final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true ? 'a' : profile?.name ?? 'surveillance'; diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 65061922..1f54e04b 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -32,19 +32,13 @@ class SettingsState extends ChangeNotifier { static const String _selectedTileTypePrefsKey = 'selected_tile_type'; static const String _legacyTestModePrefsKey = 'test_mode'; static const String _followMeModePrefsKey = 'follow_me_mode'; - static const String _proximityAlertsEnabledPrefsKey = - 'proximity_alerts_enabled'; - static const String _proximityAlertDistancePrefsKey = - 'proximity_alert_distance'; - static const String _stalenessIndicatorEnabledPrefsKey = - 'staleness_indicator_enabled'; - static const String _networkStatusIndicatorEnabledPrefsKey = - 'network_status_indicator_enabled'; - static const String _suspectedLocationMinDistancePrefsKey = - 'suspected_location_min_distance'; + static const String _proximityAlertsEnabledPrefsKey = 'proximity_alerts_enabled'; + static const String _proximityAlertDistancePrefsKey = 'proximity_alert_distance'; + static const String _stalenessIndicatorEnabledPrefsKey = 'staleness_indicator_enabled'; + static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled'; + static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance'; static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing'; - static const String _navigationAvoidanceDistancePrefsKey = - 'navigation_avoidance_distance'; + static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance'; static const String _distanceUnitPrefsKey = 'distance_unit'; static const String _keepScreenAwakePrefsKey = 'keep_screen_awake'; static const String _hideZoomControlsPrefsKey = 'hide_zoom_controls'; @@ -58,9 +52,7 @@ class SettingsState extends ChangeNotifier { int _maxNodes = kDefaultMaxNodes; // Default must account for missing secrets (preview builds) even before init() runs - UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) - ? UploadMode.simulate - : UploadMode.production; + UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production; FollowMeMode _followMeMode = FollowMeMode.follow; bool _proximityAlertsEnabled = false; int _proximityAlertDistance = kProximityAlertDefaultDistance; @@ -132,16 +124,14 @@ class SettingsState extends ChangeNotifier { _offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false; // Load queue processing setting - _pauseQueueProcessing = - prefs.getBool(_pauseQueueProcessingPrefsKey) ?? false; + _pauseQueueProcessing = prefs.getBool(_pauseQueueProcessingPrefsKey) ?? false; // Load max nodes _maxNodes = prefs.getInt(_maxNodesPrefsKey) ?? kDefaultMaxNodes; // Load navigation avoidance distance if (prefs.containsKey(_navigationAvoidanceDistancePrefsKey)) { - _navigationAvoidanceDistance = - prefs.getInt(_navigationAvoidanceDistancePrefsKey) ?? 250; + _navigationAvoidanceDistance = prefs.getInt(_navigationAvoidanceDistancePrefsKey) ?? 250; } // Load distance unit @@ -153,23 +143,17 @@ class SettingsState extends ChangeNotifier { } // Load proximity alerts settings - _proximityAlertsEnabled = - prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false; - _proximityAlertDistance = - prefs.getInt(_proximityAlertDistancePrefsKey) ?? - kProximityAlertDefaultDistance; + _proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false; + _proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance; // Load staleness indicator settings - _stalenessIndicatorEnabled = - prefs.getBool(_stalenessIndicatorEnabledPrefsKey) ?? false; + _stalenessIndicatorEnabled = prefs.getBool(_stalenessIndicatorEnabledPrefsKey) ?? false; // Load network status indicator setting - _networkStatusIndicatorEnabled = - prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? true; + _networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? true; // Load suspected location minimum distance - _suspectedLocationMinDistance = - prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100; + _suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100; // Load keep screen awake setting _keepScreenAwake = prefs.getBool(_keepScreenAwakePrefsKey) ?? false; @@ -204,17 +188,11 @@ class SettingsState extends ChangeNotifier { // 2. Production builds — force production (prefs may have sandbox/simulate // from a previous dev build on the same device) if (!kHasOsmSecrets && _uploadMode != UploadMode.simulate) { - debugPrint( - 'SettingsState: No OSM secrets available, forcing simulate mode', - ); + debugPrint('SettingsState: No OSM secrets available, forcing simulate mode'); _uploadMode = UploadMode.simulate; await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); - } else if (kHasOsmSecrets && - !kEnableDevelopmentModes && - _uploadMode != UploadMode.production) { - debugPrint( - 'SettingsState: Development modes disabled, forcing production mode', - ); + } else if (kHasOsmSecrets && !kEnableDevelopmentModes && _uploadMode != UploadMode.production) { + debugPrint('SettingsState: Development modes disabled, forcing production mode'); _uploadMode = UploadMode.production; await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); } @@ -276,9 +254,7 @@ class SettingsState extends ChangeNotifier { if (!existingProviderIds.contains(defaultProvider.id)) { _tileProviders.add(defaultProvider); hasUpdates = true; - debugPrint( - 'SettingsState: Added missing built-in provider: ${defaultProvider.name}', - ); + debugPrint('SettingsState: Added missing built-in provider: ${defaultProvider.name}'); } } @@ -362,19 +338,13 @@ class SettingsState extends ChangeNotifier { // Don't allow deleting all providers if (_tileProviders.length <= 1) return; - final providerToDelete = _tileProviders.firstWhereOrNull( - (p) => p.id == providerId, - ); + final providerToDelete = _tileProviders.firstWhereOrNull((p) => p.id == providerId); if (providerToDelete == null) return; // If selected tile type belongs to this provider, switch to another - if (providerToDelete.tileTypes.any( - (type) => type.id == _selectedTileTypeId, - )) { + if (providerToDelete.tileTypes.any((type) => type.id == _selectedTileTypeId)) { // Find first available tile type from remaining providers - final remainingProviders = _tileProviders - .where((p) => p.id != providerId) - .toList(); + final remainingProviders = _tileProviders.where((p) => p.id != providerId).toList(); final firstAvailable = remainingProviders .expand((p) => p.availableTileTypes) .firstOrNull; @@ -426,12 +396,8 @@ class SettingsState extends ChangeNotifier { /// Set proximity alert distance in meters Future setProximityAlertDistance(int distance) async { - if (distance < kProximityAlertMinDistance) { - distance = kProximityAlertMinDistance; - } - if (distance > kProximityAlertMaxDistance) { - distance = kProximityAlertMaxDistance; - } + if (distance < kProximityAlertMinDistance) distance = kProximityAlertMinDistance; + if (distance > kProximityAlertMaxDistance) distance = kProximityAlertMaxDistance; if (_proximityAlertDistance != distance) { _proximityAlertDistance = distance; final prefs = await SharedPreferences.getInstance(); diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 7210f501..64cd4428 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -35,23 +35,17 @@ class UploadQueueState extends ChangeNotifier { // Repopulate the cache with pending nodes from the queue on startup void _repopulateCacheFromQueue() { - debugPrint( - '[UploadQueue] Repopulating cache from ${_queue.length} queue items', - ); + debugPrint('[UploadQueue] Repopulating cache from ${_queue.length} queue items'); final nodesToAdd = []; for (final upload in _queue) { // Skip completed uploads - they should already be in OSM and will be fetched normally if (upload.isComplete) { - debugPrint( - '[UploadQueue] Skipping completed upload at ${upload.coord}', - ); + debugPrint('[UploadQueue] Skipping completed upload at ${upload.coord}'); continue; } - debugPrint( - '[UploadQueue] Processing ${upload.operation} upload at ${upload.coord}', - ); + debugPrint('[UploadQueue] Processing ${upload.operation} upload at ${upload.coord}'); if (upload.isDeletion) { // For deletions: mark the original node as pending deletion if it exists in cache @@ -72,9 +66,7 @@ class UploadQueueState extends ChangeNotifier { } else { // For creates, edits, and extracts: recreate temp node if needed // Generate new temp ID if not already stored (for backward compatibility) - final tempId = - upload.tempNodeId ?? - -DateTime.now().millisecondsSinceEpoch - _queue.indexOf(upload); + final tempId = upload.tempNodeId ?? -DateTime.now().millisecondsSinceEpoch - _queue.indexOf(upload); final tags = upload.getCombinedTags(); tags['_pending_upload'] = 'true'; @@ -86,13 +78,9 @@ class UploadQueueState extends ChangeNotifier { if (upload.isEdit) { // For edits: also mark original with _pending_edit if it exists if (upload.originalNodeId != null) { - final existingOriginal = _nodeCache.getNodeById( - upload.originalNodeId!, - ); + final existingOriginal = _nodeCache.getNodeById(upload.originalNodeId!); if (existingOriginal != null) { - final originalTags = Map.from( - existingOriginal.tags, - ); + final originalTags = Map.from(existingOriginal.tags); originalTags['_pending_edit'] = 'true'; final originalWithEdit = OsmNode( @@ -118,9 +106,7 @@ class UploadQueueState extends ChangeNotifier { if (nodesToAdd.isNotEmpty) { _nodeCache.addOrUpdate(nodesToAdd); - debugPrint( - '[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue', - ); + debugPrint('[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue'); // Save queue if we updated any temp IDs for backward compatibility _saveQueue(); @@ -131,22 +117,14 @@ class UploadQueueState extends ChangeNotifier { } // Add a completed session to the upload queue - PendingUpload addFromSession( - AddNodeSession session, { - required UploadMode uploadMode, - }) { + PendingUpload addFromSession(AddNodeSession session, {required UploadMode uploadMode}) { final upload = PendingUpload( coord: session.target!, - direction: _formatDirectionsForSubmission( - session.directions, - session.profile, - ), - profile: session - .profile!, // Safe to use ! because commitSession() checks for null + direction: _formatDirectionsForSubmission(session.directions, session.profile), + profile: session.profile!, // Safe to use ! because commitSession() checks for null operatorProfile: session.operatorProfile, refinedTags: session.refinedTags, - additionalExistingTags: - session.additionalExistingTags, // Always empty for new nodes + additionalExistingTags: session.additionalExistingTags, // Always empty for new nodes changesetComment: session.changesetComment, uploadMode: uploadMode, operation: UploadOperation.create, @@ -160,8 +138,7 @@ class UploadQueueState extends ChangeNotifier { // Using timestamp as negative ID to ensure uniqueness final tempId = -DateTime.now().millisecondsSinceEpoch; final tags = upload.getCombinedTags(); - tags['_pending_upload'] = - 'true'; // Mark as pending for potential UI distinction + tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction tags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal // Store the temp ID in the upload for cleanup purposes @@ -182,18 +159,14 @@ class UploadQueueState extends ChangeNotifier { PendingUpload addFromVerification(OsmNode node, {required UploadMode uploadMode}) { // Passthrough profile carrying no identity tags of its own - everything // comes from additionalExistingTags, so nothing is altered or reformatted - final passthroughProfile = NodeProfile.createExistingTagsProfile( - node, - ).copyWith(requiresDirection: false); + final passthroughProfile = NodeProfile.createExistingTagsProfile(node).copyWith(requiresDirection: false); final upload = PendingUpload( coord: node.coord, direction: 0.0, //requiresDirection is false profile: passthroughProfile, additionalExistingTags: Map.from(node.tags) - ..removeWhere( - (key, _) => key.startsWith('_'), - ), // strip internal cache markers + ..removeWhere((key, _) => key.startsWith('_')), // strip internal cache markers changesetComment: 'Verify surveillance node still present', uploadMode: uploadMode, operation: UploadOperation.modify, @@ -208,11 +181,7 @@ class UploadQueueState extends ChangeNotifier { final originalTags = Map.from(node.tags); originalTags['_pending_edit'] = 'true'; - final originalNode = OsmNode( - id: node.id, - coord: node.coord, - tags: originalTags, - ); + final originalNode = OsmNode(id: node.id, coord: node.coord, tags: originalTags); final tempId = -DateTime.now().millisecondsSinceEpoch; final verifiedTags = upload.getCombinedTags(); @@ -232,10 +201,7 @@ class UploadQueueState extends ChangeNotifier { } // Add a completed edit session to the upload queue - PendingUpload addFromEditSession( - EditNodeSession session, { - required UploadMode uploadMode, - }) { + PendingUpload addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) { // Determine operation type and coordinates final UploadOperation operation; final LatLng coordToUse; @@ -256,12 +222,8 @@ class UploadQueueState extends ChangeNotifier { final upload = PendingUpload( coord: coordToUse, - direction: _formatDirectionsForSubmission( - session.directions, - session.profile, - ), - profile: session - .profile!, // Safe to use ! because commitEditSession() checks for null + direction: _formatDirectionsForSubmission(session.directions, session.profile), + profile: session.profile!, // Safe to use ! because commitEditSession() checks for null operatorProfile: session.operatorProfile, refinedTags: session.refinedTags, additionalExistingTags: session.additionalExistingTags, @@ -280,10 +242,8 @@ class UploadQueueState extends ChangeNotifier { final tempId = -DateTime.now().millisecondsSinceEpoch; final extractedTags = upload.getCombinedTags(); extractedTags['_pending_upload'] = 'true'; // Mark as pending upload - extractedTags['_original_node_id'] = session.originalNode.id - .toString(); // Track original for line drawing - extractedTags['_temp_id'] = tempId - .toString(); // Store temp ID for specific removal + extractedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing + extractedTags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal // Store the temp ID in the upload for cleanup purposes upload.tempNodeId = tempId; @@ -299,8 +259,7 @@ class UploadQueueState extends ChangeNotifier { // For modify: mark original with grey ring and create new temp node // 1. Mark the original node with _pending_edit (grey ring) at original location final originalTags = Map.from(session.originalNode.tags); - originalTags['_pending_edit'] = - 'true'; // Mark original as having pending edit + originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit final originalNode = OsmNode( id: session.originalNode.id, @@ -312,10 +271,8 @@ class UploadQueueState extends ChangeNotifier { final tempId = -DateTime.now().millisecondsSinceEpoch; final editedTags = upload.getCombinedTags(); editedTags['_pending_upload'] = 'true'; // Mark as pending upload - editedTags['_original_node_id'] = session.originalNode.id - .toString(); // Track original for line drawing - editedTags['_temp_id'] = tempId - .toString(); // Store temp ID for specific removal + editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing + editedTags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal // Store the temp ID in the upload for cleanup purposes upload.tempNodeId = tempId; @@ -336,20 +293,12 @@ class UploadQueueState extends ChangeNotifier { } // Add a node deletion to the upload queue - PendingUpload addFromNodeDeletion( - OsmNode node, { - required UploadMode uploadMode, - String? changesetComment, - }) { + PendingUpload addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) { final upload = PendingUpload( coord: node.coord, - direction: node.directionDeg.isNotEmpty - ? node.directionDeg.first - : 0, // Direction not used for deletions but required for API + 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: - changesetComment ?? - 'Delete a surveillance node', // Use provided comment or default + changesetComment: changesetComment ?? 'Delete a surveillance node', // Use provided comment or default uploadMode: uploadMode, operation: UploadOperation.delete, originalNodeId: node.id, @@ -429,28 +378,18 @@ class UploadQueueState extends ChangeNotifier { // Check if we can start more uploads (concurrency limit check) if (_activeUploadCount >= kMaxConcurrentUploads) { - debugPrint( - '[UploadQueue] At concurrency limit ($_activeUploadCount/$kMaxConcurrentUploads), waiting for uploads to complete', - ); + debugPrint('[UploadQueue] At concurrency limit ($_activeUploadCount/$kMaxConcurrentUploads), waiting for uploads to complete'); return; } // Process any expired items - final uploadingItems = _queue - .where((pu) => pu.uploadState == UploadState.uploading) - .toList(); - final closingItems = _queue - .where((pu) => pu.uploadState == UploadState.closingChangeset) - .toList(); + final uploadingItems = _queue.where((pu) => pu.uploadState == UploadState.uploading).toList(); + final closingItems = _queue.where((pu) => pu.uploadState == UploadState.closingChangeset).toList(); for (final uploadingItem in uploadingItems) { if (uploadingItem.hasChangesetExpired) { - debugPrint( - '[UploadQueue] Changeset expired during node submission - marking as failed', - ); - uploadingItem.setError( - 'Could not submit node within 59 minutes - changeset expired', - ); + debugPrint('[UploadQueue] Changeset expired during node submission - marking as failed'); + uploadingItem.setError('Could not submit node within 59 minutes - changeset expired'); _saveQueue(); notifyListeners(); } @@ -458,34 +397,24 @@ class UploadQueueState extends ChangeNotifier { for (final closingItem in closingItems) { if (closingItem.hasChangesetExpired) { - debugPrint( - '[UploadQueue] Changeset expired during close - trusting OSM auto-close (node was submitted successfully)', - ); - _markAsCompleting( - closingItem, - submittedNodeId: closingItem.submittedNodeId!, - ); + debugPrint('[UploadQueue] Changeset expired during close - trusting OSM auto-close (node was submitted successfully)'); + _markAsCompleting(closingItem, submittedNodeId: closingItem.submittedNodeId!); } } // Find next pending item to start - final pendingItems = _queue - .where((pu) => pu.uploadState == UploadState.pending) - .toList(); + final pendingItems = _queue.where((pu) => pu.uploadState == UploadState.pending).toList(); if (pendingItems.isEmpty) { // Check if queue is effectively empty - final hasActiveItems = _queue.any( - (pu) => - pu.uploadState == UploadState.creatingChangeset || - pu.uploadState == UploadState.uploading || - pu.uploadState == UploadState.closingChangeset, + final hasActiveItems = _queue.any((pu) => + pu.uploadState == UploadState.creatingChangeset || + pu.uploadState == UploadState.uploading || + pu.uploadState == UploadState.closingChangeset, ); if (!hasActiveItems) { - debugPrint( - '[UploadQueue] No active items remaining, stopping uploader', - ); + debugPrint('[UploadQueue] No active items remaining, stopping uploader'); _uploadTimer?.cancel(); } return; @@ -497,9 +426,7 @@ class UploadQueueState extends ChangeNotifier { // Start processing the next pending upload final item = pendingItems.first; - debugPrint( - '[UploadQueue] Starting new upload processing for item at ${item.coord} ($_activeUploadCount/$kMaxConcurrentUploads active)', - ); + debugPrint('[UploadQueue] Starting new upload processing for item at ${item.coord} ($_activeUploadCount/$kMaxConcurrentUploads active)'); _activeUploadCount++; _processIndividualUpload(item, access); @@ -507,14 +434,9 @@ class UploadQueueState extends ChangeNotifier { } // Process an individual upload through all three stages - Future _processIndividualUpload( - PendingUpload item, - String accessToken, - ) async { + Future _processIndividualUpload(PendingUpload item, String accessToken) async { try { - debugPrint( - '[UploadQueue] Starting individual upload processing for ${item.operation.name} at ${item.coord}', - ); + debugPrint('[UploadQueue] Starting individual upload processing for ${item.operation.name} at ${item.coord}'); // Stage 1: Create changeset await _processCreateChangeset(item, accessToken); @@ -522,21 +444,16 @@ class UploadQueueState extends ChangeNotifier { // Stage 2: Node operation with retry logic bool nodeOperationCompleted = false; - while (!nodeOperationCompleted && - !item.hasChangesetExpired && - item.uploadState != UploadState.error) { + while (!nodeOperationCompleted && !item.hasChangesetExpired && item.uploadState != UploadState.error) { await _processNodeOperation(item, accessToken); if (item.uploadState == UploadState.closingChangeset) { // Node operation succeeded nodeOperationCompleted = true; - } else if (item.uploadState == UploadState.uploading && - !item.isReadyForNodeSubmissionRetry) { + } else if (item.uploadState == UploadState.uploading && !item.isReadyForNodeSubmissionRetry) { // Need to wait before retry final delay = item.nextNodeSubmissionRetryDelay; - debugPrint( - '[UploadQueue] Waiting ${delay.inSeconds}s before node submission retry', - ); + debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before node submission retry'); await Future.delayed(delay); } else if (item.uploadState == UploadState.error) { // Failed permanently @@ -548,21 +465,16 @@ class UploadQueueState extends ChangeNotifier { // Stage 3: Close changeset with retry logic bool changesetClosed = false; - while (!changesetClosed && - !item.hasChangesetExpired && - item.uploadState != UploadState.error) { + while (!changesetClosed && !item.hasChangesetExpired && item.uploadState != UploadState.error) { await _processChangesetClose(item, accessToken); if (item.uploadState == UploadState.complete) { // Changeset close succeeded changesetClosed = true; - } else if (item.uploadState == UploadState.closingChangeset && - !item.isReadyForChangesetCloseRetry) { + } else if (item.uploadState == UploadState.closingChangeset && !item.isReadyForChangesetCloseRetry) { // Need to wait before retry final delay = item.nextChangesetCloseRetryDelay; - debugPrint( - '[UploadQueue] Waiting ${delay.inSeconds}s before changeset close retry', - ); + debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before changeset close retry'); await Future.delayed(delay); } else if (item.uploadState == UploadState.error) { // Failed permanently @@ -572,73 +484,49 @@ class UploadQueueState extends ChangeNotifier { if (!changesetClosed && item.hasChangesetExpired) { // Trust OSM auto-close if we ran out of time - debugPrint( - '[UploadQueue] Upload completed but changeset close timed out - trusting OSM auto-close', - ); + debugPrint('[UploadQueue] Upload completed but changeset close timed out - trusting OSM auto-close'); if (item.submittedNodeId != null) { _markAsCompleting(item, submittedNodeId: item.submittedNodeId!); } } } catch (e) { - debugPrint( - '[UploadQueue] Unexpected error in individual upload processing: $e', - ); + debugPrint('[UploadQueue] Unexpected error in individual upload processing: $e'); item.setError('Unexpected error: $e'); _saveQueue(); notifyListeners(); } finally { // Always decrement the active upload count _activeUploadCount--; - debugPrint( - '[UploadQueue] Individual upload processing finished ($_activeUploadCount/$kMaxConcurrentUploads active)', - ); + debugPrint('[UploadQueue] Individual upload processing finished ($_activeUploadCount/$kMaxConcurrentUploads active)'); } } // Process changeset creation (step 1 of 3) - Future _processCreateChangeset( - PendingUpload item, - String access, - ) async { + Future _processCreateChangeset(PendingUpload item, String access) async { item.markAsCreatingChangeset(); _saveQueue(); notifyListeners(); // Show "Creating changeset..." immediately if (item.uploadMode == UploadMode.simulate) { // Simulate successful upload without calling real API - debugPrint( - '[UploadQueue] Simulating changeset creation (no real API call)', - ); - await Future.delayed( - const Duration(milliseconds: 500), - ); // Simulate network delay + debugPrint('[UploadQueue] Simulating changeset creation (no real API call)'); + await Future.delayed(const Duration(milliseconds: 500)); // Simulate network delay // Move to node operation phase - item.markChangesetCreated( - 'simulate-changeset-${DateTime.now().millisecondsSinceEpoch}', - ); + item.markChangesetCreated('simulate-changeset-${DateTime.now().millisecondsSinceEpoch}'); _saveQueue(); notifyListeners(); return; } // Real changeset creation - debugPrint( - '[UploadQueue] Creating changeset for ${item.operation.name} operation', - ); - final up = Uploader( - access, - (nodeId) {}, - (errorMessage) {}, - uploadMode: item.uploadMode, - ); + debugPrint('[UploadQueue] Creating changeset for ${item.operation.name} operation'); + final up = Uploader(access, (nodeId) {}, (errorMessage) {}, uploadMode: item.uploadMode); final result = await up.createChangeset(item); if (result.success) { // Changeset created successfully - move to node operation phase - debugPrint( - '[UploadQueue] Changeset ${result.changesetId} created successfully', - ); + debugPrint('[UploadQueue] Changeset ${result.changesetId} created successfully'); item.markChangesetCreated(result.changesetId!); _saveQueue(); notifyListeners(); // Show "Uploading node..." next @@ -649,9 +537,7 @@ class UploadQueueState extends ChangeNotifier { notifyListeners(); // Show attempt count immediately if (item.attempts >= 3) { - item.setError( - result.errorMessage ?? 'Changeset creation failed after 3 attempts', - ); + item.setError(result.errorMessage ?? 'Changeset creation failed after 3 attempts'); _saveQueue(); notifyListeners(); // Show error state immediately } else { @@ -676,27 +562,19 @@ class UploadQueueState extends ChangeNotifier { // Check if 59-minute window has expired if (item.hasChangesetExpired) { - debugPrint( - '[UploadQueue] Changeset expired, could not submit node within 59 minutes', - ); - item.setError( - 'Could not submit node within 59 minutes - changeset expired', - ); + debugPrint('[UploadQueue] Changeset expired, could not submit node within 59 minutes'); + item.setError('Could not submit node within 59 minutes - changeset expired'); _saveQueue(); notifyListeners(); return; } - debugPrint( - '[UploadQueue] Processing node operation with changeset ${item.changesetId} (attempt ${item.nodeSubmissionAttempts + 1})', - ); + debugPrint('[UploadQueue] Processing node operation with changeset ${item.changesetId} (attempt ${item.nodeSubmissionAttempts + 1})'); if (item.uploadMode == UploadMode.simulate) { // Simulate successful node operation without calling real API debugPrint('[UploadQueue] Simulating node operation (no real API call)'); - await Future.delayed( - const Duration(milliseconds: 500), - ); // Simulate network delay + await Future.delayed(const Duration(milliseconds: 500)); // Simulate network delay // Store appropriate simulated node ID based on operation type if (item.operation == UploadOperation.modify) { @@ -713,17 +591,12 @@ class UploadQueueState extends ChangeNotifier { } // Real node operation - final up = Uploader( - access, - (nodeId) { + final up = Uploader(access, (nodeId) { // This callback is called when node operation succeeds item.submittedNodeId = nodeId; - }, - (errorMessage) { + }, (errorMessage) { // Error handling is done below - }, - uploadMode: item.uploadMode, - ); + }, uploadMode: item.uploadMode); final result = await up.performNodeOperation(item, item.changesetId!); @@ -733,36 +606,26 @@ class UploadQueueState extends ChangeNotifier { if (result.success) { // Node operation succeeded - move to changeset close phase - debugPrint( - '[UploadQueue] Node operation succeeded after ${item.nodeSubmissionAttempts} attempts, node ID: ${result.nodeId}', - ); + debugPrint('[UploadQueue] Node operation succeeded after ${item.nodeSubmissionAttempts} attempts, node ID: ${result.nodeId}'); item.submittedNodeId = result.nodeId; item.markNodeOperationComplete(); _saveQueue(); notifyListeners(); // Show "Closing changeset..." next } else { // Node operation failed - will retry within 59-minute window - debugPrint( - '[UploadQueue] Node operation failed (attempt ${item.nodeSubmissionAttempts}): ${result.errorMessage}', - ); + debugPrint('[UploadQueue] Node operation failed (attempt ${item.nodeSubmissionAttempts}): ${result.errorMessage}'); // Check if we have time for another retry if (item.hasChangesetExpired) { - debugPrint( - '[UploadQueue] Changeset expired during retry, marking as failed', - ); - item.setError( - 'Could not submit node within 59 minutes - ${result.errorMessage}', - ); + debugPrint('[UploadQueue] Changeset expired during retry, marking as failed'); + item.setError('Could not submit node within 59 minutes - ${result.errorMessage}'); _saveQueue(); notifyListeners(); } else { // Still have time, will retry after backoff delay final nextDelay = item.nextNodeSubmissionRetryDelay; final timeLeft = item.timeUntilAutoClose; - debugPrint( - '[UploadQueue] Will retry node submission in $nextDelay, ${timeLeft?.inMinutes}m remaining', - ); + debugPrint('[UploadQueue] Will retry node submission in $nextDelay, ${timeLeft?.inMinutes}m remaining'); // No state change needed - attempt count was already updated above } } @@ -780,23 +643,17 @@ class UploadQueueState extends ChangeNotifier { // Check if 59-minute window has expired - if so, mark as complete (trust OSM auto-close) if (item.hasChangesetExpired) { - debugPrint( - '[UploadQueue] Changeset expired - trusting OSM auto-close (node was submitted successfully)', - ); + debugPrint('[UploadQueue] Changeset expired - trusting OSM auto-close (node was submitted successfully)'); _markAsCompleting(item, submittedNodeId: item.submittedNodeId!); return; } - debugPrint( - '[UploadQueue] Attempting to close changeset ${item.changesetId} (attempt ${item.changesetCloseAttempts + 1})', - ); + debugPrint('[UploadQueue] Attempting to close changeset ${item.changesetId} (attempt ${item.changesetCloseAttempts + 1})'); if (item.uploadMode == UploadMode.simulate) { // Simulate successful changeset close without calling real API debugPrint('[UploadQueue] Simulating changeset close (no real API call)'); - await Future.delayed( - const Duration(milliseconds: 300), - ); // Simulate network delay + await Future.delayed(const Duration(milliseconds: 300)); // Simulate network delay // Mark as complete _markAsCompleting(item, submittedNodeId: item.submittedNodeId!); @@ -804,12 +661,7 @@ class UploadQueueState extends ChangeNotifier { } // Real changeset close - final up = Uploader( - access, - (nodeId) {}, - (errorMessage) {}, - uploadMode: item.uploadMode, - ); + final up = Uploader(access, (nodeId) {}, (errorMessage) {}, uploadMode: item.uploadMode); final result = await up.closeChangeset(item.changesetId!); item.incrementChangesetCloseAttempts(); // This records the attempt time @@ -818,16 +670,12 @@ class UploadQueueState extends ChangeNotifier { if (result.success) { // Changeset closed successfully - debugPrint( - '[UploadQueue] Changeset close succeeded after ${item.changesetCloseAttempts} attempts', - ); + debugPrint('[UploadQueue] Changeset close succeeded after ${item.changesetCloseAttempts} attempts'); _markAsCompleting(item, submittedNodeId: item.submittedNodeId!); // _markAsCompleting handles its own save/notify } else if (result.changesetNotFound) { // Changeset not found - this suggests the upload may not have worked, start over with full retry - debugPrint( - '[UploadQueue] Changeset not found during close, marking for full retry', - ); + debugPrint('[UploadQueue] Changeset not found during close, marking for full retry'); item.setError(result.errorMessage ?? 'Changeset not found'); _saveQueue(); notifyListeners(); // Show error state immediately @@ -836,9 +684,7 @@ class UploadQueueState extends ChangeNotifier { // Note: This will NEVER error out - will keep trying until 59-minute window expires final nextDelay = item.nextChangesetCloseRetryDelay; final timeLeft = item.timeUntilAutoClose; - debugPrint( - '[UploadQueue] Changeset close failed (attempt ${item.changesetCloseAttempts}), will retry in $nextDelay, ${timeLeft?.inMinutes}m remaining', - ); + debugPrint('[UploadQueue] Changeset close failed (attempt ${item.changesetCloseAttempts}), will retry in $nextDelay, ${timeLeft?.inMinutes}m remaining'); debugPrint('[UploadQueue] Error: ${result.errorMessage}'); // No additional state change needed - attempt count was already updated above } @@ -849,11 +695,7 @@ class UploadQueueState extends ChangeNotifier { } // Mark an item as completing (shows checkmark) and schedule removal after 1 second - void _markAsCompleting( - PendingUpload item, { - int? submittedNodeId, - int? simulatedNodeId, - }) { + void _markAsCompleting(PendingUpload item, {int? submittedNodeId, int? simulatedNodeId}) { item.markAsComplete(); // Store the submitted node ID for cleanup purposes @@ -861,30 +703,21 @@ class UploadQueueState extends ChangeNotifier { item.submittedNodeId = submittedNodeId; if (item.isDeletion) { - debugPrint( - '[UploadQueue] Deletion successful, removing node ID: $submittedNodeId from cache', - ); + debugPrint('[UploadQueue] Deletion successful, removing node ID: $submittedNodeId from cache'); _handleSuccessfulDeletion(item); } else { - debugPrint( - '[UploadQueue] Upload successful, OSM assigned node ID: $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) { + } else if (simulatedNodeId != null && item.uploadMode == UploadMode.simulate) { // For simulate mode, use a fake but positive ID item.submittedNodeId = simulatedNodeId; if (item.isDeletion) { - debugPrint( - '[UploadQueue] Simulated deletion, removing fake node ID: $simulatedNodeId from cache', - ); + debugPrint('[UploadQueue] Simulated deletion, removing fake node ID: $simulatedNodeId from cache'); _handleSuccessfulDeletion(item); } else { - debugPrint( - '[UploadQueue] Simulated upload successful, updating cache with fake node ID: $simulatedNodeId', - ); + debugPrint('[UploadQueue] Simulated upload successful, updating cache with fake node ID: $simulatedNodeId'); // Update cache with simulated node ID, same as production mode _updateCacheWithRealNodeId(item, simulatedNodeId); } @@ -944,17 +777,14 @@ class UploadQueueState extends ChangeNotifier { } // Helper method to format multiple directions for submission, supporting profile FOV - dynamic _formatDirectionsForSubmission( - List directions, - NodeProfile? profile, - ) { + dynamic _formatDirectionsForSubmission(List directions, NodeProfile? profile) { if (directions.isEmpty) return 0.0; // If profile has FOV, convert center directions to range notation if (profile?.fov != null && profile!.fov! > 0) { - final ranges = directions - .map((center) => _formatDirectionWithFov(center, profile.fov!)) - .toList(); + final ranges = directions.map((center) => + _formatDirectionWithFov(center, profile.fov!) + ).toList(); return ranges.length == 1 ? ranges.first : ranges.join(';'); } diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index 592545f6..d9e5d387 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -45,8 +45,7 @@ class _EditNodeSheetState extends State { } Future _checkTutorialStatus() async { - final hasCompleted = await ChangelogService() - .hasCompletedPositioningTutorial(); + final hasCompleted = await ChangelogService().hasCompletedPositioningTutorial(); if (mounted) { setState(() { _showTutorial = !hasCompleted; @@ -86,19 +85,11 @@ class _EditNodeSheetState extends State { super.dispose(); } - void _checkProximityAndCommit( - BuildContext context, - AppState appState, - LocalizationService locService, - ) { + void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) { _checkSubmissionGuideAndProceed(context, appState, locService); } - void _checkSubmissionGuideAndProceed( - BuildContext context, - AppState appState, - LocalizationService locService, - ) async { + void _checkSubmissionGuideAndProceed(BuildContext context, AppState appState, LocalizationService locService) async { // Check if user has seen the submission guide final hasSeenGuide = await ChangelogService().hasSeenSubmissionGuide(); if (!context.mounted) return; @@ -122,11 +113,7 @@ class _EditNodeSheetState extends State { _checkProximityOnly(context, appState, locService); } - void _checkProximityOnly( - BuildContext context, - AppState appState, - LocalizationService 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, @@ -156,11 +143,7 @@ class _EditNodeSheetState extends State { } } - void _commitWithoutCheck( - BuildContext context, - AppState appState, - LocalizationService locService, - ) { + void _commitWithoutCheck(BuildContext context, AppState appState, LocalizationService locService) { appState.commitEditSession(); Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( @@ -175,19 +158,13 @@ class _EditNodeSheetState extends State { // 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) { + if ((session.target.latitude - session.originalNode.coord.latitude).abs() > tolerance || + (session.target.longitude - session.originalNode.coord.longitude).abs() > tolerance) { return true; } // Check direction changes - if (!_directionsEqual( - session.directions, - session.originalNode.directionDeg, - )) { + if (!_directionsEqual(session.directions, session.originalNode.directionDeg)) { return true; } @@ -260,11 +237,9 @@ class _EditNodeSheetState extends State { profile: session.profile, operatorProfile: session.operatorProfile, refinedTags: session.refinedTags, - additionalExistingTags: - session.additionalExistingTags, // Include additional existing tags! + additionalExistingTags: session.additionalExistingTags, // Include additional existing tags! changesetComment: session.changesetComment, // Required parameter - uploadMode: - UploadMode.production, // Mode doesn't matter for tag combination + uploadMode: UploadMode.production, // Mode doesn't matter for tag combination operation: UploadOperation.modify, originalNodeId: session.originalNode.id, // Required for modify operations ); @@ -273,10 +248,7 @@ class _EditNodeSheetState extends State { } /// Show dialog explaining why submission is disabled due to no changes - void _showNoChangesDialog( - BuildContext context, - LocalizationService locService, - ) { + void _showNoChangesDialog(BuildContext context, LocalizationService locService) { showDialog( context: context, builder: (context) => AlertDialog( @@ -292,18 +264,12 @@ class _EditNodeSheetState extends State { ); } - Widget _buildDirectionControls( - BuildContext context, - AppState appState, - EditNodeSession session, - LocalizationService locService, - ) { - final requiresDirection = - session.profile != null && session.profile!.requiresDirection; + 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 enableDirectionControls = requiresDirection && !is360Fov && hasDirections; final enableAddButton = requiresDirection && !is360Fov; // Force direction to 0 when FOV is 360 (omnidirectional) @@ -345,29 +311,19 @@ class _EditNodeSheetState extends State { return TextSpan( text: entry.value, style: TextStyle( - fontWeight: isEven - ? FontWeight.normal - : FontWeight.bold, + fontWeight: isEven ? FontWeight.normal : FontWeight.bold, ), ); }) else const TextSpan( text: 'None', - style: TextStyle( - fontStyle: FontStyle.italic, - color: Colors.grey, - ), + style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey), ), ], ), ) - : Text( - locService.t( - 'editNode.direction', - params: [session.directionDegrees.round().toString()], - ), - ), + : Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])), subtitle: Row( children: [ // Slider takes most of the space @@ -378,9 +334,7 @@ class _EditNodeSheetState extends State { divisions: 359, value: session.directionDegrees, label: session.directionDegrees.round().toString(), - onChanged: enableDirectionControls - ? (v) => appState.updateEditSession(directionDeg: v) - : null, + onChanged: enableDirectionControls ? (v) => appState.updateEditSession(directionDeg: v) : null, ), ), // Direction control buttons - always show but grey out when direction not required @@ -390,76 +344,49 @@ class _EditNodeSheetState extends State { icon: Icon( Icons.remove, size: 20, - color: enableDirectionControls && appState.canRemoveDirection - ? null - : Theme.of(context).disabledColor, + color: enableDirectionControls && appState.canRemoveDirection ? null : Theme.of(context).disabledColor, ), - onPressed: - enableDirectionControls && appState.canRemoveDirection - ? () => appState.removeDirection() - : null, + onPressed: enableDirectionControls && appState.canRemoveDirection + ? () => appState.removeDirection() : null, tooltip: requiresDirection ? (hasDirections - ? (appState.canRemoveDirection - ? 'Remove current direction' - : 'Cannot remove - minimum reached') - : 'No directions to remove') + ? (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, - ), + 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, + color: enableAddButton && session.directions.length < 8 ? null : Theme.of(context).disabledColor, ), - onPressed: enableAddButton && session.directions.length < 8 - ? () => appState.addDirection() - : null, + onPressed: enableAddButton && session.directions.length < 8 ? () => appState.addDirection() : null, tooltip: requiresDirection - ? (session.directions.length >= 8 - ? 'Maximum 8 directions allowed' - : 'Add new direction') + ? (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, - ), + 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, + color: enableDirectionControls && session.directions.length > 1 ? null : Theme.of(context).disabledColor, ), - onPressed: - enableDirectionControls && session.directions.length > 1 + onPressed: enableDirectionControls && session.directions.length > 1 ? () => appState.cycleDirection() : null, tooltip: requiresDirection ? (hasDirections - ? (session.directions.length > 1 - ? 'Cycle through directions' - : 'Only one direction') + ? (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, - ), + constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight), ), ], ), @@ -525,22 +452,14 @@ class _EditNodeSheetState extends State { } final session = widget.session; - final submittableProfiles = appState.enabledProfiles - .where((p) => p.isSubmittable) - .toList(); + final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList(); // 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, - ), + LatLng(nodeCoord.latitude - bufferDegrees, nodeCoord.longitude - bufferDegrees), + LatLng(nodeCoord.latitude + bufferDegrees, nodeCoord.longitude + bufferDegrees), ); hasGoodCoverage = MapDataProvider().hasGoodCoverageFor(targetBounds); @@ -554,8 +473,7 @@ class _EditNodeSheetState extends State { hasGoodCoverage = nearbyNodes.isNotEmpty; } - final allowSubmit = - kEnableNodeEdits && + final allowSubmit = kEnableNodeEdits && appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile != null && @@ -576,9 +494,7 @@ class _EditNodeSheetState extends State { currentRefinedTags: session.refinedTags, currentAdditionalExistingTags: session.additionalExistingTags, originalNodeTags: session.originalNode.tags, - operation: session.extractFromWay - ? UploadOperation.extract - : UploadOperation.modify, + operation: session.extractFromWay ? UploadOperation.extract : UploadOperation.modify, ), fullscreenDialog: true, ), @@ -612,22 +528,13 @@ class _EditNodeSheetState extends State { ), const SizedBox(height: 8), Text( - locService.t( - 'editNode.title', - params: [session.originalNode.id.toString()], - ), + 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, - ), + trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService), ), // Direction controls _buildDirectionControls(context, appState, session, locService), @@ -641,12 +548,8 @@ class _EditNodeSheetState extends State { // 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'), - ), + title: Text(locService.t('editNode.extractFromWay')), + subtitle: Text(locService.t('editNode.extractFromWaySubtitle')), value: session.extractFromWay, onChanged: (value) { appState.updateEditSession(extractFromWay: value); @@ -665,9 +568,7 @@ class _EditNodeSheetState extends State { const SizedBox(width: 8), Expanded( child: Text( - locService.t( - 'editNode.cannotMoveConstrainedNode', - ), + locService.t('editNode.cannotMoveConstrainedNode'), style: Theme.of(context).textTheme.bodyMedium, ), ), @@ -681,9 +582,7 @@ class _EditNodeSheetState extends State { OutlinedButton.icon( onPressed: () => _openAdvancedEdit(context), icon: const Icon(Icons.open_in_new, size: 16), - label: Text( - locService.t('actions.useAdvancedEditor'), - ), + label: Text(locService.t('actions.useAdvancedEditor')), style: OutlinedButton.styleFrom( minimumSize: const Size(0, 32), ), @@ -699,19 +598,12 @@ class _EditNodeSheetState extends State { padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ - const Icon( - Icons.construction, - color: Colors.orange, - size: 20, - ), + 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, - ), + style: const TextStyle(color: Colors.orange, fontSize: 13), ), ), ], @@ -722,19 +614,12 @@ class _EditNodeSheetState extends State { padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ - const Icon( - Icons.info_outline, - color: Colors.red, - size: 20, - ), + 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, - ), + style: const TextStyle(color: Colors.red, fontSize: 13), ), ), ], @@ -745,19 +630,12 @@ class _EditNodeSheetState extends State { padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ - const Icon( - Icons.info_outline, - color: Colors.red, - size: 20, - ), + 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, - ), + style: const TextStyle(color: Colors.red, fontSize: 13), ), ), ], @@ -768,19 +646,12 @@ class _EditNodeSheetState extends State { padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ - const Icon( - Icons.info_outline, - color: Colors.orange, - size: 20, - ), + 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, - ), + style: const TextStyle(color: Colors.orange, fontSize: 13), ), ), ], @@ -791,19 +662,12 @@ class _EditNodeSheetState extends State { padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ - const Icon( - Icons.info_outline, - color: Colors.orange, - size: 20, - ), + 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, - ), + style: const TextStyle(color: Colors.orange, fontSize: 13), ), ), ], @@ -814,19 +678,12 @@ class _EditNodeSheetState extends State { padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ - const Icon( - Icons.cloud_download, - color: Colors.blue, - size: 20, - ), + 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, - ), + style: const TextStyle(color: Colors.blue, fontSize: 13), ), ), ], @@ -838,9 +695,7 @@ class _EditNodeSheetState extends State { child: SizedBox( width: double.infinity, child: OutlinedButton.icon( - onPressed: session.profile != null - ? openRefineTags - : null, // Disabled when no profile selected + onPressed: session.profile != null ? openRefineTags : null, // Disabled when no profile selected icon: const Icon(Icons.tune), label: Text(locService.t('editNode.refineTags')), ), @@ -860,14 +715,8 @@ class _EditNodeSheetState extends State { 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'), - ), + onPressed: !appState.isLoggedIn ? navigateToLogin : (allowSubmit ? commit : null), + child: Text(!appState.isLoggedIn ? locService.t('actions.logIn') : locService.t('actions.saveEdit')), ), ), ], @@ -886,13 +735,8 @@ class _EditNodeSheetState extends State { ); } - Widget _buildProfileDropdown( - BuildContext context, - AppState appState, - EditNodeSession session, - List submittableProfiles, - LocalizationService locService, - ) { + 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'); @@ -931,7 +775,8 @@ class _EditNodeSheetState extends State { child: Text(locService.t('editNode.existingTags')), ), // Divider after existing tags profile - if (submittableProfiles.isNotEmpty) const PopupMenuDivider(), + if (submittableProfiles.isNotEmpty) + const PopupMenuDivider(), // Regular profiles ...submittableProfiles.map( (profile) => PopupMenuItem( @@ -951,7 +796,9 @@ class _EditNodeSheetState extends State { const SizedBox(width: 8), Text( locService.t('profiles.getMore'), - style: const TextStyle(fontStyle: FontStyle.italic), + style: const TextStyle( + fontStyle: FontStyle.italic, + ), ), ], ), @@ -962,15 +809,11 @@ class _EditNodeSheetState extends State { _openIdentifyWebsite(context); } else if (value == 'existing_tags') { // Re-create and select the existing tags profile - final existingTagsProfile = NodeProfile.createExistingTagsProfile( - session.originalNode, - ); + 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, - ); + final profile = submittableProfiles.firstWhere((p) => p.id == profileId); appState.updateEditSession(profile: profile); } }, @@ -1001,7 +844,10 @@ class _EditNodeSheetState extends State { void _showErrorSnackBar(BuildContext context, String message) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Colors.red), + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), ); } @@ -1009,8 +855,7 @@ class _EditNodeSheetState extends State { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => - AdvancedEditOptionsSheet(node: widget.session.originalNode), + builder: (context) => AdvancedEditOptionsSheet(node: widget.session.originalNode), ); } } diff --git a/lib/widgets/map/marker_layer_builder.dart b/lib/widgets/map/marker_layer_builder.dart index 0a917a17..63208905 100644 --- a/lib/widgets/map/marker_layer_builder.dart +++ b/lib/widgets/map/marker_layer_builder.dart @@ -60,14 +60,12 @@ class MarkerLayerBuilder { return LayoutBuilder( builder: (context, constraints) { // Determine if nodes should be dimmed and/or disabled - final shouldDimNodes = - appState.selectedSuspectedLocation != null || - appState.isInSearchMode || - appState.showingOverview; + final shouldDimNodes = appState.selectedSuspectedLocation != null || + appState.isInSearchMode || + appState.showingOverview; // Disable node interactions when navigation is in conflicting state - final shouldDisableNodeTaps = - appState.isInSearchMode || appState.showingOverview; + final shouldDisableNodeTaps = appState.isInSearchMode || appState.showingOverview; final markers = NodeMarkersBuilder.buildNodeMarkers( nodes: nodesToRender, @@ -82,12 +80,8 @@ class MarkerLayerBuilder { // Build suspected location markers (respect same zoom and count limits as nodes) final suspectedLocationMarkers = []; - if (appState.suspectedLocationsEnabled && - mapBounds != null && - currentZoom >= - (appState.uploadMode == UploadMode.sandbox - ? kOsmApiMinZoomLevel - : kNodeMinZoomLevel)) { + if (appState.suspectedLocationsEnabled && mapBounds != null && + currentZoom >= (appState.uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel)) { final suspectedLocations = appState.getSuspectedLocationsInBoundsSync( north: mapBounds.north, south: mapBounds.south, @@ -97,25 +91,21 @@ class MarkerLayerBuilder { // Apply same node count limit as surveillance nodes final maxNodes = appState.maxNodes; - final limitedSuspectedLocations = suspectedLocations - .take(maxNodes) - .toList(); + final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList(); // Filter out suspected locations that are too close to real nodes - final filteredSuspectedLocations = - _filterSuspectedLocationsByProximity( - suspectedLocations: limitedSuspectedLocations, - realNodes: nodesToRender, - minDistance: appState.suspectedLocationMinDistance, - ); + final filteredSuspectedLocations = _filterSuspectedLocationsByProximity( + suspectedLocations: limitedSuspectedLocations, + realNodes: nodesToRender, + minDistance: appState.suspectedLocationMinDistance, + ); suspectedLocationMarkers.addAll( SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers( locations: filteredSuspectedLocations, mapController: mapController.mapController, selectedLocationId: appState.selectedSuspectedLocation?.ticketNo, - onLocationTap: - onSuspectedLocationTap, // Keep the original callback + onLocationTap: onSuspectedLocationTap, // Keep the original callback shouldDimAll: shouldDisableNodeTaps, enabled: !shouldDisableNodeTaps, // Use enabled parameter instead ), @@ -164,9 +154,7 @@ class MarkerLayerBuilder { width: kNodeIconDiameter, height: kNodeIconDiameter, child: CameraIcon( - type: editSession != null - ? CameraIconType.editing - : CameraIconType.mock, + type: editSession != null ? CameraIconType.editing : CameraIconType.mock, ), ), ); @@ -180,8 +168,7 @@ class MarkerLayerBuilder { /// Build provisional pin for navigation/search mode static List _buildNavigationMarkers(AppState appState) { final markers = []; - if (appState.showProvisionalPin && - appState.provisionalPinLocation != null) { + if (appState.showProvisionalPin && appState.provisionalPinLocation != null) { markers.add( Marker( point: appState.provisionalPinLocation!, @@ -197,9 +184,7 @@ class MarkerLayerBuilder { /// Build start/end pins for route visualization static List _buildRouteMarkers(AppState appState) { final markers = []; - if (appState.showingOverview || - appState.isInRouteMode || - appState.isSettingSecondPoint) { + if (appState.showingOverview || appState.isInRouteMode || appState.isSettingSecondPoint) { if (appState.routeStart != null) { markers.add( Marker( diff --git a/lib/widgets/map/node_markers.dart b/lib/widgets/map/node_markers.dart index 0e2b2ede..eb095edd 100644 --- a/lib/widgets/map/node_markers.dart +++ b/lib/widgets/map/node_markers.dart @@ -46,9 +46,7 @@ class _NodeMapMarkerState extends State { } else { // Fallback: This should not happen if callbacks are properly provided, // but if it does, at least open the sheet (without map coordination) - debugPrint( - '[NodeMapMarker] Warning: onNodeTap callback not provided, using fallback', - ); + debugPrint('[NodeMapMarker] Warning: onNodeTap callback not provided, using fallback'); showModalBottomSheet( context: context, builder: (_) => NodeTagSheet(node: widget.node), @@ -62,10 +60,7 @@ class _NodeMapMarkerState extends State { if (!widget.enabled) return; // Don't respond to double taps when disabled _tapTimer?.cancel(); - widget.mapController.move( - widget.node.coord, - widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta, - ); + widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta); } @override @@ -77,15 +72,12 @@ class _NodeMapMarkerState extends State { @override Widget build(BuildContext context) { // Check node state - final isPendingUpload = - widget.node.tags.containsKey('_pending_upload') && - widget.node.tags['_pending_upload'] == 'true'; - final isPendingEdit = - widget.node.tags.containsKey('_pending_edit') && - widget.node.tags['_pending_edit'] == 'true'; - final isPendingDeletion = - widget.node.tags.containsKey('_pending_deletion') && - widget.node.tags['_pending_deletion'] == 'true'; + final isPendingUpload = widget.node.tags.containsKey('_pending_upload') && + widget.node.tags['_pending_upload'] == 'true'; + final isPendingEdit = widget.node.tags.containsKey('_pending_edit') && + widget.node.tags['_pending_edit'] == 'true'; + final isPendingDeletion = widget.node.tags.containsKey('_pending_deletion') && + widget.node.tags['_pending_deletion'] == 'true'; CameraIconType iconType; double agingProgress = 0.0; @@ -130,11 +122,11 @@ class NodeMarkersBuilder { }) { final markers = [ // Node markers - ...nodes.where(_isValidNodeCoordinate).map((n) { + ...nodes.where(_isValidNodeCoordinate) + .map((n) { // Check if this node should be highlighted (selected) or dimmed final isSelected = selectedNodeId == n.id; - final shouldDimNode = - shouldDim || (selectedNodeId != null && !isSelected); + final shouldDimNode = shouldDim || (selectedNodeId != null && !isSelected); return Marker( point: n.coord, @@ -148,7 +140,7 @@ class NodeMarkersBuilder { onNodeTap: onNodeTap, enabled: enabled, stalenessIndicatorEnabled: stalenessIndicatorEnabled - ), + ), ), ); }), diff --git a/lib/widgets/node_provider_with_cache.dart b/lib/widgets/node_provider_with_cache.dart index 5a8f05e8..fb79636b 100644 --- a/lib/widgets/node_provider_with_cache.dart +++ b/lib/widgets/node_provider_with_cache.dart @@ -11,8 +11,7 @@ import '../app_state.dart'; /// 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 NodeProviderWithCache extends ChangeNotifier { - static final NodeProviderWithCache instance = - NodeProviderWithCache._internal(); + static final NodeProviderWithCache instance = NodeProviderWithCache._internal(); factory NodeProviderWithCache() => instance; NodeProviderWithCache._internal(); diff --git a/lib/widgets/node_tag_sheet.dart b/lib/widgets/node_tag_sheet.dart index bf3ba4f8..e10a5741 100644 --- a/lib/widgets/node_tag_sheet.dart +++ b/lib/widgets/node_tag_sheet.dart @@ -204,9 +204,7 @@ class _NodeTagSheetState extends State { Navigator.pop(context); // Close this sheet first appState.deleteNode(displayNode, changesetComment: result!.comment); ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(locService.t('node.deleteQueuedForUpload')), - ), + SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))), ); } } @@ -220,22 +218,14 @@ class _NodeTagSheetState extends State { } else { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - locService.t('advancedEdit.couldNotOpenOSMWebsite'), - ), - ), + SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))), ); } } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - locService.t('advancedEdit.couldNotOpenOSMWebsite'), - ), - ), + SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))), ); } } @@ -653,7 +643,10 @@ class _DeleteNodeDialog extends StatefulWidget { final String nodeId; final LocalizationService locService; - const _DeleteNodeDialog({required this.nodeId, required this.locService}); + const _DeleteNodeDialog({ + required this.nodeId, + required this.locService, + }); @override State<_DeleteNodeDialog> createState() => _DeleteNodeDialogState(); @@ -691,12 +684,7 @@ class _DeleteNodeDialogState extends State<_DeleteNodeDialog> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - widget.locService.t( - 'node.confirmDeleteMessage', - params: [widget.nodeId], - ), - ), + Text(widget.locService.t('node.confirmDeleteMessage', params: [widget.nodeId])), const SizedBox(height: 16), Text( widget.locService.t('node.deleteReasonLabel'), @@ -740,8 +728,7 @@ class _DeleteNodeDialogState extends State<_DeleteNodeDialog> { ), actions: [ TextButton( - onPressed: () => - Navigator.of(context).pop((confirmed: false, comment: '')), + onPressed: () => Navigator.of(context).pop((confirmed: false, comment: '')), child: Text(widget.locService.cancel), ), TextButton( From 0a50f435143b73459b0020f92c0e173b7207a3df Mon Sep 17 00:00:00 2001 From: Asiya Kadivar Date: Tue, 8 Sep 2026 21:46:19 -0400 Subject: [PATCH 08/10] import fix --- lib/state/upload_queue_state.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index e34b4422..51debada 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart'; import '../models/pending_upload.dart'; import '../models/osm_node.dart'; +import '../models/node_profile.dart'; import '../services/map_data_provider.dart'; import '../services/direction_submission_formatter.dart'; import '../services/uploader.dart'; From cea03885c9af994f81cbafb2eec3411ca1b8a355 Mon Sep 17 00:00:00 2001 From: Asiya Kadivar Date: Mon, 28 Sep 2026 15:51:43 -0400 Subject: [PATCH 09/10] Ignore check_date in EditNodeChangeDetector --- lib/services/edit_node_change_detector.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/services/edit_node_change_detector.dart b/lib/services/edit_node_change_detector.dart index b20e3444..06f8055e 100644 --- a/lib/services/edit_node_change_detector.dart +++ b/lib/services/edit_node_change_detector.dart @@ -63,10 +63,12 @@ class EditNodeChangeDetector { ) { final original = Map.from(originalTags) ..remove('direction') - ..remove('camera:direction'); + ..remove('camera:direction') + ..remove('check_date'); final submitted = Map.from(submittedTags) ..remove('direction') - ..remove('camera:direction'); + ..remove('camera:direction') + ..remove('check_date'); if (original.length != submitted.length) return false; for (final entry in original.entries) { From 22f67e9d0892b765966adc44634a5692ad728e29 Mon Sep 17 00:00:00 2001 From: Asiya Kadivar Date: Mon, 28 Sep 2026 16:23:17 -0400 Subject: [PATCH 10/10] Stop injecting assumed check_date into tags, fallback kept --- lib/models/osm_node.dart | 4 ---- lib/services/map_data_submodules/nodes_from_local.dart | 6 ------ lib/services/map_data_submodules/nodes_from_osm_api.dart | 5 ----- lib/services/overpass_service.dart | 3 --- 4 files changed, 18 deletions(-) diff --git a/lib/models/osm_node.dart b/lib/models/osm_node.dart index cb99346f..a6745c82 100644 --- a/lib/models/osm_node.dart +++ b/lib/models/osm_node.dart @@ -31,10 +31,6 @@ class OsmNode { }); } - if (!tags.containsKey('check_date') || (tags['check_date'] ?? '').isEmpty) { - tags['check_date'] = kFeatureReleaseDate.toIso8601String().split('T')[0]; - } - return OsmNode( id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0, coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()), diff --git a/lib/services/map_data_submodules/nodes_from_local.dart b/lib/services/map_data_submodules/nodes_from_local.dart index 5d80c895..9a536803 100644 --- a/lib/services/map_data_submodules/nodes_from_local.dart +++ b/lib/services/map_data_submodules/nodes_from_local.dart @@ -5,7 +5,6 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; import '../../models/osm_node.dart'; import '../../models/node_profile.dart'; -import '../../dev_config.dart'; import '../offline_area_service.dart'; import '../offline_areas/offline_area_models.dart'; @@ -60,11 +59,6 @@ Future> _loadAreaNodes(OfflineArea area) async { final str = await fileToLoad.readAsString(); final jsonList = jsonDecode(str) as List; final nodes = jsonList.map((e) => OsmNode.fromJson(e)).toList(); - for (final node in nodes) { - if (!node.tags.containsKey('check_date') || (node.tags['check_date'] ?? '').isEmpty) { - node.tags['check_date'] = kFeatureReleaseDate.toIso8601String().split('T')[0]; - } - } return nodes; } catch (e) { debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e'); diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index b908b9c8..0c21dada 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -7,7 +7,6 @@ import 'package:xml/xml.dart'; import '../../models/node_profile.dart'; import '../../models/osm_node.dart'; import '../../app_state.dart'; -import '../../dev_config.dart'; import '../http_client.dart'; import '../service_policy.dart'; @@ -161,10 +160,6 @@ List _parseOsmApiResponseWithConstraints(XmlDocument document, List.from(element['tags'] ?? {}); - if (!tags.containsKey('check_date') || (tags['check_date'] ?? '').isEmpty) { - tags['check_date'] = kFeatureReleaseDate.toIso8601String().split('T')[0]; - } return OsmNode( id: nodeId, coord: LatLng(element['lat'], element['lon']),