diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 47b41da1..a48caa12 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -321,7 +321,9 @@ "keyHint": "Schlüssel", "valueHint": "Wert", "atLeastOneTagRequired": "Mindestens ein Tag ist erforderlich", - "profileSaved": "Profil \"{}\" gespeichert" + "profileSaved": "Profil \"{}\" gespeichert", + "removeTagTitle": "Tag entfernen?", + "removeTagMessage": "\"{}\" von diesem Profil entfernen?" }, "operatorProfileEditor": { "newOperatorProfile": "Neues Betreiber-Profil", @@ -392,7 +394,9 @@ "existingTagsTitle": "Vorhandene Tags", "existingTagsDescription": "Bearbeiten Sie die vorhandenen Tags auf diesem Gerät. Hinzufügen, entfernen oder ändern Sie beliebige Tags:", "existingOperator": "", - "existingOperatorTags": "vorhandene Betreiber-Tags" + "existingOperatorTags": "vorhandene Betreiber-Tags", + "removeTagTitle": "Tag entfernen?", + "removeTagMessage": "\"{}\" von diesem Knoten entfernen?" }, "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 fa62994e..f274de90 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -358,7 +358,9 @@ "keyHint": "key", "valueHint": "value", "atLeastOneTagRequired": "At least one tag is required", - "profileSaved": "Profile \"{}\" saved" + "profileSaved": "Profile \"{}\" saved", + "removeTagTitle": "Remove tag?", + "removeTagMessage": "Remove \"{}\" from this profile?" }, "operatorProfileEditor": { "newOperatorProfile": "New Operator Profile", @@ -429,7 +431,9 @@ "existingTagsTitle": "Existing Tags", "existingTagsDescription": "Edit the existing tags on this device. Add, remove, or modify any tag:", "existingOperator": "", - "existingOperatorTags": "existing operator tags" + "existingOperatorTags": "existing operator tags", + "removeTagTitle": "Remove tag?", + "removeTagMessage": "Remove \"{}\" from this node?" }, "layerSelector": { "cannotChangeTileTypes": "Cannot change tile types while downloading offline areas", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 8cfe386e..2f432e3e 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -358,7 +358,9 @@ "keyHint": "clave", "valueHint": "valor", "atLeastOneTagRequired": "Se requiere al menos una etiqueta", - "profileSaved": "Perfil \"{}\" guardado" + "profileSaved": "Perfil \"{}\" guardado", + "removeTagTitle": "¿Eliminar etiqueta?", + "removeTagMessage": "¿Eliminar \"{}\" de este perfil?" }, "operatorProfileEditor": { "newOperatorProfile": "Nuevo Perfil de Operador", @@ -429,7 +431,9 @@ "existingTagsTitle": "Etiquetas Existentes", "existingTagsDescription": "Edite las etiquetas existentes en este dispositivo. Agregue, elimine o modifique cualquier etiqueta:", "existingOperator": "", - "existingOperatorTags": "etiquetas de operador existentes" + "existingOperatorTags": "etiquetas de operador existentes", + "removeTagTitle": "¿Eliminar etiqueta?", + "removeTagMessage": "¿Eliminar \"{}\" de este nodo?" }, "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 2dcb8951..2c15dcca 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -358,7 +358,9 @@ "keyHint": "clé", "valueHint": "valeur", "atLeastOneTagRequired": "Au moins une balise est requise", - "profileSaved": "Profil \"{}\" sauvegardé" + "profileSaved": "Profil \"{}\" sauvegardé", + "removeTagTitle": "Supprimer la balise ?", + "removeTagMessage": "Supprimer \"{}\" de ce profil ?" }, "operatorProfileEditor": { "newOperatorProfile": "Nouveau Profil d'Opérateur", @@ -429,7 +431,9 @@ "existingTagsTitle": "Balises Existantes", "existingTagsDescription": "Modifiez les balises existantes sur cet appareil. Ajoutez, supprimez ou modifiez n'importe quelle balise :", "existingOperator": "", - "existingOperatorTags": "balises d'opérateur existantes" + "existingOperatorTags": "balises d'opérateur existantes", + "removeTagTitle": "Supprimer la balise ?", + "removeTagMessage": "Supprimer \"{}\" de ce nœud ?" }, "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 c61fe7f1..3eaea0ff 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -358,7 +358,9 @@ "keyHint": "chiave", "valueHint": "valore", "atLeastOneTagRequired": "È richiesto almeno un tag", - "profileSaved": "Profilo \"{}\" salvato" + "profileSaved": "Profilo \"{}\" salvato", + "removeTagTitle": "Rimuovere tag?", + "removeTagMessage": "Rimuovere \"{}\" da questo profilo?" }, "operatorProfileEditor": { "newOperatorProfile": "Nuovo Profilo Operatore", @@ -429,7 +431,9 @@ "existingTagsTitle": "Tag Esistenti", "existingTagsDescription": "Modifica i tag esistenti su questo dispositivo. Aggiungi, rimuovi o modifica qualsiasi tag:", "existingOperator": "", - "existingOperatorTags": "tag operatore esistenti" + "existingOperatorTags": "tag operatore esistenti", + "removeTagTitle": "Rimuovere tag?", + "removeTagMessage": "Rimuovere \"{}\" da questo nodo?" }, "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 38e611b7..90a24d6c 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -358,7 +358,9 @@ "keyHint": "chave", "valueHint": "valor", "atLeastOneTagRequired": "Pelo menos uma tag é obrigatória", - "profileSaved": "Perfil \"{}\" salvo" + "profileSaved": "Perfil \"{}\" salvo", + "removeTagTitle": "Remover tag?", + "removeTagMessage": "Remover \"{}\" deste perfil?" }, "operatorProfileEditor": { "newOperatorProfile": "Novo Perfil de Operador", @@ -429,7 +431,9 @@ "existingTagsTitle": "Tags Existentes", "existingTagsDescription": "Edite as tags existentes neste dispositivo. Adicione, remova ou modifique qualquer tag:", "existingOperator": "", - "existingOperatorTags": "tags de operador existentes" + "existingOperatorTags": "tags de operador existentes", + "removeTagTitle": "Remover tag?", + "removeTagMessage": "Remover \"{}\" deste nó?" }, "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 ab446840..94c962a5 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -358,7 +358,9 @@ "keyHint": "键", "valueHint": "值", "atLeastOneTagRequired": "至少需要一个标签", - "profileSaved": "配置文件 \"{}\" 已保存" + "profileSaved": "配置文件 \"{}\" 已保存", + "removeTagTitle": "删除标签?", + "removeTagMessage": "从此配置文件中删除 \"{}\"?" }, "operatorProfileEditor": { "newOperatorProfile": "新建运营商配置文件", @@ -429,7 +431,9 @@ "existingTagsTitle": "现有标签", "existingTagsDescription": "编辑此设备上的现有标签。添加、删除或修改任何标签:", "existingOperator": "<现有运营商>", - "existingOperatorTags": "现有运营商标签" + "existingOperatorTags": "现有运营商标签", + "removeTagTitle": "删除标签?", + "removeTagMessage": "从此节点中删除 \"{}\"?" }, "layerSelector": { "cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型", diff --git a/lib/screens/operator_profile_editor.dart b/lib/screens/operator_profile_editor.dart index b967d62b..3554d498 100644 --- a/lib/screens/operator_profile_editor.dart +++ b/lib/screens/operator_profile_editor.dart @@ -131,6 +131,7 @@ class _OperatorProfileEditorState extends State { initialValue: _tags[i].value, hintText: locService.t('profileEditor.valueHint'), onChanged: (v) => setState(() => _tags[i] = MapEntry(_tags[i].key, v)), + onCleared: () => _confirmRemoveTag(i), ), ), IconButton( @@ -143,6 +144,31 @@ class _OperatorProfileEditorState extends State { }); } + void _confirmRemoveTag(int index) async { + final tagKey = _tags[index].key; + final locService = LocalizationService.instance; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(locService.t('profileEditor.removeTagTitle')), + content: Text(locService.t('profileEditor.removeTagMessage', params: [tagKey])), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(locService.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(locService.t('actions.delete')), + ), + ], + ), + ); + if (confirmed == true && mounted) { + setState(() => _tags.removeAt(index)); + } + } + void _save() { final locService = LocalizationService.instance; final name = _nameCtrl.text.trim(); diff --git a/lib/screens/profile_editor.dart b/lib/screens/profile_editor.dart index 08a6bf51..9b17119e 100644 --- a/lib/screens/profile_editor.dart +++ b/lib/screens/profile_editor.dart @@ -182,9 +182,10 @@ class _ProfileEditorState extends State { initialValue: _tags[i].value, hintText: locService.t('profileEditor.valueHint'), readOnly: !widget.profile.editable, - onChanged: !widget.profile.editable + onChanged: !widget.profile.editable ? (v) {} // No-op when read-only : (v) => setState(() => _tags[i] = MapEntry(_tags[i].key, v)), + onCleared: !widget.profile.editable ? null : () => _confirmRemoveTag(i), ), ), if (widget.profile.editable) @@ -198,6 +199,31 @@ class _ProfileEditorState extends State { }); } + void _confirmRemoveTag(int index) async { + final tagKey = _tags[index].key; + final locService = LocalizationService.instance; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(locService.t('profileEditor.removeTagTitle')), + content: Text(locService.t('profileEditor.removeTagMessage', params: [tagKey])), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(locService.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(locService.t('actions.delete')), + ), + ], + ), + ); + if (confirmed == true && mounted) { + setState(() => _tags.removeAt(index)); + } + } + String? _validateFov() { final text = _fovCtrl.text.trim(); if (text.isEmpty) return null; // Optional field diff --git a/lib/widgets/nsi_tag_value_field.dart b/lib/widgets/nsi_tag_value_field.dart index d05f752c..6b3a70f3 100644 --- a/lib/widgets/nsi_tag_value_field.dart +++ b/lib/widgets/nsi_tag_value_field.dart @@ -11,6 +11,7 @@ class NSITagValueField extends StatefulWidget { required this.onChanged, this.readOnly = false, this.hintText, + this.onCleared, }); final String tagKey; @@ -18,6 +19,7 @@ class NSITagValueField extends StatefulWidget { final ValueChanged onChanged; final bool readOnly; final String? hintText; + final VoidCallback? onCleared; @override State createState() => _NSITagValueFieldState(); @@ -133,11 +135,10 @@ class _NSITagValueFieldState extends State { widget.onChanged(value); }, onSubmitted: (_) { - // Only auto-complete when there's text to match against. - // Otherwise, pressing Done on an empty field would auto-select - // the first suggestion, preventing users from clearing values. - if (controller.text.isNotEmpty) { + if (controller.text.trim().isNotEmpty) { onFieldSubmitted(); + } else { + widget.onCleared?.call(); } }, ); diff --git a/lib/widgets/refine_tags_sheet.dart b/lib/widgets/refine_tags_sheet.dart index 5ee9e5d7..560ac0b6 100644 --- a/lib/widgets/refine_tags_sheet.dart +++ b/lib/widgets/refine_tags_sheet.dart @@ -517,6 +517,7 @@ class _RefineTagsSheetState extends State { _additionalExistingTags[index] = MapEntry(_additionalExistingTags[index].key, newValue); }); }, + onCleared: () => _confirmRemoveAdditionalTag(index), ), ), const SizedBox(width: 8), @@ -544,4 +545,29 @@ class _RefineTagsSheetState extends State { _additionalExistingTags.removeAt(index); }); } + + void _confirmRemoveAdditionalTag(int index) async { + final tagKey = _additionalExistingTags[index].key; + final locService = LocalizationService.instance; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(locService.t('refineTagsSheet.removeTagTitle')), + content: Text(locService.t('refineTagsSheet.removeTagMessage', params: [tagKey])), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(locService.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(locService.t('actions.delete')), + ), + ], + ), + ); + if (confirmed == true && mounted) { + _removeAdditionalTag(index); + } + } } \ No newline at end of file diff --git a/test/widgets/nsi_tag_value_field_test.dart b/test/widgets/nsi_tag_value_field_test.dart new file mode 100644 index 00000000..1b044430 --- /dev/null +++ b/test/widgets/nsi_tag_value_field_test.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Tests for the RawAutocomplete onSubmitted behavior used by NSITagValueField. +/// +/// These test the exact widget tree pattern from NSITagValueField.build(): +/// - onFieldSubmitted guarded on non-empty text (prevents auto-select on Done) +/// - onCleared callback fires when Done is pressed on an empty field +void main() { + Widget buildAutocompleteField({ + required TextEditingController controller, + required FocusNode focusNode, + required List suggestions, + required ValueChanged onChanged, + required ValueChanged onSelected, + VoidCallback? onCleared, + }) { + return MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: RawAutocomplete( + textEditingController: controller, + focusNode: focusNode, + optionsBuilder: (TextEditingValue textEditingValue) { + if (suggestions.isEmpty) { + return const Iterable.empty(); + } + if (textEditingValue.text.isEmpty) return suggestions; + return suggestions + .where((s) => s.contains(textEditingValue.text)); + }, + onSelected: (String selection) => onSelected(selection), + fieldViewBuilder: + (context, controller, focusNode, onFieldSubmitted) { + return TextField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + onSubmitted: (_) { + if (controller.text.trim().isNotEmpty) { + onFieldSubmitted(); + } else { + onCleared?.call(); + } + }, + ); + }, + optionsViewBuilder: (context, onSelected, options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + child: SizedBox( + height: 200, + child: ListView( + children: options + .map((o) => ListTile( + title: Text(o), + onTap: () => onSelected(o), + )) + .toList(), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + + group('NSITagValueField onSubmitted behavior', () { + testWidgets('pressing Done on empty field does not auto-select', + (tester) async { + final controller = TextEditingController(text: 'Hikvision'); + final focusNode = FocusNode(); + + await tester.pumpWidget(buildAutocompleteField( + controller: controller, + focusNode: focusNode, + suggestions: ['Axis', 'Dahua', 'Hikvision'], + onChanged: (_) {}, + onSelected: (_) {}, + )); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(controller.text, equals(''), + reason: 'Field should stay empty after pressing Done'); + }); + + testWidgets('pressing Done on empty field calls onCleared', + (tester) async { + bool clearedCalled = false; + final controller = TextEditingController(text: 'Hikvision'); + final focusNode = FocusNode(); + + await tester.pumpWidget(buildAutocompleteField( + controller: controller, + focusNode: focusNode, + suggestions: ['Axis', 'Dahua', 'Hikvision'], + onChanged: (_) {}, + onSelected: (_) {}, + onCleared: () => clearedCalled = true, + )); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(clearedCalled, isTrue, + reason: 'onCleared should fire when Done pressed on empty field'); + }); + + testWidgets('pressing Done on non-empty field does not call onCleared', + (tester) async { + bool clearedCalled = false; + final controller = TextEditingController(); + final focusNode = FocusNode(); + + await tester.pumpWidget(buildAutocompleteField( + controller: controller, + focusNode: focusNode, + suggestions: ['Axis', 'Dahua', 'Hikvision'], + onChanged: (_) {}, + onSelected: (_) {}, + onCleared: () => clearedCalled = true, + )); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + await tester.enterText(find.byType(TextField), 'Axi'); + await tester.pump(); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(clearedCalled, isFalse, + reason: 'onCleared should not fire when field has text'); + expect(controller.text, equals('Axis'), + reason: 'Should auto-complete partial text'); + }); + + testWidgets('pressing Done on whitespace-only field calls onCleared', + (tester) async { + bool clearedCalled = false; + final controller = TextEditingController(); + final focusNode = FocusNode(); + + await tester.pumpWidget(buildAutocompleteField( + controller: controller, + focusNode: focusNode, + suggestions: ['Axis', 'Dahua', 'Hikvision'], + onChanged: (_) {}, + onSelected: (_) {}, + onCleared: () => clearedCalled = true, + )); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + await tester.enterText(find.byType(TextField), ' '); + await tester.pump(); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(clearedCalled, isTrue, + reason: 'onCleared should fire when Done pressed on whitespace-only field'); + }); + + testWidgets('onCleared not provided — Done on empty field is a no-op', + (tester) async { + final controller = TextEditingController(text: 'Dahua'); + final focusNode = FocusNode(); + String lastSelected = ''; + + await tester.pumpWidget(buildAutocompleteField( + controller: controller, + focusNode: focusNode, + suggestions: ['Axis', 'Dahua', 'Hikvision'], + onChanged: (_) {}, + onSelected: (v) => lastSelected = v, + // onCleared intentionally omitted + )); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(controller.text, equals('')); + expect(lastSelected, equals(''), + reason: 'No selection should occur on empty submit'); + }); + }); +}