Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions app/lib/src/screens/dance_editor/name_picker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class NamePicker extends StatelessWidget {
}
}

class _AddAutocomplete extends StatelessWidget {
class _AddAutocomplete extends StatefulWidget {
const _AddAutocomplete({
required this.fieldKey,
required this.selectedIds,
Expand All @@ -86,34 +86,65 @@ class _AddAutocomplete extends StatelessWidget {
final ValueChanged<String> onAdd;
final Future<String> Function(String name) onCreate;

@override
State<_AddAutocomplete> createState() => _AddAutocompleteState();
}

class _AddAutocompleteState extends State<_AddAutocomplete> {
// Owned so we can clear the field and keep focus after a tag is committed
// (issue #402: the typed text lingered because Flutter's Autocomplete fills
// the field with the selected option's label before onSelected runs).
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();

@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
final fieldKey = widget.fieldKey;
return Autocomplete<_PickerChoice>(
key: ValueKey('$fieldKey-autocomplete'),
textEditingController: _controller,
focusNode: _focusNode,
displayStringForOption: (choice) => choice.label,
optionsBuilder: (value) {
final q = value.text.trim();
if (q.isEmpty) return const Iterable<_PickerChoice>.empty();
final lower = q.toLowerCase();
final matches = options
final matches = widget.options
.where(
(o) =>
!selectedIds.contains(o.id) &&
!widget.selectedIds.contains(o.id) &&
o.name.toLowerCase().contains(lower),
)
.map((o) => _PickerChoice.existing(o.id, o.name))
.toList();
final exact = options.any((o) => o.name.toLowerCase() == lower);
final exact = widget.options.any((o) => o.name.toLowerCase() == lower);
if (!exact) matches.add(_PickerChoice.create(q));
return matches;
},
onSelected: (choice) async {
if (choice.isCreate) {
final id = await onCreate(choice.name);
onAdd(id);
// Only clear after the create + add succeeds; a thrown onCreate
// short-circuits before we touch the field.
final id = await widget.onCreate(choice.name);
Comment thread
ibanner56 marked this conversation as resolved.
// Guard against the widget being disposed during the await (e.g. the
// editor route closed while the tag was being created).
if (!mounted) return;
widget.onAdd(id);
} else {
onAdd(choice.id!);
widget.onAdd(choice.id!);
}
// Reset the field and keep focus so the next tag can be typed straight
// away. Clearing lives only here, so empty/duplicate/no-op submits
// (which never reach onSelected) leave state untouched.
_controller.clear();
_focusNode.requestFocus();
},
fieldViewBuilder: (context, controller, focusNode, onSubmit) {
return TextField(
Expand Down
157 changes: 157 additions & 0 deletions app/test/dance_editor_screen_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:compendium_core/compendium_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Expand All @@ -9,6 +11,7 @@ import 'package:compendium_app/src/data/repositories_scope.dart';
import 'package:compendium_app/src/editor/editor_draft_codec.dart';
import 'package:compendium_app/src/editor/editor_snapshot.dart';
import 'package:compendium_app/src/screens/dance_editor_screen.dart';
import 'package:compendium_app/src/screens/dance_editor/name_picker.dart';
import 'package:compendium_app/src/screens/dance_list_screen.dart';
import 'package:compendium_app/src/theme/app_theme.dart';
import 'package:compendium_app/src/widgets/figure_list_editor.dart';
Expand Down Expand Up @@ -610,6 +613,160 @@ void main() {
expect(find.text('Choreographer details'), findsNothing);
});

testWidgets(
'committing a tag clears the input, keeps focus, and supports back-to-back adds',
(tester) async {
final repos = openTestRepositories();
await repos.tags.upsert(Tag(id: 't1', name: 'flowy'));
await repos.tags.upsert(Tag(id: 't2', name: 'smooth'));
await _pumpEditor(tester, repos);

await _expandMoreDetails(tester);

TextField tagField() =>
tester.widget<TextField>(find.byKey(const ValueKey('tag-input')));

// First tag: type, pick the existing option.
await tester.enterText(find.byKey(const ValueKey('tag-input')), 'flowy');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('tag-option-t1')));
await tester.pumpAndSettle();

expect(find.widgetWithText(Chip, 'flowy'), findsOneWidget);
// The typed text is cleared and focus stays in the field so the next tag
// can be typed immediately (issue #402).
expect(tagField().controller!.text, isEmpty);
expect(tagField().focusNode!.hasFocus, isTrue);

// Second tag added straight away from the now-empty field.
await tester.enterText(find.byKey(const ValueKey('tag-input')), 'smooth');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('tag-option-t2')));
await tester.pumpAndSettle();

expect(find.widgetWithText(Chip, 'flowy'), findsOneWidget);
expect(find.widgetWithText(Chip, 'smooth'), findsOneWidget);
expect(tagField().controller!.text, isEmpty);
},
);

testWidgets('creating a new tag inline clears the input', (tester) async {
final repos = openTestRepositories();
await _pumpEditor(tester, repos);

await _expandMoreDetails(tester);

await tester.enterText(find.byKey(const ValueKey('tag-input')), 'sparkly');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('tag-option-create:sparkly')));
await tester.pumpAndSettle();

expect(find.widgetWithText(Chip, 'sparkly'), findsOneWidget);
final field = tester.widget<TextField>(
find.byKey(const ValueKey('tag-input')),
);
expect(field.controller!.text, isEmpty);
});

testWidgets('an empty tag entry does not add a chip or corrupt state', (
tester,
) async {
final repos = openTestRepositories();
await repos.tags.upsert(Tag(id: 't1', name: 'flowy'));
await _pumpEditor(tester, repos);

await _expandMoreDetails(tester);

// Whitespace-only input never opens options, so nothing can be committed.
await tester.enterText(find.byKey(const ValueKey('tag-input')), ' ');
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('tag-option-t1')), findsNothing);
expect(find.byKey(const ValueKey('tag-chip-t1')), findsNothing);

// The field is still usable: a real tag can still be added afterwards.
await tester.enterText(find.byKey(const ValueKey('tag-input')), 'flowy');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('tag-option-t1')));
await tester.pumpAndSettle();
expect(find.widgetWithText(Chip, 'flowy'), findsOneWidget);
});

testWidgets(
'disposing the picker mid-create does not touch the disposed controller',
(tester) async {
// Reproduces the async gap on the create path (#402 follow-up): onCreate
// is a real Future, so if the editor is dismissed before it completes the
// owned controller/focus node must not be used. Guarded by !mounted.
final createCompleter = Completer<String>();
var addCount = 0;

Widget harness(bool showPicker) => MaterialApp(
home: Scaffold(
body: showPicker
? NamePicker(
fieldKey: 'tag',
selectedIds: const [],
namesById: const {},
options: const [],
onAdd: (_) => addCount++,
onRemove: (_) {},
onCreate: (_) => createCompleter.future,
)
: const SizedBox.shrink(),
),
);

await tester.pumpWidget(harness(true));
await tester.enterText(
find.byKey(const ValueKey('tag-input')),
'sparkly',
);
await tester.pumpAndSettle();
// Commit via the create option; onSelected now awaits onCreate.
await tester.tap(find.byKey(const ValueKey('tag-option-create:sparkly')));
await tester.pump();

// Tear the picker down while the create is still in flight, then let the
// future resolve. Without the !mounted guard this would throw
// "A TextEditingController was used after being disposed."
await tester.pumpWidget(harness(false));
createCompleter.complete('t-new');
await tester.pumpAndSettle();

expect(tester.takeException(), isNull);
// The guard short-circuits before onAdd/clear once disposed.
expect(addCount, 0);
},
);

testWidgets(
'committing an author clears the shared picker input and keeps focus',
(tester) async {
final repos = openTestRepositories();
await repos.choreographers.upsert(
Choreographer(id: 'c1', name: 'Gene Hubert'),
);
await _pumpEditor(tester, repos, danceId: null);

await tester.enterText(
find.byKey(const ValueKey('author-input')),
'Gene',
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('author-option-c1')));
await tester.pumpAndSettle();

expect(find.widgetWithText(InputChip, 'Gene Hubert'), findsOneWidget);
// The shared NamePicker clears + keeps focus for authors too, so the
// guarantee can't silently regress for one field but not the other.
final field = tester.widget<TextField>(
find.byKey(const ValueKey('author-input')),
);
expect(field.controller!.text, isEmpty);
expect(field.focusNode!.hasFocus, isTrue);
},
);

testWidgets('surfaces non-blocking phrase warnings', (tester) async {
final repos = openTestRepositories();
await repos.dances.create(
Expand Down