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
+8 -3
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,
);
}
}
+8 -3
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,
);
}
}
+181
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();
}
},
),
);
}
}
+180 -2
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;
}
});
},
),
],
),
);