diff --git a/lib/app_state.dart b/lib/app_state.dart index 90fbb4f8..bc53e663 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,7 +58,7 @@ 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 @@ -82,7 +82,7 @@ class AppState extends ChangeNotifier { _settingsState = SettingsState(); _suspectedLocationState = SuspectedLocationState(); _uploadQueueState = UploadQueueState(); - + // Set up state change listeners _authState.addListener(_onStateChanged); _messagesState.addListener(_onStateChanged); @@ -94,17 +94,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; @@ -116,7 +116,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; @@ -132,30 +132,30 @@ class AppState extends ChangeNotifier { 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; 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; @@ -172,20 +172,20 @@ class AppState extends ChangeNotifier { int get proximityAlertDistance => _settingsState.proximityAlertDistance; bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled; int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance; - + bool get stalenessIndicatorEnabled => _settingsState.stalenessIndicatorEnabled; + // Messages state int? get unreadMessageCount => _messagesState.unreadCount; bool get hasUnreadMessages => _messagesState.hasUnreadMessages; bool get hasUnreadChangesetComments => _messagesState.hasUnreadChangesetComments; bool get hasUnreadNotifications => _messagesState.hasUnreadNotifications; 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; @@ -212,80 +212,82 @@ 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 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), @@ -293,7 +295,7 @@ class AppState extends ChangeNotifier { if (isLoggedIn) { checkMessages(); } - }, + } ); } @@ -327,7 +329,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(); @@ -338,7 +340,7 @@ class AppState extends ChangeNotifier { forceRefresh: forceRefresh, ); } - + String getMessagesUrl() { return _messagesState.getMessagesUrl(uploadMode); } @@ -368,10 +370,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 @@ -400,7 +402,7 @@ class AppState extends ChangeNotifier { client.close(); } } - + /// Show re-authentication dialog if needed Future checkAndPromptReauthForMessages(BuildContext context) async { if (await needsReauthForMessages()) { @@ -408,7 +410,7 @@ class AppState extends ChangeNotifier { _showReauthDialog(context); } } - + void _showReauthDialog(BuildContext context) { showDialog( context: context, @@ -423,7 +425,7 @@ class AppState extends ChangeNotifier { ), ); } - + String _getApiHost() { switch (uploadMode) { case UploadMode.production: @@ -456,15 +458,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(); } @@ -512,7 +514,7 @@ class AppState extends ChangeNotifier { changesetComment: changesetComment, updateOperatorProfile: updateOperatorProfile, ); - + // Check tutorial completion if position changed if (target != null) { _checkTutorialCompletion(target); @@ -541,13 +543,13 @@ class AppState extends ChangeNotifier { 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(); @@ -571,17 +573,17 @@ class AppState extends ChangeNotifier { void _checkTutorialCompletion(LatLng newPosition) { if (_tutorialCompletionCallback == null || _tutorialStartPosition == null) return; - + // Calculate distance moved final distance = Distance(); 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(); } @@ -601,8 +603,6 @@ class AppState extends ChangeNotifier { _sessionState.cycleDirection(); } - - void cancelSession() { _sessionState.cancelSession(); } @@ -633,6 +633,11 @@ 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); _startUploader(); @@ -688,12 +693,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); } @@ -777,19 +782,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 } @@ -817,7 +822,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); @@ -833,13 +838,16 @@ 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 navigation avoidance distance Future setNavigationAvoidanceDistance(int distance) async { await _settingsState.setNavigationAvoidanceDistance(distance); @@ -853,7 +861,7 @@ class AppState extends ChangeNotifier { void clearQueue() { _uploadQueueState.clearQueue(); } - + void removeFromQueue(PendingUpload upload) { _uploadQueueState.removeFromQueue(upload); } @@ -902,7 +910,7 @@ class AppState extends ChangeNotifier { west: west, ); } - + List getSuspectedLocationsInBoundsSync({ required double north, required double south, @@ -929,12 +937,12 @@ class AppState extends ChangeNotifier { 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: @@ -974,7 +982,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 bf7f6ed3..0fead7be 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -25,7 +25,7 @@ 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 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 @@ -60,7 +60,7 @@ const Duration kUploadHttpTimeout = Duration(seconds: 30); // HTTP request timeo 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 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; @@ -78,7 +78,7 @@ const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Ove 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 = 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 @@ -109,10 +109,19 @@ const Duration kMarkerTapTimeout = Duration(milliseconds: 250); 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, 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 + +// 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 // Data refresh configuration const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds @@ -176,6 +185,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 @@ -188,12 +198,11 @@ 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/de.json b/lib/localizations/de.json index b8557991..5ac6cc22 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", "hideZoomControls": "Zoom-Steuerung Ausblenden", "enableOfflineFeatures": "Offline-Funktionen Aktivieren", @@ -107,6 +109,12 @@ "blockedTitle": "Benachrichtigungen sind blockiert", "blockedMessage": "Benachrichtigungen für DeFlock sind deaktiviert. Aktiviere sie in den Systemeinstellungen, um Näherungswarnungen zu erhalten." }, + "staleness": { + "showIndicator": "Veraltete Knoten anzeigen", + "showIndicatorExplanation": "Knoten hervorheben, die nicht kürzlich überprüft wurden", + "threshold": "Als veraltet betrachten nach", + "daysCount": "{} Tage" + }, "node": { "title": "Knoten #{}", "tagSheetTitle": "Gerät-Tags", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 36e915c0..e66f5aad 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", @@ -113,6 +114,7 @@ "proximityAlerts": "Proximity Alerts", "networkStatusIndicator": "Network Status Indicator", "keepScreenAwake": "Keep Screen Awake", + "dataFreshness": "Data Freshness", "hideZoomControls": "Hide Zoom Controls", "enableOfflineFeatures": "Enable Offline Features", "enableOfflineFeaturesTitle": "Enable offline features", @@ -144,6 +146,12 @@ "blockedTitle": "Notifications are blocked", "blockedMessage": "Notifications are turned off for DeFlock. Enable them in your system settings to receive proximity alerts." }, + "staleness": { + "showIndicator": "Show stale node indicator", + "showIndicatorExplanation": "Highlight nodes that haven't been checked recently", + "threshold": "Consider stale after", + "daysCount": "{} days" + }, "node": { "title": "Node #{}", "tagSheetTitle": "Surveillance Device Tags", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index d4bc19dd..a485b20b 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", "hideZoomControls": "Ocultar Controles de Zoom", "enableOfflineFeatures": "Activar Funciones sin Conexión", @@ -144,6 +146,12 @@ "blockedTitle": "Las notificaciones están bloqueadas", "blockedMessage": "Las notificaciones están desactivadas para DeFlock. Actívalas en los ajustes del sistema para recibir alertas de proximidad." }, + "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" + }, "node": { "title": "Nodo #{}", "tagSheetTitle": "Etiquetas del Dispositivo", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 47bd605c..9e7018fd 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", "hideZoomControls": "Masquer les Contrôles de Zoom", "enableOfflineFeatures": "Activer les Fonctionnalités Hors Ligne", @@ -144,6 +146,12 @@ "blockedTitle": "Les notifications sont bloquées", "blockedMessage": "Les notifications sont désactivées pour DeFlock. Activez-les dans les réglages du système pour recevoir les alertes de proximité." }, + "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" + }, "node": { "title": "Nœud #{}", "tagSheetTitle": "Balises du Dispositif", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 1a57865c..ea044d2c 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", "hideZoomControls": "Nascondi Controlli Zoom", "enableOfflineFeatures": "Abilita Funzionalità Offline", @@ -144,6 +146,12 @@ "blockedTitle": "Le notifiche sono bloccate", "blockedMessage": "Le notifiche sono disattivate per DeFlock. Attivale nelle impostazioni di sistema per ricevere gli avvisi di prossimità." }, + "staleness": { + "showIndicator": "Mostra indicatore nodi obsoleti", + "showIndicatorExplanation": "Evidenzia i nodi non controllati di recente", + "threshold": "Considera obsoleto dopo", + "daysCount": "{} giorni" + }, "node": { "title": "Nodo #{}", "tagSheetTitle": "Tag Dispositivo di Sorveglianza", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 4a379bd8..109c20d6 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", "hideZoomControls": "Zoomknoppen Verbergen", "enableOfflineFeatures": "Offline Functies Inschakelen", @@ -144,6 +146,12 @@ "blockedTitle": "Meldingen zijn geblokkeerd", "blockedMessage": "Meldingen zijn uitgeschakeld voor DeFlock. Schakel ze in via je systeeminstellingen om nabijheidswaarschuwingen te ontvangen." }, + "staleness": { + "showIndicator": "Verouderde knooppuntindicator tonen", + "showIndicatorExplanation": "Knooppunten markeren die niet recent zijn gecontroleerd", + "threshold": "Als verouderd beschouwen na", + "daysCount": "{} dagen" + }, "node": { "title": "Node #{}", "tagSheetTitle": "Surveillance Apparaat Tags", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index f7fb118b..794ebb86 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", "hideZoomControls": "Ukryj Kontrolki Zoomu", "enableOfflineFeatures": "Włącz Funkcje Offline", @@ -144,6 +146,12 @@ "blockedTitle": "Powiadomienia są zablokowane", "blockedMessage": "Powiadomienia dla DeFlock są wyłączone. Włącz je w ustawieniach systemowych, aby otrzymywać alerty o bliskości." }, + "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" + }, "node": { "title": "Węzeł #{}", "tagSheetTitle": "Tagi Urządzenia Nadzoru", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 2d0f48a4..97a55b13 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", "hideZoomControls": "Ocultar Controlos de Zoom", "enableOfflineFeatures": "Ativar Funcionalidades Offline", @@ -144,6 +146,12 @@ "blockedTitle": "As notificações estão bloqueadas", "blockedMessage": "As notificações estão desativadas para o DeFlock. Ative-as nas configurações do sistema para receber alertas de proximidade." }, + "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" + }, "node": { "title": "Nó #{}", "tagSheetTitle": "Tags do Dispositivo de Vigilância", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 8fd429d4..653c8004 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", "hideZoomControls": "Yakınlaştırma Kontrollerini Gizle", "enableOfflineFeatures": "Çevrimdışı Özellikleri Etkinleştir", @@ -144,6 +146,12 @@ "blockedTitle": "Bildirimler engellendi", "blockedMessage": "DeFlock için bildirimler kapalı. Yakınlık uyarıları almak için sistem ayarlarından etkinleştirin." }, + "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" + }, "node": { "title": "Düğüm #{}", "tagSheetTitle": "Gözetleme Cihazı Etiketleri", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index d756f842..4f9120d4 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": "Індикатор Стану Мережі", "hideZoomControls": "Приховати Елементи Керування Масштабом", "enableOfflineFeatures": "Увімкнути Офлайн-Функції", @@ -144,6 +146,12 @@ "blockedTitle": "Сповіщення заблоковано", "blockedMessage": "Сповіщення для DeFlock вимкнено. Увімкніть їх у налаштуваннях системи, щоб отримувати сповіщення про наближення." }, + "staleness": { + "showIndicator": "Показувати індикатор застарілих вузлів", + "showIndicatorExplanation": "Виділяти вузли, які давно не перевірялися", + "threshold": "Вважати застарілим після", + "daysCount": "{} днів" + }, "node": { "title": "Вузол #{}", "tagSheetTitle": "Теги Пристрою Спостереження", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 15bd4b4d..f1d2e50a 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": "网络状态指示器", "hideZoomControls": "隐藏缩放控件", "enableOfflineFeatures": "启用离线功能", @@ -144,6 +146,12 @@ "blockedTitle": "通知已被阻止", "blockedMessage": "DeFlock 的通知已关闭。请在系统设置中启用,以接收邻近提醒。" }, + "staleness": { + "showIndicator": "显示过期节点指示器", + "showIndicatorExplanation": "高亮显示最近未检查的节点", + "threshold": "视为过期的天数", + "daysCount": "{} 天" + }, "node": { "title": "节点 #{}", "tagSheetTitle": "监控设备标签", diff --git a/lib/models/osm_node.dart b/lib/models/osm_node.dart index efceedd6..a6745c82 100644 --- a/lib/models/osm_node.dart +++ b/lib/models/osm_node.dart @@ -30,6 +30,7 @@ class OsmNode { tags[k.toString()] = v.toString(); }); } + 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()), @@ -38,6 +39,19 @@ class OsmNode { ); } + /// 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 + double get stalenessProgress { + final date = checkDate; + 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; /// Get direction and FOV pairs, supporting range notation like "90-270" or "10-45;90-125;290" @@ -55,18 +69,19 @@ class OsmNode { 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 +89,17 @@ 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)); continue; } - + // Then try numeric parsing final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(trimmed); if (match == null) continue; @@ -95,9 +110,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 +123,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 +148,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..bbe2aa0b 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 { @@ -70,32 +70,33 @@ class PendingUpload { 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' + '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,32 +104,32 @@ 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; } - + // 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 + 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); return DateTime.now().isAfter(nextRetryTime); } @@ -211,18 +212,18 @@ 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 + 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); return DateTime.now().isAfter(nextRetryTime); } @@ -233,15 +234,15 @@ 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 @@ -249,12 +250,12 @@ class PendingUpload { 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 +266,102 @@ class PendingUpload { tags['direction'] = '0'; } } - + + // Add current check_date tag for all operations except delete (still there) + if (operation != UploadOperation.delete) { + tags['check_date'] = DateTime.now() + .toIso8601String() + .split('T') + .first; + } + // 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, + '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 + 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; @@ -361,9 +372,9 @@ class PendingUpload { final operation = j['operation'] != null ? UploadOperation.values[j['operation']] : (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create); - + final profileName = j['profile']?['name'] ?? 'surveillance'; - + switch (operation) { case UploadOperation.create: return 'Add $profileName surveillance node'; @@ -376,4 +387,3 @@ class PendingUpload { } } } - diff --git a/lib/screens/advanced_settings_screen.dart b/lib/screens/advanced_settings_screen.dart index 2bdabf8e..09f5cd71 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'; @@ -14,18 +15,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 [ @@ -33,6 +32,8 @@ class AdvancedSettingsScreen extends StatelessWidget { Divider(), ProximityAlertsSection(), Divider(), + StalenessIndicatorSection(), + Divider(), SuspectedLocationsSection(), Divider(), KeepScreenAwakeSection(), @@ -48,4 +49,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..80167828 --- /dev/null +++ b/lib/screens/settings/sections/staleness_indicator_section.dart @@ -0,0 +1,46 @@ +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}); + + @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, + ), + ], + ); + }, + ); + } +} 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) { diff --git a/lib/services/map_data_submodules/nodes_from_local.dart b/lib/services/map_data_submodules/nodes_from_local.dart index 4ca37288..9a536803 100644 --- a/lib/services/map_data_submodules/nodes_from_local.dart +++ b/lib/services/map_data_submodules/nodes_from_local.dart @@ -58,7 +58,8 @@ 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(); + return nodes; } catch (e) { debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e'); } diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 90299d7e..170852ae 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -293,10 +293,12 @@ out; // Second pass: create OsmNode objects final nodes = nodeElements.map((element) { final nodeId = element['id'] as int; + final tags = Map.from(element['tags'] ?? {}); + 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/session_state.dart b/lib/state/session_state.dart index 6925295f..db46a93a 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -11,14 +11,14 @@ 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 + 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 - + String changesetComment; // User-editable changeset comment + AddNodeSession({ - this.profile, + this.profile, double initialDirection = 0, this.operatorProfile, this.target, @@ -30,10 +30,10 @@ class AddNodeSession { refinedTags = refinedTags ?? {}, 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 +49,13 @@ 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 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 - + String changesetComment; // User-editable changeset comment + EditNodeSession({ required this.originalNode, required this.originalHadDirections, @@ -72,10 +72,10 @@ class EditNodeSession { refinedTags = refinedTags ?? {}, additionalExistingTags = additionalExistingTags ?? {}, 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) { @@ -105,22 +105,22 @@ class SessionState extends ChangeNotifier { 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); - + // 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 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); - + // Auto-populate refined tags (empty profile means no refined tags initially) final initialRefinedTags = _calculateRefinedTags(existingTagsProfile, node); - + _editSession = EditNodeSession( originalNode: node, originalHadDirections: originalHadDirections, @@ -132,7 +132,7 @@ class SessionState extends ChangeNotifier { refinedTags: initialRefinedTags, 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 @@ -143,44 +143,44 @@ class SessionState extends ChangeNotifier { /// Calculate additional existing tags for a given profile change 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) { 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 +189,21 @@ class SessionState extends ChangeNotifier { } } } - + return refinedTags; } - + /// 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; - + return false; } @@ -273,7 +273,7 @@ class SessionState extends ChangeNotifier { bool dirty = false; bool snapBackRequired = false; LatLng? snapBackTarget; - + if (directionDeg != null && directionDeg != _editSession!.directionDegrees) { _editSession!.directionDegrees = directionDeg; dirty = true; @@ -281,35 +281,35 @@ class SessionState extends ChangeNotifier { 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); } - + // 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); } - + // Regenerate changeset comment when profile changes final operation = _editSession!.extractFromWay ? UploadOperation.extract : UploadOperation.modify; _editSession!.changesetComment = _generateDefaultChangesetComment( profile: profile, operation: operation, ); - + dirty = true; } // Only update operator profile when explicitly requested @@ -331,6 +331,7 @@ class SessionState extends ChangeNotifier { } dirty = true; } + if (refinedTags != null) { _editSession!.refinedTags = Map.from(refinedTags); dirty = true; @@ -343,9 +344,9 @@ class SessionState extends ChangeNotifier { _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,7 +388,7 @@ 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) { @@ -411,8 +412,6 @@ class SessionState extends ChangeNotifier { } } - - void cancelSession() { _session = null; notifyListeners(); @@ -426,7 +425,7 @@ class SessionState extends ChangeNotifier { AddNodeSession? commitSession() { if (_session?.target == null || _session?.profile == null) return null; - + final session = _session!; _session = null; notifyListeners(); @@ -435,7 +434,7 @@ class SessionState extends ChangeNotifier { EditNodeSession? commitEditSession() { if (_editSession?.profile == null) return null; - + final session = _editSession!; _editSession = null; _detectedOperatorProfile = null; @@ -446,7 +445,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; @@ -461,9 +460,9 @@ class SessionState extends ChangeNotifier { /// Handle direction requirements when profile changes in edit session 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]; @@ -481,16 +480,16 @@ class SessionState extends ChangeNotifier { 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 80bc6ffe..1f54e04b 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 } @@ -34,6 +34,7 @@ class SettingsState extends ChangeNotifier { 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 _pauseQueueProcessingPrefsKey = 'pause_queue_processing'; @@ -55,6 +56,7 @@ class SettingsState extends ChangeNotifier { FollowMeMode _followMeMode = FollowMeMode.follow; bool _proximityAlertsEnabled = false; int _proximityAlertDistance = kProximityAlertDefaultDistance; + bool _stalenessIndicatorEnabled = false; bool _networkStatusIndicatorEnabled = true; int _suspectedLocationMinDistance = 100; // meters List _tileProviders = []; @@ -70,6 +72,7 @@ class SettingsState extends ChangeNotifier { FollowMeMode get followMeMode => _followMeMode; bool get proximityAlertsEnabled => _proximityAlertsEnabled; int get proximityAlertDistance => _proximityAlertDistance; + bool get stalenessIndicatorEnabled => _stalenessIndicatorEnabled; bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled; int get suspectedLocationMinDistance => _suspectedLocationMinDistance; bool get keepScreenAwake => _keepScreenAwake; @@ -81,7 +84,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) { @@ -93,7 +96,7 @@ class SettingsState extends ChangeNotifier { } return null; } - + /// Get the provider that contains the selected tile type TileProvider? get selectedTileProvider { for (final provider in _tileProviders) { @@ -103,7 +106,7 @@ class SettingsState extends ChangeNotifier { } return null; } - + /// Get all available tile types from all providers List get allAvailableTileTypes { final types = []; @@ -113,18 +116,16 @@ 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; - + // Load max nodes _maxNodes = prefs.getInt(_maxNodesPrefsKey) ?? kDefaultMaxNodes; @@ -132,7 +133,7 @@ class SettingsState extends ChangeNotifier { if (prefs.containsKey(_navigationAvoidanceDistancePrefsKey)) { _navigationAvoidanceDistance = prefs.getInt(_navigationAvoidanceDistancePrefsKey) ?? 250; } - + // Load distance unit if (prefs.containsKey(_distanceUnitPrefsKey)) { final unitIndex = prefs.getInt(_distanceUnitPrefsKey) ?? 0; @@ -140,14 +141,17 @@ class SettingsState extends ChangeNotifier { _distanceUnit = DistanceUnit.values[unitIndex]; } } - + // Load proximity alerts settings _proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false; _proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance; - + + // Load staleness indicator settings + _stalenessIndicatorEnabled = prefs.getBool(_stalenessIndicatorEnabledPrefsKey) ?? false; + // Load network status indicator setting _networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? true; - + // Load suspected location minimum distance _suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100; @@ -177,7 +181,7 @@ 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 @@ -192,10 +196,10 @@ class SettingsState extends ChangeNotifier { _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; @@ -203,7 +207,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) { @@ -224,7 +228,7 @@ class SettingsState extends ChangeNotifier { _tileProviders = providersList .map((json) => TileProvider.fromJson(json)) .toList(); - + // Migration: Add any missing built-in providers await _addMissingBuiltinProviders(prefs); } @@ -245,7 +249,7 @@ 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); @@ -253,7 +257,7 @@ class SettingsState extends ChangeNotifier { debugPrint('SettingsState: Added missing built-in provider: ${defaultProvider.name}'); } } - + if (hasUpdates) { await _saveTileProviders(prefs); } @@ -298,7 +302,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); @@ -323,7 +327,7 @@ class SettingsState extends ChangeNotifier { } else { _tileProviders.add(provider); } - + final prefs = await SharedPreferences.getInstance(); await _saveTileProviders(prefs); notifyListeners(); @@ -333,10 +337,10 @@ 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); if (providerToDelete == null) return; - + // If selected tile type belongs to this provider, switch to another if (providerToDelete.tileTypes.any((type) => type.id == _selectedTileTypeId)) { // Find first available tile type from remaining providers @@ -344,14 +348,14 @@ class SettingsState extends ChangeNotifier { 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); @@ -362,7 +366,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); @@ -373,13 +377,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) { @@ -402,6 +406,16 @@ 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 network status indicator enabled/disabled Future setNetworkStatusIndicatorEnabled(bool enabled) async { if (_networkStatusIndicatorEnabled != enabled) { @@ -423,14 +437,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 hide zoom controls enabled/disabled Future setHideZoomControls(bool enabled) async { @@ -478,5 +492,4 @@ class SettingsState extends ChangeNotifier { notifyListeners(); } } - } diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 15557f4c..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'; @@ -37,16 +38,16 @@ class UploadQueueState extends ChangeNotifier { void _repopulateCacheFromQueue() { 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}'); continue; } - + 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 +55,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, @@ -67,14 +68,14 @@ class UploadQueueState extends ChangeNotifier { // 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 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) { @@ -82,7 +83,7 @@ class UploadQueueState extends ChangeNotifier { if (existingOriginal != null) { final originalTags = Map.from(existingOriginal.tags); originalTags['_pending_edit'] = 'true'; - + final originalWithEdit = OsmNode( id: upload.originalNodeId!, coord: existingOriginal.coord, @@ -91,30 +92,26 @@ 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'); - + // Save queue if we updated any temp IDs for backward compatibility _saveQueue(); - + // Notify node provider to update the map NodeProviderWithCache.instance.notifyListeners(); } @@ -133,10 +130,10 @@ class UploadQueueState extends ChangeNotifier { 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 @@ -144,20 +141,62 @@ class UploadQueueState extends ChangeNotifier { final tags = upload.getCombinedTags(); 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(); + return upload; + } + + // Add a "verify" upload: resubmits a node with all existing tags unchanged, + // 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 + 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(); return upload; } @@ -167,7 +206,7 @@ class UploadQueueState extends ChangeNotifier { // 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; @@ -181,7 +220,7 @@ class UploadQueueState extends ChangeNotifier { operation = UploadOperation.modify; coordToUse = session.target; } - + final upload = PendingUpload( coord: coordToUse, direction: DirectionSubmissionFormatter.format(session.directions, session.profile), @@ -194,10 +233,10 @@ class UploadQueueState extends ChangeNotifier { 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 @@ -206,50 +245,50 @@ class UploadQueueState extends ChangeNotifier { 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 - + // 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 - + 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 - + // 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(); return upload; } @@ -265,24 +304,24 @@ class UploadQueueState extends ChangeNotifier { 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(); return upload; } @@ -292,22 +331,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(); @@ -322,7 +361,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, @@ -347,7 +386,7 @@ class UploadQueueState extends ChangeNotifier { // Process any expired items 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'); @@ -356,7 +395,7 @@ class UploadQueueState extends ChangeNotifier { notifyListeners(); } } - + for (final closingItem in closingItems) { if (closingItem.hasChangesetExpired) { debugPrint('[UploadQueue] Changeset expired during close - trusting OSM auto-close (node was submitted successfully)'); @@ -366,15 +405,15 @@ class UploadQueueState extends ChangeNotifier { // Find next pending item to start final pendingItems = _queue.where((pu) => pu.uploadState == UploadState.pending).toList(); - + if (pendingItems.isEmpty) { // Check if queue is effectively empty - final hasActiveItems = _queue.any((pu) => + final hasActiveItems = _queue.any((pu) => pu.uploadState == UploadState.creatingChangeset || pu.uploadState == UploadState.uploading || - pu.uploadState == UploadState.closingChangeset + pu.uploadState == UploadState.closingChangeset, ); - + if (!hasActiveItems) { debugPrint('[UploadQueue] No active items remaining, stopping uploader'); _uploadTimer?.cancel(); @@ -389,7 +428,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)'); - + _activeUploadCount++; _processIndividualUpload(item, access); }); @@ -399,7 +438,7 @@ class UploadQueueState extends ChangeNotifier { Future _processIndividualUpload(PendingUpload item, String accessToken) async { try { 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; @@ -408,7 +447,7 @@ class UploadQueueState extends ChangeNotifier { bool nodeOperationCompleted = false; while (!nodeOperationCompleted && !item.hasChangesetExpired && item.uploadState != UploadState.error) { await _processNodeOperation(item, accessToken); - + if (item.uploadState == UploadState.closingChangeset) { // Node operation succeeded nodeOperationCompleted = true; @@ -425,11 +464,11 @@ 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) { await _processChangesetClose(item, accessToken); - + if (item.uploadState == UploadState.complete) { // Changeset close succeeded changesetClosed = true; @@ -443,7 +482,7 @@ class UploadQueueState extends ChangeNotifier { 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'); @@ -451,7 +490,6 @@ class UploadQueueState extends ChangeNotifier { _markAsCompleting(item, submittedNodeId: item.submittedNodeId!); } } - } catch (e) { debugPrint('[UploadQueue] Unexpected error in individual upload processing: $e'); item.setError('Unexpected error: $e'); @@ -469,24 +507,24 @@ class UploadQueueState extends ChangeNotifier { 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 - + // Move to node operation phase 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); final result = await up.createChangeset(item); - + if (result.success) { // Changeset created successfully - move to node operation phase debugPrint('[UploadQueue] Changeset ${result.changesetId} created successfully'); @@ -498,7 +536,7 @@ 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'); _saveQueue(); @@ -531,14 +569,14 @@ class UploadQueueState extends ChangeNotifier { notifyListeners(); return; } - + 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 - + // 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) @@ -552,21 +590,21 @@ 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); - + // 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}'); @@ -577,7 +615,7 @@ class UploadQueueState extends ChangeNotifier { } else { // Node operation failed - will retry within 59-minute window 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'); @@ -610,34 +648,34 @@ class UploadQueueState extends ChangeNotifier { _markAsCompleting(item, submittedNodeId: item.submittedNodeId!); return; } - + 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 - + // Mark as complete _markAsCompleting(item, submittedNodeId: item.submittedNodeId!); return; } - + // Real changeset close 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'); _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 + // 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(); @@ -660,11 +698,11 @@ 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}) { 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'); _handleSuccessfulDeletion(item); @@ -674,7 +712,7 @@ class UploadQueueState extends ChangeNotifier { _updateCacheWithRealNodeId(item, submittedNodeId); } } else if (simulatedNodeId != null && item.uploadMode == UploadMode.simulate) { - // For simulate mode, use a fake but positive ID + // 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'); @@ -685,10 +723,10 @@ class UploadQueueState extends ChangeNotifier { _updateCacheWithRealNodeId(item, simulatedNodeId); } } - + _saveQueue(); notifyListeners(); - + // Remove the item after 1 second Timer(const Duration(seconds: 1), () { _queue.remove(item); @@ -696,26 +734,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) { @@ -723,7 +761,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(); } @@ -733,7 +771,7 @@ 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(); } @@ -800,4 +838,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..07b61510 100644 --- a/lib/widgets/camera_icon.dart +++ b/lib/widgets/camera_icon.dart @@ -2,24 +2,31 @@ 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 + 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 + 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}); + final double agingProgress; + + const CameraIcon({super.key, required this.type, this.agingProgress = 0.0}); Color get _ringColor { switch (type) { case CameraIconType.real: return kNodeRingColorReal; + case CameraIconType.stale: + return kNodeRingColorStale; + case CameraIconType.aging: + return _computeAgingColor(agingProgress); case CameraIconType.mock: return kNodeRingColorMock; case CameraIconType.pending: @@ -33,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( @@ -48,4 +66,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 2131172b..18c720cd 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -51,7 +51,7 @@ class _EditNodeSheetState extends State { _showTutorial = !hasCompleted; _isCheckingTutorial = false; }); - + // If tutorial should be shown, register callback with AppState if (_showTutorial) { final appState = context.read(); @@ -73,7 +73,7 @@ class _EditNodeSheetState extends State { void dispose() { // Remove listener NodeDataManager().removeListener(_onCacheUpdated); - + // Clear tutorial callback when widget is disposed if (_showTutorial) { try { @@ -116,11 +116,11 @@ class _EditNodeSheetState extends State { 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( @@ -168,20 +168,21 @@ class _EditNodeSheetState extends State { ); } - Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) { + Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService +locService) { final requiresDirection = session.profile != null && session.profile!.requiresDirection; final is360Fov = session.profile?.fov == 360; final hasDirections = session.directions.isNotEmpty; final enableDirectionControls = requiresDirection && !is360Fov && hasDirections; final enableAddButton = requiresDirection && !is360Fov; - + // Force direction to 0 when FOV is 360 (omnidirectional) if (is360Fov && session.directionDegrees != 0) { WidgetsBinding.instance.addPostFrameCallback((_) { appState.updateEditSession(directionDeg: 0); }); } - + // Format direction display text with bold for current direction String directionsText = ''; if (requiresDirection && hasDirections) { @@ -200,31 +201,33 @@ 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, - style: TextStyle( - fontWeight: isEven ? FontWeight.normal : FontWeight.bold, - ), - ); - }) - else - const TextSpan( - text: 'None', - style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey), - ), - ], - ), - ) - : Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])), + title: requiresDirection + ? RichText( + text: TextSpan( + style: Theme.of(context).textTheme.titleMedium, + children: [ + const TextSpan(text: 'Directions: '), + if (directionsText.isNotEmpty) + ...directionsText.split('**').asMap().entries.map(( + entry, + ) { + final isEven = entry.key % 2 == 0; + return TextSpan( + text: entry.value, + style: TextStyle( + fontWeight: isEven ? FontWeight.normal : FontWeight.bold, + ), + ); + }) + else + const TextSpan( + text: 'None', + style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey), + ), + ], + ), + ) + : Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])), subtitle: Row( children: [ // Slider takes most of the space @@ -243,15 +246,14 @@ class _EditNodeSheetState extends State { // Remove button IconButton( icon: Icon( - Icons.remove, + Icons.remove, size: 20, color: enableDirectionControls && appState.canRemoveDirection ? null : Theme.of(context).disabledColor, ), onPressed: enableDirectionControls && appState.canRemoveDirection - ? () => appState.removeDirection() - : null, - tooltip: requiresDirection - ? (hasDirections + ? () => appState.removeDirection() : null, + tooltip: requiresDirection + ? (hasDirections ? (appState.canRemoveDirection ? 'Remove current direction' : 'Cannot remove - minimum reached') : 'No directions to remove') : 'Direction not required for this profile', @@ -261,13 +263,13 @@ class _EditNodeSheetState extends State { // Add button IconButton( icon: Icon( - Icons.add, + Icons.add, size: 20, color: enableAddButton && session.directions.length < 8 ? null : Theme.of(context).disabledColor, ), onPressed: enableAddButton && session.directions.length < 8 ? () => appState.addDirection() : null, - tooltip: requiresDirection - ? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction') + 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), @@ -275,17 +277,17 @@ class _EditNodeSheetState extends State { // Cycle button IconButton( icon: Icon( - Icons.repeat, + Icons.repeat, size: 20, 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), @@ -364,24 +366,24 @@ class _EditNodeSheetState extends State { 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'); } @@ -417,214 +419,215 @@ 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), - - // 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) ...[ + const SizedBox(height: 8), + Text( + locService.t('editNode.title', params: [session.originalNode.id.toString()]), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + ListTile( + title: Text(locService.t('editNode.profile')), + trailing: _buildProfileDropdown(context, appState, session, submittableProfiles, locService), + ), + // Direction controls + _buildDirectionControls(context, appState, session, locService), + + // Constraint message for nodes that cannot be moved + if (session.originalNode.isConstrained) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Column( + children: [ + // Extract from way checkbox (only show if enabled in dev config) + if (kEnableNodeExtraction) ...[ + CheckboxListTile( + title: Text(locService.t('editNode.extractFromWay')), + subtitle: Text(locService.t('editNode.extractFromWaySubtitle')), + value: session.extractFromWay, + onChanged: (value) { + appState.updateEditSession(extractFromWay: value); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + const SizedBox(height: 8), + ], + // Constraint info message (only show if extract is not checked or not enabled) + if (!kEnableNodeExtraction || + !session.extractFromWay) ...[ + Row( + children: [ + const Icon(Icons.info_outline, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + locService.t('editNode.cannotMoveConstrainedNode'), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + const SizedBox(height: 8), + ], Row( + mainAxisAlignment: MainAxisAlignment.end, children: [ - 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), + const SizedBox(width: 6), + Expanded( + child: Text( + locService.t('editNode.temporarilyDisabled'), + style: const TextStyle(color: Colors.orange, fontSize: 13), + ), ), - ), - ], - ), - ) - else if (!appState.isLoggedIn) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Row( - children: [ - const Icon(Icons.info_outline, color: Colors.red, size: 20), - const SizedBox(width: 6), - Expanded( - child: Text( - locService.t('editNode.mustBeLoggedIn'), - style: const TextStyle(color: Colors.red, fontSize: 13), + ], + ), + ) + else if (!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 (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 - always mounted once the initial check @@ -641,7 +644,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'); @@ -650,7 +654,7 @@ class _EditNodeSheetState extends State { } return profile.name; } - + return PopupMenuButton( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -680,7 +684,7 @@ class _EditNodeSheetState extends State { child: Text(locService.t('editNode.existingTags')), ), // Divider after existing tags profile - if (submittableProfiles.isNotEmpty) + if (submittableProfiles.isNotEmpty) const PopupMenuDivider(), // Regular profiles ...submittableProfiles.map( @@ -727,7 +731,7 @@ class _EditNodeSheetState extends State { void _openIdentifyWebsite(BuildContext context) async { const url = 'https://deflock.me/identify'; - + try { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { @@ -763,4 +767,4 @@ class _EditNodeSheetState extends State { 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..63208905 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,14 @@ 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; - + // Disable node interactions when navigation is in conflicting state final shouldDisableNodeTaps = appState.isInSearchMode || appState.showingOverview; - + final markers = NodeMarkersBuilder.buildNodeMarkers( nodes: nodesToRender, mapController: mapController.mapController, @@ -77,11 +75,12 @@ class MarkerLayerBuilder { onNodeTap: onNodeTap, // Keep the original callback shouldDim: shouldDimNodes, enabled: !shouldDisableNodeTaps, // Use enabled parameter instead + stalenessIndicatorEnabled: appState.stalenessIndicatorEnabled ); // Build suspected location markers (respect same zoom and count limits as nodes) final suspectedLocationMarkers = []; - if (appState.suspectedLocationsEnabled && mapBounds != null && + if (appState.suspectedLocationsEnabled && mapBounds != null && currentZoom >= (appState.uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel)) { final suspectedLocations = appState.getSuspectedLocationsInBoundsSync( north: mapBounds.north, @@ -89,18 +88,18 @@ class MarkerLayerBuilder { east: mapBounds.east, west: mapBounds.west, ); - + // Apply same node count limit as surveillance nodes final maxNodes = appState.maxNodes; 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, ); - + suspectedLocationMarkers.addAll( SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers( locations: filteredSuspectedLocations, @@ -128,12 +127,12 @@ class MarkerLayerBuilder { return MarkerLayer( markers: [ - ...suspectedLocationMarkers, - ...markers, + ...suspectedLocationMarkers, + ...markers, ...centerMarkers, ...navigationMarkers, ...routeMarkers, - ] + ], ); }, ); @@ -217,31 +216,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..eb095edd 100644 --- a/lib/widgets/map/node_markers.dart +++ b/lib/widgets/map/node_markers.dart @@ -14,12 +14,14 @@ class NodeMapMarker extends StatefulWidget { final MapController mapController; final void Function(OsmNode)? onNodeTap; final bool enabled; - + final bool stalenessIndicatorEnabled; + const NodeMapMarker({ - required this.node, - required this.mapController, + required this.node, + required this.mapController, this.onNodeTap, this.enabled = true, + this.stalenessIndicatorEnabled = false, super.key, }); @@ -34,10 +36,10 @@ 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); @@ -56,7 +58,7 @@ 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); } @@ -70,28 +72,38 @@ 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; if (isPendingDeletion) { iconType = CameraIconType.pendingDeletion; } else if (isPendingUpload) { iconType = CameraIconType.pending; } else if (isPendingEdit) { iconType = CameraIconType.pendingEdit; - } 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), ); } } @@ -106,32 +118,33 @@ class NodeMarkersBuilder { void Function(OsmNode)? onNodeTap, bool shouldDim = false, bool enabled = true, + bool stalenessIndicatorEnabled = false }) { 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 ), - ), - ); - }), - + ), + ); + }), + // User location marker if (userLocation != null) Marker( @@ -147,7 +160,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 105bc6ca..8d30b911 100644 --- a/lib/widgets/node_provider_with_cache.dart +++ b/lib/widgets/node_provider_with_cache.dart @@ -24,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); @@ -114,11 +114,18 @@ 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) { + 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 bare key + final directMatch = node.tags[key] == expectedValue; + + if (!directMatch) return false; } return true; } diff --git a/lib/widgets/node_tag_sheet.dart b/lib/widgets/node_tag_sheet.dart index c728af13..e10a5741 100644 --- a/lib/widgets/node_tag_sheet.dart +++ b/lib/widgets/node_tag_sheet.dart @@ -18,8 +18,8 @@ class NodeTagSheet extends StatefulWidget { final bool isNodeLimitActive; const NodeTagSheet({ - super.key, - required this.node, + super.key, + required this.node, this.onEditPressed, this.isNodeLimitActive = false, }); @@ -140,7 +140,7 @@ class _NodeTagSheetState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - locService.t('nodeLimitIndicator.editingDisabledMessage') + locService.t('nodeLimitIndicator.editingDisabledMessage'), ), duration: const Duration(seconds: 4), behavior: SnackBarBehavior.floating, @@ -158,6 +158,14 @@ class _NodeTagSheetState extends State { } } + void verifyNode() { + Navigator.pop(context); // Close this sheet first + appState.verifyNode(displayNode); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('node.verifyQueuedForUpload'))), + ); + } + void deleteNode() async { if (!appState.isLoggedIn) { final shouldLogIn = await showDialog( @@ -479,40 +487,54 @@ 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; }