NSI and tag refinement

This commit is contained in:
stopflock
2025-12-10 12:52:20 -06:00
parent 6ed30dcff8
commit e6b18bf89b
26 changed files with 679 additions and 43 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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<String, String>? 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<String, String>? refinedTags,
}) {
_sessionState.updateEditSession(
directionDeg: directionDeg,
@@ -434,6 +453,7 @@ class AppState extends ChangeNotifier {
operatorProfile: operatorProfile,
target: target,
extractFromWay: extractFromWay,
refinedTags: refinedTags,
);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -400,7 +400,12 @@
"additionalTagsTitle": "额外标签",
"noTagsDefinedForProfile": "此运营商配置文件未定义标签。",
"noOperatorProfiles": "未定义运营商配置文件",
"noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。"
"noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。",
"profileTags": "配置文件标签",
"profileTagsDescription": "为需要细化的标签指定值:",
"selectValue": "选择值...",
"noValue": "(无值)",
"noSuggestions": "无建议可用"
},
"layerSelector": {
"cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型",

View File

@@ -100,6 +100,21 @@ class OneTimeMigrations {
}
}
/// Clear any active sessions to reset refined tags system (v2.1.0)
static Future<void> 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<void> 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;
}

View File

@@ -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',
},

View File

@@ -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<String, String> 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<String, String>? 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<String, String> getCombinedTags() {
// Deletions don't need tags
if (operation == UploadOperation.delete || profile == null) {
@@ -228,6 +231,14 @@ class PendingUpload {
final tags = Map<String, String>.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<String, String>.from(j['refinedTags'])
: {}, // Default empty map for legacy entries
uploadMode: j['uploadMode'] != null
? UploadMode.values[j['uploadMode']]
: UploadMode.production, // Default for legacy entries

View File

@@ -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<OperatorProfileEditor> {
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<OperatorProfileEditor> {
final tagMap = <String, String>{};
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(

View File

@@ -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<ProfileEditor> {
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<ProfileEditor> {
final tagMap = <String, String>{};
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) {

View File

@@ -202,7 +202,11 @@ bool _nodeMatchesProfiles(Map<String, String> nodeTags, List<NodeProfile> profil
/// Check if a node's tags match a specific profile
bool _nodeMatchesProfile(Map<String, String> 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;
}

View File

@@ -197,8 +197,9 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> 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();

View File

@@ -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<String, List<String>> _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<List<String>> 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<List<String>> _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<String, dynamic>;
final values = data['data'] as List<dynamic>? ?? [];
// Extract the most commonly used values
final suggestions = <String>[];
for (final item in values) {
if (item is Map<String, dynamic>) {
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<List<String>> 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();
}
}

View File

@@ -9,6 +9,13 @@ class ProfileState extends ChangeNotifier {
final List<NodeProfile> _profiles = [];
final Set<NodeProfile> _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<NodeProfile> 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();
}

View File

@@ -12,14 +12,17 @@ class AddNodeSession {
LatLng? target;
List<double> directions; // All directions [90, 180, 270]
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
Map<String, String> refinedTags; // User-selected values for empty profile tags
AddNodeSession({
this.profile,
double initialDirection = 0,
this.operatorProfile,
this.target,
Map<String, String>? 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<double> 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<String, String> 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<String, String>? 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<String, String>? refinedTags,
}) {
if (_session == null) return;
@@ -132,6 +139,10 @@ class SessionState extends ChangeNotifier {
_session!.target = target;
dirty = true;
}
if (refinedTags != null) {
_session!.refinedTags = Map<String, String>.from(refinedTags);
dirty = true;
}
if (dirty) notifyListeners();
}
@@ -141,6 +152,7 @@ class SessionState extends ChangeNotifier {
OperatorProfile? operatorProfile,
LatLng? target,
bool? extractFromWay,
Map<String, String>? refinedTags,
}) {
if (_editSession == null) return;
@@ -174,6 +186,10 @@ class SessionState extends ChangeNotifier {
}
dirty = true;
}
if (refinedTags != null) {
_editSession!.refinedTags = Map<String, String>.from(refinedTags);
dirty = true;
}
if (dirty) notifyListeners();

View File

@@ -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

View File

@@ -227,17 +227,22 @@ class AddNodeSheet extends StatelessWidget {
session.profile!.isSubmittable;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
final result = await Navigator.push<RefineTagsResult?>(
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,
);
}
}

View File

@@ -226,17 +226,22 @@ class EditNodeSheet extends StatelessWidget {
session.profile!.isSubmittable;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
final result = await Navigator.push<RefineTagsResult?>(
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,
);
}
}

View File

@@ -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<String> onChanged;
final bool readOnly;
final String? hintText;
@override
State<NSITagValueField> createState() => _NSITagValueFieldState();
}
class _NSITagValueFieldState extends State<NSITagValueField> {
late TextEditingController _controller;
List<String> _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();
}
},
),
);
}
}

View File

@@ -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<String, String> 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<String, String>? currentRefinedTags;
@override
State<RefineTagsSheet> createState() => _RefineTagsSheetState();
@@ -19,11 +36,58 @@ class RefineTagsSheet extends StatefulWidget {
class _RefineTagsSheetState extends State<RefineTagsSheet> {
OperatorProfile? _selectedOperatorProfile;
Map<String, String> _refinedTags = {};
Map<String, List<String>> _tagSuggestions = {};
Map<String, bool> _loadingSuggestions = {};
@override
void initState() {
super.initState();
_selectedOperatorProfile = widget.selectedOperatorProfile;
_refinedTags = Map<String, String>.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<String> _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<RefineTagsSheet> {
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<RefineTagsSheet> {
),
],
],
// Add refineable tags section
..._buildRefinableTagsSection(locService),
],
),
);
}
/// Build the section for refineable tags (empty-value profile tags)
List<Widget> _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<String>(
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<String>(
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<String>(
value: null,
child: Text(locService.t('refineTagsSheet.noValue'),
style: const TextStyle(color: Colors.grey)),
),
...suggestions.map((suggestion) => DropdownMenuItem<String>(
value: suggestion,
child: Text(suggestion),
)),
],
onChanged: (value) {
setState(() {
if (value == null) {
_refinedTags.remove(tagKey);
} else {
_refinedTags[tagKey] = value;
}
});
},
),
],
),
);

View File

@@ -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+