From e6b18bf89bf19c346e34fb4a819a78067758166a Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 10 Dec 2025 12:52:20 -0600 Subject: [PATCH] NSI and tag refinement --- README.md | 1 - assets/changelog.json | 8 + lib/app_state.dart | 20 ++ lib/localizations/de.json | 7 +- lib/localizations/en.json | 7 +- lib/localizations/es.json | 7 +- lib/localizations/fr.json | 7 +- lib/localizations/it.json | 7 +- lib/localizations/pt.json | 7 +- lib/localizations/zh.json | 7 +- lib/migrations.dart | 17 ++ lib/models/node_profile.dart | 5 + lib/models/pending_upload.dart | 23 ++- lib/screens/operator_profile_editor.dart | 19 +- lib/screens/profile_editor.dart | 21 +- .../nodes_from_osm_api.dart | 4 + .../nodes_from_overpass.dart | 3 +- lib/services/nsi_service.dart | 132 +++++++++++++ lib/state/profile_state.dart | 11 ++ lib/state/session_state.dart | 20 +- lib/state/upload_queue_state.dart | 2 + lib/widgets/add_node_sheet.dart | 11 +- lib/widgets/edit_node_sheet.dart | 11 +- lib/widgets/nsi_tag_value_field.dart | 181 +++++++++++++++++ lib/widgets/refine_tags_sheet.dart | 182 +++++++++++++++++- pubspec.yaml | 2 +- 26 files changed, 679 insertions(+), 43 deletions(-) create mode 100644 lib/services/nsi_service.dart create mode 100644 lib/widgets/nsi_tag_value_field.dart diff --git a/README.md b/README.md index cb7e6a5..ec17923 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,6 @@ cp lib/keys.dart.example lib/keys.dart ### Current Development - Optional reason message when deleting -- Dropdown on "refine tags" page to select acceptable options for camera:mount= (is this a boolean property of a profile?) - Option to pull in profiles from NSI (man_made=surveillance only?) ### On Pause diff --git a/assets/changelog.json b/assets/changelog.json index 9860ece..7222127 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,12 @@ { + "2.1.0": { + "content": [ + "• Profile tag refinement system - any profile tag with an empty value now shows a dropdown in refine tags", + "• OSM Name Suggestion Index (NSI) integration - shows most commonly used tag values from TagInfo API, both when creating/editing profiles and refining tags", + "• FIXED: Can now remove FOV values from profiles", + "• FIXED: Profile deletion while add/edit sheets are open no longer causes a crash" + ] + }, "1.8.3": { "content": [ "• Fixed node limit indicator disappearing when navigation sheet opens during search/routing", diff --git a/lib/app_state.dart b/lib/app_state.dart index 1d31147..63aaf90 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -208,6 +208,9 @@ class AppState extends ChangeNotifier { 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); @@ -388,6 +391,19 @@ class AppState extends ChangeNotifier { void deleteProfile(NodeProfile p) { _profileState.deleteProfile(p); } + + // 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 + if (_sessionState.editSession?.profile?.id == deletedProfile.id) { + cancelEditSession(); + } + } // ---------- Operator Profile Methods ---------- void addOrUpdateOperatorProfile(OperatorProfile p) { @@ -412,12 +428,14 @@ class AppState extends ChangeNotifier { NodeProfile? profile, OperatorProfile? operatorProfile, LatLng? target, + Map? refinedTags, }) { _sessionState.updateSession( directionDeg: directionDeg, profile: profile, operatorProfile: operatorProfile, target: target, + refinedTags: refinedTags, ); } @@ -427,6 +445,7 @@ class AppState extends ChangeNotifier { OperatorProfile? operatorProfile, LatLng? target, bool? extractFromWay, + Map? refinedTags, }) { _sessionState.updateEditSession( directionDeg: directionDeg, @@ -434,6 +453,7 @@ class AppState extends ChangeNotifier { operatorProfile: operatorProfile, target: target, extractFromWay: extractFromWay, + refinedTags: refinedTags, ); } diff --git a/lib/localizations/de.json b/lib/localizations/de.json index fdd3712..4c9f8a0 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -368,7 +368,12 @@ "additionalTagsTitle": "Zusätzliche Tags", "noTagsDefinedForProfile": "Keine Tags für dieses Betreiber-Profil definiert.", "noOperatorProfiles": "Keine Betreiber-Profile definiert", - "noOperatorProfilesMessage": "Erstellen Sie Betreiber-Profile in den Einstellungen, um zusätzliche Tags auf Ihre Knoten-Übertragungen anzuwenden." + "noOperatorProfilesMessage": "Erstellen Sie Betreiber-Profile in den Einstellungen, um zusätzliche Tags auf Ihre Knoten-Übertragungen anzuwenden.", + "profileTags": "Profil-Tags", + "profileTagsDescription": "Geben Sie Werte für Tags an, die verfeinert werden müssen:", + "selectValue": "Wert auswählen...", + "noValue": "(Kein Wert)", + "noSuggestions": "Keine Vorschläge verfügbar" }, "layerSelector": { "cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 7647ea8..512ce9c 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -400,7 +400,12 @@ "additionalTagsTitle": "Additional Tags", "noTagsDefinedForProfile": "No tags defined for this operator profile.", "noOperatorProfiles": "No operator profiles defined", - "noOperatorProfilesMessage": "Create operator profiles in Settings to apply additional tags to your node submissions." + "noOperatorProfilesMessage": "Create operator profiles in Settings to apply additional tags to your node submissions.", + "profileTags": "Profile Tags", + "profileTagsDescription": "Complete these optional tag values for more detailed submissions:", + "selectValue": "Select value...", + "noValue": "(leave empty)", + "noSuggestions": "No suggestions available" }, "layerSelector": { "cannotChangeTileTypes": "Cannot change tile types while downloading offline areas", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 656f34b..5a791f1 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -400,7 +400,12 @@ "additionalTagsTitle": "Etiquetas Adicionales", "noTagsDefinedForProfile": "No hay etiquetas definidas para este perfil de operador.", "noOperatorProfiles": "No hay perfiles de operador definidos", - "noOperatorProfilesMessage": "Cree perfiles de operador en Configuración para aplicar etiquetas adicionales a sus envíos de nodos." + "noOperatorProfilesMessage": "Cree perfiles de operador en Configuración para aplicar etiquetas adicionales a sus envíos de nodos.", + "profileTags": "Etiquetas de Perfil", + "profileTagsDescription": "Especifique valores para etiquetas que necesitan refinamiento:", + "selectValue": "Seleccionar un valor...", + "noValue": "(Sin valor)", + "noSuggestions": "No hay sugerencias disponibles" }, "layerSelector": { "cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 0eed4c5..ff912fb 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -400,7 +400,12 @@ "additionalTagsTitle": "Étiquettes Supplémentaires", "noTagsDefinedForProfile": "Aucune étiquette définie pour ce profil d'opérateur.", "noOperatorProfiles": "Aucun profil d'opérateur défini", - "noOperatorProfilesMessage": "Créez des profils d'opérateur dans les Paramètres pour appliquer des étiquettes supplémentaires à vos soumissions de nœuds." + "noOperatorProfilesMessage": "Créez des profils d'opérateur dans les Paramètres pour appliquer des étiquettes supplémentaires à vos soumissions de nœuds.", + "profileTags": "Étiquettes de Profil", + "profileTagsDescription": "Spécifiez des valeurs pour les étiquettes qui nécessitent un raffinement :", + "selectValue": "Sélectionner une valeur...", + "noValue": "(Aucune valeur)", + "noSuggestions": "Aucune suggestion disponible" }, "layerSelector": { "cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index e3d306b..6ea97fe 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -400,7 +400,12 @@ "additionalTagsTitle": "Tag Aggiuntivi", "noTagsDefinedForProfile": "Nessun tag definito per questo profilo operatore.", "noOperatorProfiles": "Nessun profilo operatore definito", - "noOperatorProfilesMessage": "Crea profili operatore nelle Impostazioni per applicare tag aggiuntivi ai tuoi invii di nodi." + "noOperatorProfilesMessage": "Crea profili operatore nelle Impostazioni per applicare tag aggiuntivi ai tuoi invii di nodi.", + "profileTags": "Tag del Profilo", + "profileTagsDescription": "Specificare valori per i tag che necessitano di raffinamento:", + "selectValue": "Seleziona un valore...", + "noValue": "(Nessun valore)", + "noSuggestions": "Nessun suggerimento disponibile" }, "layerSelector": { "cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index c002638..ba61d42 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -400,7 +400,12 @@ "additionalTagsTitle": "Tags Adicionais", "noTagsDefinedForProfile": "Nenhuma tag definida para este perfil de operador.", "noOperatorProfiles": "Nenhum perfil de operador definido", - "noOperatorProfilesMessage": "Crie perfis de operador nas Configurações para aplicar tags adicionais aos seus envios de nós." + "noOperatorProfilesMessage": "Crie perfis de operador nas Configurações para aplicar tags adicionais aos seus envios de nós.", + "profileTags": "Tags do Perfil", + "profileTagsDescription": "Especifique valores para tags que precisam de refinamento:", + "selectValue": "Selecionar um valor...", + "noValue": "(Sem valor)", + "noSuggestions": "Nenhuma sugestão disponível" }, "layerSelector": { "cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index a8e7143..ca22db8 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -400,7 +400,12 @@ "additionalTagsTitle": "额外标签", "noTagsDefinedForProfile": "此运营商配置文件未定义标签。", "noOperatorProfiles": "未定义运营商配置文件", - "noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。" + "noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。", + "profileTags": "配置文件标签", + "profileTagsDescription": "为需要细化的标签指定值:", + "selectValue": "选择值...", + "noValue": "(无值)", + "noSuggestions": "无建议可用" }, "layerSelector": { "cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型", diff --git a/lib/migrations.dart b/lib/migrations.dart index 0e1d3cc..a661501 100644 --- a/lib/migrations.dart +++ b/lib/migrations.dart @@ -100,6 +100,21 @@ class OneTimeMigrations { } } + /// Clear any active sessions to reset refined tags system (v2.1.0) + static Future migrate_2_1_0(AppState appState) async { + try { + // Clear any existing sessions since they won't have refinedTags field + // This is simpler and safer than trying to migrate session data + appState.cancelSession(); + appState.cancelEditSession(); + + debugPrint('[Migration] 2.1.0 completed: cleared sessions for refined tags system'); + } catch (e) { + debugPrint('[Migration] 2.1.0 ERROR: Failed to clear sessions: $e'); + // Don't rethrow - this is non-critical + } + } + /// Get the migration function for a specific version static Future Function(AppState)? getMigrationForVersion(String version) { switch (version) { @@ -111,6 +126,8 @@ class OneTimeMigrations { return migrate_1_6_3; case '1.8.0': return migrate_1_8_0; + case '2.1.0': + return migrate_2_1_0; default: return null; } diff --git a/lib/models/node_profile.dart b/lib/models/node_profile.dart index 108694e..d798fc7 100644 --- a/lib/models/node_profile.dart +++ b/lib/models/node_profile.dart @@ -30,6 +30,7 @@ class NodeProfile { tags: const { 'man_made': 'surveillance', 'surveillance:type': 'ALPR', + 'camera:mount': '', // Empty value for refinement }, builtin: true, requiresDirection: true, @@ -45,6 +46,7 @@ class NodeProfile { 'surveillance:type': 'ALPR', 'surveillance:zone': 'traffic', 'camera:type': 'fixed', + 'camera:mount': '', // Empty value for refinement 'manufacturer': 'Flock Safety', 'manufacturer:wikidata': 'Q108485435', }, @@ -62,6 +64,7 @@ class NodeProfile { 'surveillance:type': 'ALPR', 'surveillance:zone': 'traffic', 'camera:type': 'fixed', + 'camera:mount': '', // Empty value for refinement 'manufacturer': 'Motorola Solutions', 'manufacturer:wikidata': 'Q634815', }, @@ -79,6 +82,8 @@ class NodeProfile { 'surveillance:type': 'ALPR', 'surveillance:zone': 'traffic', 'camera:type': 'fixed', + 'camera:mount': '', // Empty for refinement + 'surveillance:brand': '', // Empty for refinement 'manufacturer': 'Genetec', 'manufacturer:wikidata': 'Q30295174', }, diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index d25307f..d2f53e0 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -21,6 +21,7 @@ class PendingUpload { final dynamic direction; // Can be double or String for multiple directions final NodeProfile? profile; final OperatorProfile? operatorProfile; + final Map refinedTags; // User-selected values for empty profile tags final UploadMode uploadMode; // Capture upload destination when queued final UploadOperation operation; // Type of operation: create, modify, or delete final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node @@ -43,6 +44,7 @@ class PendingUpload { required this.direction, this.profile, this.operatorProfile, + Map? refinedTags, required this.uploadMode, required this.operation, this.originalNodeId, @@ -59,7 +61,8 @@ class PendingUpload { this.lastChangesetCloseAttemptAt, this.nodeSubmissionAttempts = 0, this.lastNodeSubmissionAttemptAt, - }) : assert( + }) : refinedTags = refinedTags ?? {}, + 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' @@ -219,7 +222,7 @@ class PendingUpload { return DateTime.now().isAfter(nextRetryTime); } - // Get combined tags from node profile and operator profile + // Get combined tags from node profile, operator profile, and refined tags Map getCombinedTags() { // Deletions don't need tags if (operation == UploadOperation.delete || profile == null) { @@ -228,6 +231,14 @@ class PendingUpload { final tags = Map.from(profile!.tags); + // Apply refined tags (these fill in empty values from the profile) + for (final entry in refinedTags.entries) { + // Only apply refined tags if the profile tag value is empty + if (tags.containsKey(entry.key) && tags[entry.key]?.trim().isEmpty == true) { + tags[entry.key] = entry.value; + } + } + // Add operator profile tags (they override node profile tags if there are conflicts) if (operatorProfile != null) { tags.addAll(operatorProfile!.tags); @@ -244,6 +255,10 @@ class PendingUpload { } } + // 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; } @@ -253,6 +268,7 @@ class PendingUpload { 'dir': direction, 'profile': profile?.toJson(), 'operatorProfile': operatorProfile?.toJson(), + 'refinedTags': refinedTags, 'uploadMode': uploadMode.index, 'operation': operation.index, 'originalNodeId': originalNodeId, @@ -280,6 +296,9 @@ class PendingUpload { operatorProfile: j['operatorProfile'] != null ? OperatorProfile.fromJson(j['operatorProfile']) : null, + refinedTags: j['refinedTags'] != null + ? Map.from(j['refinedTags']) + : {}, // Default empty map for legacy entries uploadMode: j['uploadMode'] != null ? UploadMode.values[j['uploadMode']] : UploadMode.production, // Default for legacy entries diff --git a/lib/screens/operator_profile_editor.dart b/lib/screens/operator_profile_editor.dart index 85dd39d..23cc4ce 100644 --- a/lib/screens/operator_profile_editor.dart +++ b/lib/screens/operator_profile_editor.dart @@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart'; import '../models/operator_profile.dart'; import '../app_state.dart'; import '../services/localization_service.dart'; +import '../widgets/nsi_tag_value_field.dart'; class OperatorProfileEditor extends StatefulWidget { const OperatorProfileEditor({super.key, required this.profile}); @@ -123,14 +124,12 @@ class _OperatorProfileEditorState extends State { const SizedBox(width: 8), Expanded( flex: 3, - child: TextField( - decoration: InputDecoration( - hintText: locService.t('profileEditor.valueHint'), - border: const OutlineInputBorder(), - isDense: true, - ), - controller: valueController, - onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v), + child: NSITagValueField( + key: ValueKey('${_tags[i].key}_$i'), // Rebuild when key changes + tagKey: _tags[i].key, + initialValue: _tags[i].value, + hintText: locService.t('profileEditor.valueHint'), + onChanged: (v) => setState(() => _tags[i] = MapEntry(_tags[i].key, v)), ), ), IconButton( @@ -155,8 +154,8 @@ class _OperatorProfileEditorState extends State { final tagMap = {}; for (final e in _tags) { - if (e.key.trim().isEmpty || e.value.trim().isEmpty) continue; - tagMap[e.key.trim()] = e.value.trim(); + if (e.key.trim().isEmpty) continue; // Skip only if key is empty + tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement } final newProfile = widget.profile.copyWith( diff --git a/lib/screens/profile_editor.dart b/lib/screens/profile_editor.dart index cf68dd2..256ec95 100644 --- a/lib/screens/profile_editor.dart +++ b/lib/screens/profile_editor.dart @@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart'; import '../models/node_profile.dart'; import '../app_state.dart'; import '../services/localization_service.dart'; +import '../widgets/nsi_tag_value_field.dart'; class ProfileEditor extends StatefulWidget { const ProfileEditor({super.key, required this.profile}); @@ -175,17 +176,15 @@ class _ProfileEditorState extends State { const SizedBox(width: 8), Expanded( flex: 3, - child: TextField( - decoration: InputDecoration( - hintText: locService.t('profileEditor.valueHint'), - border: const OutlineInputBorder(), - isDense: true, - ), - controller: valueController, + child: NSITagValueField( + key: ValueKey('${_tags[i].key}_$i'), // Rebuild when key changes + tagKey: _tags[i].key, + initialValue: _tags[i].value, + hintText: locService.t('profileEditor.valueHint'), readOnly: !widget.profile.editable, onChanged: !widget.profile.editable - ? null - : (v) => _tags[i] = MapEntry(_tags[i].key, v), + ? (v) {} // No-op when read-only + : (v) => setState(() => _tags[i] = MapEntry(_tags[i].key, v)), ), ), if (widget.profile.editable) @@ -231,8 +230,8 @@ class _ProfileEditorState extends State { final tagMap = {}; for (final e in _tags) { - if (e.key.trim().isEmpty || e.value.trim().isEmpty) continue; - tagMap[e.key.trim()] = e.value.trim(); + if (e.key.trim().isEmpty) continue; // Skip only if key is empty + tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement } if (tagMap.isEmpty) { diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index a6c7ad0..4501a5a 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -202,7 +202,11 @@ bool _nodeMatchesProfiles(Map nodeTags, List profil /// Check if a node's tags match a specific profile bool _nodeMatchesProfile(Map nodeTags, NodeProfile profile) { // All profile tags must be present in the node for it to match + // Skip empty values as they are for refinement purposes only for (final entry in profile.tags.entries) { + if (entry.value.trim().isEmpty) { + continue; // Skip empty values - they don't need to match anything + } if (nodeTags[entry.key] != entry.value) { return false; } diff --git a/lib/services/map_data_submodules/nodes_from_overpass.dart b/lib/services/map_data_submodules/nodes_from_overpass.dart index 1039055..f898d98 100644 --- a/lib/services/map_data_submodules/nodes_from_overpass.dart +++ b/lib/services/map_data_submodules/nodes_from_overpass.dart @@ -197,8 +197,9 @@ Future> _fetchSingleOverpassQuery({ String _buildOverpassQuery(LatLngBounds bounds, List profiles, int maxResults) { // Build node clauses for each profile final nodeClauses = profiles.map((profile) { - // Convert profile tags to Overpass filter format + // Convert profile tags to Overpass filter format, excluding empty values final tagFilters = profile.tags.entries + .where((entry) => entry.value.trim().isNotEmpty) // Skip empty values .map((entry) => '["${entry.key}"="${entry.value}"]') .join(); diff --git a/lib/services/nsi_service.dart b/lib/services/nsi_service.dart new file mode 100644 index 0000000..0368415 --- /dev/null +++ b/lib/services/nsi_service.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import '../app_state.dart'; + +/// Service for fetching tag value suggestions from OpenStreetMap Name Suggestion Index +class NSIService { + static final NSIService _instance = NSIService._(); + factory NSIService() => _instance; + NSIService._(); + + static const String _userAgent = 'DeFlock/2.1.0 (OSM surveillance mapping app)'; + static const Duration _timeout = Duration(seconds: 10); + + // Cache to avoid repeated API calls + final Map> _suggestionCache = {}; + + /// Get suggested values for a given OSM tag key + /// Returns a list of the most commonly used values, or empty list if none found + Future> getSuggestionsForTag(String tagKey) async { + if (tagKey.trim().isEmpty) { + return []; + } + + final cacheKey = tagKey.trim().toLowerCase(); + + // Return cached results if available + if (_suggestionCache.containsKey(cacheKey)) { + return _suggestionCache[cacheKey]!; + } + + try { + final suggestions = await _fetchSuggestionsForTag(tagKey); + _suggestionCache[cacheKey] = suggestions; + return suggestions; + } catch (e) { + debugPrint('[NSIService] Failed to fetch suggestions for $tagKey: $e'); + // Cache empty result to avoid repeated failures + _suggestionCache[cacheKey] = []; + return []; + } + } + + /// Fetch tag value suggestions from TagInfo API + Future> _fetchSuggestionsForTag(String tagKey) async { + final uri = Uri.parse('https://taginfo.openstreetmap.org/api/4/key/values') + .replace(queryParameters: { + 'key': tagKey, + 'format': 'json', + 'sortname': 'count', + 'sortorder': 'desc', + 'page': '1', + 'rp': '15', // Get top 15 most commonly used values + }); + + final response = await http.get( + uri, + headers: {'User-Agent': _userAgent}, + ).timeout(_timeout); + + if (response.statusCode != 200) { + throw Exception('TagInfo API returned status ${response.statusCode}'); + } + + final data = jsonDecode(response.body) as Map; + final values = data['data'] as List? ?? []; + + // Extract the most commonly used values + final suggestions = []; + + for (final item in values) { + if (item is Map) { + final value = item['value'] as String?; + if (value != null && value.trim().isNotEmpty && _isValidSuggestion(value)) { + suggestions.add(value.trim()); + } + } + + // Limit to top 10 suggestions for UI performance + if (suggestions.length >= 10) break; + } + + return suggestions; + } + + /// Filter out common unwanted values that appear in TagInfo but aren't useful suggestions + bool _isValidSuggestion(String value) { + final lowercaseValue = value.toLowerCase(); + + // Filter out obvious non-useful values + final unwanted = { + 'yes', 'no', 'unknown', '?', 'null', 'none', 'n/a', 'na', + 'todo', 'fixme', 'check', 'verify', 'test', 'temp', 'temporary' + }; + + if (unwanted.contains(lowercaseValue)) { + return false; + } + + // Filter out very short generic values (except single letters that might be valid) + if (value.length == 1 && !RegExp(r'[A-Z]').hasMatch(value)) { + return false; + } + + return true; + } + + + + /// Get suggestions for a tag key - returns empty list when offline mode enabled + Future> getAllSuggestions(String tagKey) async { + // Check if app is in offline mode + if (AppState.instance.offlineMode) { + debugPrint('[NSIService] Offline mode enabled - no suggestions available for $tagKey'); + return []; // No suggestions when in offline mode - user must input manually + } + + // Online mode: try to get suggestions from API + try { + return await getSuggestionsForTag(tagKey); + } catch (e) { + debugPrint('[NSIService] API call failed: $e'); + return []; // No fallback - just return empty list + } + } + + /// Clear the suggestion cache (useful for testing or memory management) + void clearCache() { + _suggestionCache.clear(); + } +} \ No newline at end of file diff --git a/lib/state/profile_state.dart b/lib/state/profile_state.dart index 8cbdc13..41493ab 100644 --- a/lib/state/profile_state.dart +++ b/lib/state/profile_state.dart @@ -9,6 +9,13 @@ class ProfileState extends ChangeNotifier { final List _profiles = []; final Set _enabled = {}; + + // Callback for when a profile is deleted (used to clear stale sessions) + void Function(NodeProfile)? _onProfileDeleted; + + void setProfileDeletedCallback(void Function(NodeProfile) callback) { + _onProfileDeleted = callback; + } // Getters List get profiles => List.unmodifiable(_profiles); @@ -78,6 +85,10 @@ class ProfileState extends ChangeNotifier { } _saveEnabledProfiles(); ProfileService().save(_profiles); + + // Notify about profile deletion so other parts can clean up + _onProfileDeleted?.call(p); + notifyListeners(); } diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index cfbdfcb..9058012 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -12,14 +12,17 @@ class AddNodeSession { 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 AddNodeSession({ this.profile, double initialDirection = 0, this.operatorProfile, this.target, + Map? refinedTags, }) : directions = [initialDirection], - currentDirectionIndex = 0; + currentDirectionIndex = 0, + refinedTags = refinedTags ?? {}; // Slider always shows the current direction being edited double get directionDegrees => directions[currentDirectionIndex]; @@ -35,6 +38,7 @@ class EditNodeSession { 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 EditNodeSession({ required this.originalNode, @@ -42,8 +46,10 @@ class EditNodeSession { required double initialDirection, required this.target, this.extractFromWay = false, + Map? refinedTags, }) : directions = [initialDirection], - currentDirectionIndex = 0; + currentDirectionIndex = 0, + refinedTags = refinedTags ?? {}; // Slider always shows the current direction being edited double get directionDegrees => directions[currentDirectionIndex]; @@ -112,6 +118,7 @@ class SessionState extends ChangeNotifier { NodeProfile? profile, OperatorProfile? operatorProfile, LatLng? target, + Map? refinedTags, }) { if (_session == null) return; @@ -132,6 +139,10 @@ class SessionState extends ChangeNotifier { _session!.target = target; dirty = true; } + if (refinedTags != null) { + _session!.refinedTags = Map.from(refinedTags); + dirty = true; + } if (dirty) notifyListeners(); } @@ -141,6 +152,7 @@ class SessionState extends ChangeNotifier { OperatorProfile? operatorProfile, LatLng? target, bool? extractFromWay, + Map? refinedTags, }) { if (_editSession == null) return; @@ -174,6 +186,10 @@ class SessionState extends ChangeNotifier { } dirty = true; } + if (refinedTags != null) { + _editSession!.refinedTags = Map.from(refinedTags); + dirty = true; + } if (dirty) notifyListeners(); diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index ce69548..2d126a8 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -124,6 +124,7 @@ class UploadQueueState extends ChangeNotifier { direction: _formatDirectionsForSubmission(session.directions, session.profile), profile: session.profile!, // Safe to use ! because commitSession() checks for null operatorProfile: session.operatorProfile, + refinedTags: session.refinedTags, uploadMode: uploadMode, operation: UploadOperation.create, ); @@ -180,6 +181,7 @@ class UploadQueueState extends ChangeNotifier { direction: _formatDirectionsForSubmission(session.directions, session.profile), profile: session.profile!, // Safe to use ! because commitEditSession() checks for null operatorProfile: session.operatorProfile, + refinedTags: session.refinedTags, uploadMode: uploadMode, operation: operation, originalNodeId: session.originalNode.id, // Track which node we're editing diff --git a/lib/widgets/add_node_sheet.dart b/lib/widgets/add_node_sheet.dart index c148c89..a519188 100644 --- a/lib/widgets/add_node_sheet.dart +++ b/lib/widgets/add_node_sheet.dart @@ -227,17 +227,22 @@ class AddNodeSheet extends StatelessWidget { session.profile!.isSubmittable; void _openRefineTags() async { - final result = await Navigator.push( + final result = await Navigator.push( context, MaterialPageRoute( builder: (context) => RefineTagsSheet( selectedOperatorProfile: session.operatorProfile, + selectedProfile: session.profile, + currentRefinedTags: session.refinedTags, ), fullscreenDialog: true, ), ); - if (result != session.operatorProfile) { - appState.updateSession(operatorProfile: result); + if (result != null) { + appState.updateSession( + operatorProfile: result.operatorProfile, + refinedTags: result.refinedTags, + ); } } diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index 123bb97..6d069c8 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -226,17 +226,22 @@ class EditNodeSheet extends StatelessWidget { session.profile!.isSubmittable; void _openRefineTags() async { - final result = await Navigator.push( + final result = await Navigator.push( context, MaterialPageRoute( builder: (context) => RefineTagsSheet( selectedOperatorProfile: session.operatorProfile, + selectedProfile: session.profile, + currentRefinedTags: session.refinedTags, ), fullscreenDialog: true, ), ); - if (result != session.operatorProfile) { - appState.updateEditSession(operatorProfile: result); + if (result != null) { + appState.updateEditSession( + operatorProfile: result.operatorProfile, + refinedTags: result.refinedTags, + ); } } diff --git a/lib/widgets/nsi_tag_value_field.dart b/lib/widgets/nsi_tag_value_field.dart new file mode 100644 index 0000000..86d9528 --- /dev/null +++ b/lib/widgets/nsi_tag_value_field.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; + +import '../services/nsi_service.dart'; + +/// A text field that provides NSI suggestions for OSM tag values +class NSITagValueField extends StatefulWidget { + const NSITagValueField({ + super.key, + required this.tagKey, + required this.initialValue, + required this.onChanged, + this.readOnly = false, + this.hintText, + }); + + final String tagKey; + final String initialValue; + final ValueChanged onChanged; + final bool readOnly; + final String? hintText; + + @override + State createState() => _NSITagValueFieldState(); +} + +class _NSITagValueFieldState extends State { + late TextEditingController _controller; + List _suggestions = []; + bool _showingSuggestions = false; + final LayerLink _layerLink = LayerLink(); + late OverlayEntry _overlayEntry; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + _loadSuggestions(); + + _focusNode.addListener(_onFocusChanged); + } + + @override + void didUpdateWidget(NSITagValueField oldWidget) { + super.didUpdateWidget(oldWidget); + + // If the tag key changed, reload suggestions + if (oldWidget.tagKey != widget.tagKey) { + _hideSuggestions(); // Hide old suggestions immediately + _suggestions.clear(); + _loadSuggestions(); // Load new suggestions for new key + } + + // If the initial value changed, update the controller + if (oldWidget.initialValue != widget.initialValue) { + _controller.text = widget.initialValue; + } + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + _hideSuggestions(); + super.dispose(); + } + + void _loadSuggestions() async { + if (widget.tagKey.trim().isEmpty) return; + + try { + final suggestions = await NSIService().getAllSuggestions(widget.tagKey); + if (mounted) { + setState(() { + _suggestions = suggestions.take(10).toList(); // Limit to 10 suggestions + }); + } + } catch (e) { + // Silently fail - field still works as regular text field + if (mounted) { + setState(() { + _suggestions = []; + }); + } + } + } + + void _onFocusChanged() { + if (_focusNode.hasFocus && _suggestions.isNotEmpty && !widget.readOnly) { + _showSuggestions(); + } else { + _hideSuggestions(); + } + } + + void _showSuggestions() { + if (_showingSuggestions || _suggestions.isEmpty) return; + + _overlayEntry = OverlayEntry( + builder: (context) => Positioned( + width: 200, // Fixed width for suggestions + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: const Offset(0.0, 35.0), // Below the text field + child: Material( + elevation: 4.0, + borderRadius: BorderRadius.circular(8.0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: _suggestions.length, + itemBuilder: (context, index) { + final suggestion = _suggestions[index]; + return ListTile( + dense: true, + title: Text(suggestion, style: const TextStyle(fontSize: 14)), + onTap: () => _selectSuggestion(suggestion), + ); + }, + ), + ), + ), + ), + ), + ); + + Overlay.of(context).insert(_overlayEntry); + setState(() { + _showingSuggestions = true; + }); + } + + void _hideSuggestions() { + if (!_showingSuggestions) return; + + _overlayEntry.remove(); + setState(() { + _showingSuggestions = false; + }); + } + + void _selectSuggestion(String suggestion) { + _controller.text = suggestion; + widget.onChanged(suggestion); + _hideSuggestions(); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _layerLink, + child: TextField( + controller: _controller, + focusNode: _focusNode, + readOnly: widget.readOnly, + decoration: InputDecoration( + hintText: widget.hintText, + border: const OutlineInputBorder(), + isDense: true, + suffixIcon: _suggestions.isNotEmpty && !widget.readOnly + ? Icon( + Icons.arrow_drop_down, + color: _showingSuggestions ? Theme.of(context).primaryColor : Colors.grey, + ) + : null, + ), + onChanged: widget.readOnly ? null : (value) { + widget.onChanged(value); + }, + onTap: () { + if (!widget.readOnly && _suggestions.isNotEmpty) { + _showSuggestions(); + } + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/refine_tags_sheet.dart b/lib/widgets/refine_tags_sheet.dart index befa7c5..b9a0a76 100644 --- a/lib/widgets/refine_tags_sheet.dart +++ b/lib/widgets/refine_tags_sheet.dart @@ -3,15 +3,32 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../models/operator_profile.dart'; +import '../models/node_profile.dart'; import '../services/localization_service.dart'; +import '../services/nsi_service.dart'; + +/// Result returned from RefineTagsSheet +class RefineTagsResult { + final OperatorProfile? operatorProfile; + final Map refinedTags; + + RefineTagsResult({ + required this.operatorProfile, + required this.refinedTags, + }); +} class RefineTagsSheet extends StatefulWidget { const RefineTagsSheet({ super.key, this.selectedOperatorProfile, + this.selectedProfile, + this.currentRefinedTags, }); final OperatorProfile? selectedOperatorProfile; + final NodeProfile? selectedProfile; + final Map? currentRefinedTags; @override State createState() => _RefineTagsSheetState(); @@ -19,11 +36,58 @@ class RefineTagsSheet extends StatefulWidget { class _RefineTagsSheetState extends State { OperatorProfile? _selectedOperatorProfile; + Map _refinedTags = {}; + Map> _tagSuggestions = {}; + Map _loadingSuggestions = {}; @override void initState() { super.initState(); _selectedOperatorProfile = widget.selectedOperatorProfile; + _refinedTags = Map.from(widget.currentRefinedTags ?? {}); + _loadTagSuggestions(); + } + + /// Load suggestions for all empty-value tags in the selected profile + void _loadTagSuggestions() async { + if (widget.selectedProfile == null) return; + + final refinableTags = _getRefinableTags(); + + for (final tagKey in refinableTags) { + if (_tagSuggestions.containsKey(tagKey)) continue; + + setState(() { + _loadingSuggestions[tagKey] = true; + }); + + try { + final suggestions = await NSIService().getAllSuggestions(tagKey); + if (mounted) { + setState(() { + _tagSuggestions[tagKey] = suggestions; + _loadingSuggestions[tagKey] = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _tagSuggestions[tagKey] = []; + _loadingSuggestions[tagKey] = false; + }); + } + } + } + } + + /// Get list of tag keys that have empty values and can be refined + List _getRefinableTags() { + if (widget.selectedProfile == null) return []; + + return widget.selectedProfile!.tags.entries + .where((entry) => entry.value.trim().isEmpty) + .map((entry) => entry.key) + .toList(); } @override @@ -37,11 +101,17 @@ class _RefineTagsSheetState extends State { title: Text(locService.t('refineTagsSheet.title')), leading: IconButton( icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context, widget.selectedOperatorProfile), + onPressed: () => Navigator.pop(context, RefineTagsResult( + operatorProfile: widget.selectedOperatorProfile, + refinedTags: widget.currentRefinedTags ?? {}, + )), ), actions: [ TextButton( - onPressed: () => Navigator.pop(context, _selectedOperatorProfile), + onPressed: () => Navigator.pop(context, RefineTagsResult( + operatorProfile: _selectedOperatorProfile, + refinedTags: _refinedTags, + )), child: Text(locService.t('refineTagsSheet.done')), ), ], @@ -152,6 +222,114 @@ class _RefineTagsSheetState extends State { ), ], ], + // Add refineable tags section + ..._buildRefinableTagsSection(locService), + ], + ), + ); + } + + /// Build the section for refineable tags (empty-value profile tags) + List _buildRefinableTagsSection(LocalizationService locService) { + final refinableTags = _getRefinableTags(); + if (refinableTags.isEmpty) { + return []; + } + + return [ + const SizedBox(height: 24), + Text( + locService.t('refineTagsSheet.profileTags'), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + locService.t('refineTagsSheet.profileTagsDescription'), + style: const TextStyle(color: Colors.grey, fontSize: 14), + ), + const SizedBox(height: 16), + ...refinableTags.map((tagKey) => _buildTagDropdown(tagKey, locService)), + ], + ), + ), + ), + ]; + } + + /// Build a dropdown for a single refineable tag + Widget _buildTagDropdown(String tagKey, LocalizationService locService) { + final suggestions = _tagSuggestions[tagKey] ?? []; + final isLoading = _loadingSuggestions[tagKey] ?? false; + final currentValue = _refinedTags[tagKey]; + + return Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tagKey, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 4), + if (isLoading) + const Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 8), + Text('Loading suggestions...', style: TextStyle(color: Colors.grey)), + ], + ) + else if (suggestions.isEmpty) + DropdownButtonFormField( + value: currentValue?.isNotEmpty == true ? currentValue : null, + decoration: InputDecoration( + hintText: locService.t('refineTagsSheet.noSuggestions'), + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + items: const [], + onChanged: null, // Disabled when no suggestions + ) + else + DropdownButtonFormField( + value: currentValue?.isNotEmpty == true ? currentValue : null, + decoration: InputDecoration( + hintText: locService.t('refineTagsSheet.selectValue'), + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + items: [ + DropdownMenuItem( + value: null, + child: Text(locService.t('refineTagsSheet.noValue'), + style: const TextStyle(color: Colors.grey)), + ), + ...suggestions.map((suggestion) => DropdownMenuItem( + value: suggestion, + child: Text(suggestion), + )), + ], + onChanged: (value) { + setState(() { + if (value == null) { + _refinedTags.remove(tagKey); + } else { + _refinedTags[tagKey] = value; + } + }); + }, + ), ], ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 72181c3..a7599fd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 2.0.0+33 # The thing after the + is the version code, incremented with each release +version: 2.1.0+34 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+