// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; class User { const User({ required this.email, required this.name, }); final String email; final String name; @override String toString() { return '$name, $email'; } } void main() { const List<String> kOptions = <String>[ 'aardvark', 'bobcat', 'chameleon', 'dingo', 'elephant', 'flamingo', 'goose', 'hippopotamus', 'iguana', 'jaguar', 'koala', 'lemur', 'mouse', 'northern white rhinocerous', ]; const List<User> kOptionsUsers = <User>[ User(name: 'Alice', email: 'alice@example.com'), User(name: 'Bob', email: 'bob@example.com'), User(name: 'Charlie', email: 'charlie123@gmail.com'), ]; group('RawAutocomplete', () { testWidgets('can filter and select a list of string options', (WidgetTester tester) async { final GlobalKey fieldKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey(); late Iterable<String> lastOptions; late AutocompleteOnSelected<String> lastOnSelected; late FocusNode focusNode; late TextEditingController textEditingController; await tester.pumpWidget( MaterialApp( home: Scaffold( body: RawAutocomplete<String>( optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { focusNode = fieldFocusNode; textEditingController = fieldTextEditingController; return TextField( key: fieldKey, focusNode: focusNode, controller: textEditingController, ); }, optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) { lastOptions = options; lastOnSelected = onSelected; return Container(key: optionsKey); }, ), ), ), ); // The field is always rendered, but the options are not unless needed. expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsNothing); // Focus the empty field. All the options are displayed. focusNode.requestFocus(); await tester.pump(); expect(find.byKey(optionsKey), findsOneWidget); expect(lastOptions.length, kOptions.length); // Enter text. The options are filtered by the text. textEditingController.value = const TextEditingValue( text: 'ele', selection: TextSelection(baseOffset: 3, extentOffset: 3), ); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsOneWidget); expect(lastOptions.length, 2); expect(lastOptions.elementAt(0), 'chameleon'); expect(lastOptions.elementAt(1), 'elephant'); // Select a option. The options hide and the field updates to show the // selection. final String selection = lastOptions.elementAt(1); lastOnSelected(selection); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsNothing); expect(textEditingController.text, selection); // Modify the field text. The options appear again and are filtered. textEditingController.value = const TextEditingValue( text: 'e', selection: TextSelection(baseOffset: 1, extentOffset: 1), ); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsOneWidget); expect(lastOptions.length, 6); expect(lastOptions.elementAt(0), 'chameleon'); expect(lastOptions.elementAt(1), 'elephant'); expect(lastOptions.elementAt(2), 'goose'); expect(lastOptions.elementAt(3), 'lemur'); expect(lastOptions.elementAt(4), 'mouse'); expect(lastOptions.elementAt(5), 'northern white rhinocerous'); }); testWidgets('can filter and select a list of custom User options', (WidgetTester tester) async { final GlobalKey fieldKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey(); late Iterable<User> lastOptions; late AutocompleteOnSelected<User> lastOnSelected; late User lastUserSelected; late FocusNode focusNode; late TextEditingController textEditingController; await tester.pumpWidget( MaterialApp( home: Scaffold( body: RawAutocomplete<User>( optionsBuilder: (TextEditingValue textEditingValue) { return kOptionsUsers.where((User option) { return option.toString().contains(textEditingValue.text.toLowerCase()); }); }, onSelected: (User selected) { lastUserSelected = selected; }, fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { focusNode = fieldFocusNode; textEditingController = fieldTextEditingController; return TextField( key: fieldKey, focusNode: focusNode, controller: fieldTextEditingController, ); }, optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) { lastOptions = options; lastOnSelected = onSelected; return Container(key: optionsKey); }, ), ), ), ); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsNothing); // Enter text. The options are filtered by the text. focusNode.requestFocus(); textEditingController.value = const TextEditingValue( text: 'example', selection: TextSelection(baseOffset: 7, extentOffset: 7), ); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsOneWidget); expect(lastOptions.length, 2); expect(lastOptions.elementAt(0), kOptionsUsers[0]); expect(lastOptions.elementAt(1), kOptionsUsers[1]); // Select a option. The options hide and onSelected is called. final User selection = lastOptions.elementAt(1); lastOnSelected(selection); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsNothing); expect(lastUserSelected, selection); expect(textEditingController.text, selection.toString()); // Modify the field text. The options appear again and are filtered, this // time by name instead of email. textEditingController.value = const TextEditingValue( text: 'B', selection: TextSelection(baseOffset: 1, extentOffset: 1), ); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsOneWidget); expect(lastOptions.length, 1); expect(lastOptions.elementAt(0), kOptionsUsers[1]); }); testWidgets('can specify a custom display string for a list of custom User options', (WidgetTester tester) async { final GlobalKey fieldKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey(); late Iterable<User> lastOptions; late AutocompleteOnSelected<User> lastOnSelected; late User lastUserSelected; late final AutocompleteOptionToString<User> displayStringForOption = (User option) => option.name; late FocusNode focusNode; late TextEditingController textEditingController; await tester.pumpWidget( MaterialApp( home: Scaffold( body: RawAutocomplete<User>( optionsBuilder: (TextEditingValue textEditingValue) { return kOptionsUsers.where((User option) { return option .toString() .contains(textEditingValue.text.toLowerCase()); }); }, displayStringForOption: displayStringForOption, onSelected: (User selected) { lastUserSelected = selected; }, fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { textEditingController = fieldTextEditingController; focusNode = fieldFocusNode; return TextField( key: fieldKey, focusNode: focusNode, controller: fieldTextEditingController, ); }, optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) { lastOptions = options; lastOnSelected = onSelected; return Container(key: optionsKey); }, ), ), ), ); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsNothing); // Enter text. The options are filtered by the text. focusNode.requestFocus(); textEditingController.value = const TextEditingValue( text: 'example', selection: TextSelection(baseOffset: 7, extentOffset: 7), ); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsOneWidget); expect(lastOptions.length, 2); expect(lastOptions.elementAt(0), kOptionsUsers[0]); expect(lastOptions.elementAt(1), kOptionsUsers[1]); // Select a option. The options hide and onSelected is called. The field // has its text set to the selection's display string. final User selection = lastOptions.elementAt(1); lastOnSelected(selection); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsNothing); expect(lastUserSelected, selection); expect(textEditingController.text, selection.name); // Modify the field text. The options appear again and are filtered, this // time by name instead of email. textEditingController.value = const TextEditingValue( text: 'B', selection: TextSelection(baseOffset: 1, extentOffset: 1), ); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsOneWidget); expect(lastOptions.length, 1); expect(lastOptions.elementAt(0), kOptionsUsers[1]); }); testWidgets('onFieldSubmitted selects the first option', (WidgetTester tester) async { final GlobalKey fieldKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey(); late Iterable<String> lastOptions; late VoidCallback lastOnFieldSubmitted; late FocusNode focusNode; late TextEditingController textEditingController; await tester.pumpWidget( MaterialApp( home: Scaffold( body: RawAutocomplete<String>( optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { textEditingController = fieldTextEditingController; focusNode = fieldFocusNode; lastOnFieldSubmitted = onFieldSubmitted; return TextField( key: fieldKey, focusNode: focusNode, controller: fieldTextEditingController, ); }, optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) { lastOptions = options; return Container(key: optionsKey); }, ), ), ), ); // Enter text. The options are filtered by the text. focusNode.requestFocus(); textEditingController.value = const TextEditingValue( text: 'ele', selection: TextSelection(baseOffset: 3, extentOffset: 3), ); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsOneWidget); expect(lastOptions.length, 2); expect(lastOptions.elementAt(0), 'chameleon'); expect(lastOptions.elementAt(1), 'elephant'); // Select the current string, as if the field was submitted. The options // hide and the field updates to show the selection. lastOnFieldSubmitted(); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsNothing); expect(textEditingController.text, lastOptions.elementAt(0)); }); testWidgets('options follow field when it moves', (WidgetTester tester) async { final GlobalKey fieldKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey(); late StateSetter setState; Alignment alignment = Alignment.center; late FocusNode focusNode; late TextEditingController textEditingController; await tester.pumpWidget( MaterialApp( home: Scaffold( body: StatefulBuilder( builder: (BuildContext context, StateSetter setter) { setState = setter; return Align( alignment: alignment, child: RawAutocomplete<String>( optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { focusNode = fieldFocusNode; textEditingController = fieldTextEditingController; return TextFormField( controller: fieldTextEditingController, focusNode: focusNode, key: fieldKey, ); }, optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) { return Container(key: optionsKey); }, ), ); }, ), ), ), ); // Field is shown but not options. expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsNothing); // Enter text to show the options. focusNode.requestFocus(); textEditingController.value = const TextEditingValue( text: 'ele', selection: TextSelection(baseOffset: 3, extentOffset: 3), ); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsOneWidget); // Options are just below the field. final Offset optionsOffset = tester.getTopLeft(find.byKey(optionsKey)); Offset fieldOffset = tester.getTopLeft(find.byKey(fieldKey)); final Size fieldSize = tester.getSize(find.byKey(fieldKey)); expect(optionsOffset.dy, fieldOffset.dy + fieldSize.height); // Move the field (similar to as if the keyboard opened). The options move // to follow the field. setState(() { alignment = Alignment.topCenter; }); await tester.pump(); fieldOffset = tester.getTopLeft(find.byKey(fieldKey)); final Offset optionsOffsetOpen = tester.getTopLeft(find.byKey(optionsKey)); expect(optionsOffsetOpen.dy, isNot(equals(optionsOffset.dy))); expect(optionsOffsetOpen.dy, fieldOffset.dy + fieldSize.height); }); testWidgets('can prevent options from showing by returning an empty iterable', (WidgetTester tester) async { final GlobalKey fieldKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey(); late Iterable<String> lastOptions; late FocusNode focusNode; late TextEditingController textEditingController; await tester.pumpWidget( MaterialApp( home: Scaffold( body: RawAutocomplete<String>( optionsBuilder: (TextEditingValue textEditingValue) { if (textEditingValue.text == null || textEditingValue.text == '') { return const Iterable<String>.empty(); } return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { focusNode = fieldFocusNode; textEditingController = fieldTextEditingController; return TextField( key: fieldKey, focusNode: focusNode, controller: fieldTextEditingController, ); }, optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) { lastOptions = options; return Container(key: optionsKey); }, ), ), ), ); // The field is always rendered, but the options are not unless needed. expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsNothing); // Focus the empty field. The options are not displayed because // optionsBuilder returns nothing for an empty field query. focusNode.requestFocus(); textEditingController.value = const TextEditingValue( text: '', selection: TextSelection(baseOffset: 0, extentOffset: 0), ); await tester.pump(); expect(find.byKey(optionsKey), findsNothing); // Enter text. Now the options appear, filtered by the text. textEditingController.value = const TextEditingValue( text: 'ele', selection: TextSelection(baseOffset: 3, extentOffset: 3), ); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); expect(find.byKey(optionsKey), findsOneWidget); expect(lastOptions.length, 2); expect(lastOptions.elementAt(0), 'chameleon'); expect(lastOptions.elementAt(1), 'elephant'); }); }); }