Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Forked from saltedpotatos/autocomplete_form.dart
Last active January 7, 2026 20:00
Show Gist options
  • Select an option

  • Save slightfoot/25e817686c61448137ef01a230b04a86 to your computer and use it in GitHub Desktop.

Select an option

Save slightfoot/25e817686c61448137ef01a230b04a86 to your computer and use it in GitHub Desktop.
Autocomplete Lists :: HumpdayQandA on 7th January 2026 :: https://www.youtube.com/watch?v=dD-nfl1FY_o
//Hello FlutterCommunity, long time lurker, first time gist-er
//
//I've been creating a budgeting app and have these combo boxes that
//I can either select an existing account or create a new one.
//
//However, I've been struggling to make keyboard navigation through this form buttery smooth
//
//Adding 100 accounts and trying to navigate by keyboard through the form is not a great experience.
// - hit the down arrow to open the options list and then hit the up arrow to navigate up from the bottom
//
// - can be hard to select an account with the keyboard and get to the next field smoothly.
// Seems to need an extra `esc` to dismiss before I can tab on to the next field
//
// - Creating an account doesn't refresh the options list, although the account does get created.
// Erasing a character will show the new account in the options list
//
//Hopefully I've trimmed this down enough to be useful without introducing new issues when I extracted this widget out
import 'dart:async';
import 'dart:math' show Random;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show SchedulerBinding;
import 'package:flutter/services.dart' show LogicalKeyboardKey, KeyDownEvent;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:signals_hooks/signals_hooks.dart';
final _random = Random();
class Account {
const Account({required this.id, required this.name});
final String id;
final String name;
}
class AccountOptionsView {
const AccountOptionsView(
this.account, {
this.isCreateNew = false,
});
final Account account;
final bool isCreateNew;
}
void main() {
SignalsObserver.instance = null;
runApp(const MainApp());
}
class MainApp extends StatefulHookWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
final budgetId = 'budgetId';
final BigInt dungId = BigInt.parse('5');
final String accountId = 'accountId';
@override
Widget build(BuildContext context) {
final accounts = useSignal([Account(id: '0', name: 'Some Option')]);
final selectedAccount = useSignal<Account?>(null);
final successRate = useSignal(1.0);
final payeeController = useTextEditingController();
final payeeFocus = useFocusNode();
final categoryController = useTextEditingController();
final categoryFocus = useFocusNode();
final dateFocusNode = useFocusNode();
DateTime selectedDate = DateTime.now();
final dateController = useTextEditingController();
void setDate(DateTime date) {
setState(() {
selectedDate = date;
dateController.text = '${date.toLocal()}'.split(' ')[0];
});
}
Future<(Account?, String?)> createAccount({
required String accountName,
bool? alwaysSucceed,
}) async {
// Simulate success/failure based on successRate
final shouldSucceed = (alwaysSucceed ?? false) || _random.nextDouble() < successRate.value;
if (!shouldSucceed) {
final successPercentage = (successRate.value * 100).toStringAsFixed(0);
return (null, 'Simulated failure (success rate: $successPercentage%)');
}
try {
final account = Account(
id: 'account-${accounts.value.length}',
name: accountName,
);
accounts.add(account);
return (account, null);
} catch (error, _) {
return (null, error.toString());
}
}
void add100Accounts() {
final categoryNames = [
'Groceries',
'Dining Out',
'Coffee Shops',
'Fast Food',
'Restaurants',
'Gas & Fuel',
'Car Maintenance',
'Car Insurance',
'Public Transportation',
'Ride Share',
'Electric Bill',
'Water Bill',
'Gas Bill',
'Internet',
'Phone Bill',
'Cable TV',
'Streaming Services',
'Rent',
'Mortgage',
'Home Insurance',
'Property Tax',
'Home Maintenance',
'Home Improvement',
'Furniture',
'Clothing',
'Shoes',
'Personal Care',
'Haircuts',
'Gym Membership',
'Sports & Fitness',
'Entertainment',
'Movies & Theater',
'Hobbies',
'Books & Magazines',
'Music',
'Pet Food',
'Pet Care',
'Veterinary',
'Medical',
'Dental',
'Pharmacy',
'Health Insurance',
'Gifts',
'Donations',
'Education',
'Subscriptions',
'Software',
'Office Supplies',
'Miscellaneous',
'Emergency Fund',
];
for (final name in categoryNames) {
createAccount(accountName: name, alwaysSucceed: true);
}
}
void resetAccounts() {
accounts.set([]);
}
FutureOr<void> onPayeeSelected(Account account) async {
if (account.id.isEmpty) {
accounts.add(Account(id: account.name, name: account.name));
} else {
// Existing account was selected
selectedAccount.value = account;
}
}
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
spacing: 4,
crossAxisAlignment: .stretch,
children: [
InputDatePickerFormField(
focusNode: dateFocusNode,
onDateSaved: (date) => setDate(date),
onDateSubmitted: (date) => setDate(date),
fieldLabelText: 'Date',
fieldHintText: 'MM/DD/YYYY',
errorInvalidText: 'No transfers prior to y2k buddy',
errorFormatText: 'Gonna need to see a date here pal',
initialDate: selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
),
Watch.builder(
builder: (context) {
return TransactionCombobox(
textController: payeeController,
focusNode: payeeFocus,
accounts: accounts.value,
hintText: 'Merchant',
labelText: 'Merchant',
color: Colors.blue.shade200,
validator: null,
createNewAccount: (name) async {
final (account, message) = await createAccount(
accountName: name,
);
if (account != null) {
return (account, null);
} else {
return (null, 'Failed to create account');
}
},
onFieldSubmitted: (name) async {
return await createAccount(accountName: name);
},
onAccountSelected: (n) => onPayeeSelected(n!),
);
},
dependencies: [accounts],
),
Watch.builder(
builder: (context) {
return TransactionCombobox(
textController: categoryController,
focusNode: categoryFocus,
accounts: accounts.value,
hintText: 'Category',
labelText: 'Category',
color: Colors.orange.shade200,
validator: null,
createNewAccount: (name) async {
final (account, message) = await createAccount(
accountName: name,
);
if (account != null) {
return (account, null);
} else {
return (null, 'Failed to create account');
}
},
onFieldSubmitted: (name) async {
return await createAccount(accountName: name);
},
onAccountSelected: (n) => onPayeeSelected(n!),
);
},
dependencies: [accounts],
),
TextFormField(
decoration: const InputDecoration(
labelText: 'Some Field',
hintText: 'Tab tab tab',
border: OutlineInputBorder(),
),
),
Text(
'Utilities and debugging',
style: Theme.of(context).textTheme.titleMedium,
),
Watch.builder(
builder: (context) {
final accountSimple = accounts.get().map(
(acc) => '${acc.name}(${acc.id})',
);
final currentRate = successRate.value;
final percentage = (currentRate * 100).toInt();
return Row(
mainAxisAlignment: .spaceBetween,
crossAxisAlignment: .start,
children: [
Flexible(
child: Column(
children: [
Column(
children: [
Text('Request Success Rate:'),
Row(
children: [
Expanded(
child: Slider(
value: currentRate,
min: 0.0,
max: 1.0,
divisions: 20,
label: '$percentage%',
onChanged: (value) {
successRate.value = value;
},
),
),
SizedBox(
width: 50,
child: Text(
'$percentage%',
textAlign: TextAlign.center,
),
),
],
),
],
),
TextButton(
onPressed: resetAccounts,
child: Text('Reset accounts to default'),
),
TextButton(
onPressed: add100Accounts,
child: Text('Add 100 accounts'),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
for (final account in accountSimple) //
Text(account),
],
),
),
],
);
},
),
],
),
),
),
),
);
}
}
class TransactionCombobox extends StatefulWidget {
const TransactionCombobox({
required this.accounts,
required this.createNewAccount,
required this.onAccountSelected,
required this.onFieldSubmitted,
required this.textController,
required this.focusNode,
this.validator,
this.hintText = 'ComboBox',
this.labelText,
this.color = Colors.grey,
this.icon = Icons.mail_outline_rounded,
this.disabled = false,
super.key,
});
final List<Account> accounts;
final String hintText;
final String? labelText;
final Future<(Account?, String?)> Function(String name)? createNewAccount;
final Function(String name) onFieldSubmitted;
final FutureOr<void> Function(Account? account) onAccountSelected;
final String? Function(String?)? validator;
final Color color;
final IconData icon;
final TextEditingController textController;
final FocusNode focusNode;
final bool disabled;
@override
State<TransactionCombobox> createState() => _TransactionComboboxState();
}
class _TransactionComboboxState extends State<TransactionCombobox> {
List<AccountOptionsView> _getOptions(TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) {
return widget
.accounts //
.map((account) => AccountOptionsView(account))
.toList();
}
final matches = widget.accounts
.where((Account acc) {
return acc.name.toLowerCase().contains(
textEditingValue.text.toLowerCase(),
);
})
.map((account) => AccountOptionsView(account))
.toList();
final input = textEditingValue.text.trim();
if (widget.createNewAccount != null) {
if (input.isNotEmpty &&
!widget.accounts.any(
(cat) => cat.name.toLowerCase() == input.toLowerCase(),
)) {
matches.add(
AccountOptionsView(
Account(id: '', name: input),
isCreateNew: true,
),
);
}
}
return matches;
}
Future<void> _handleSelection(
AccountOptionsView selection, {
TextEditingController? controller,
}) async {
final effectiveController = controller ?? widget.textController;
late Account finalSelection;
if (selection.isCreateNew) {
final (account, errorMessage) = await widget.createNewAccount!(selection.account.name);
if (account == null || errorMessage != null) {
return;
}
finalSelection = account;
} else {
finalSelection = selection.account;
}
widget.onAccountSelected(finalSelection);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
//See fixme above
effectiveController.text = finalSelection.name;
FocusScope.of(context).nextFocus();
}
});
}
@override
Widget build(BuildContext context) {
return Autocomplete<AccountOptionsView>(
textEditingController: widget.textController,
focusNode: widget.focusNode,
displayStringForOption: (view) => view.account.name,
optionsBuilder: _getOptions,
onSelected: _handleSelection,
fieldViewBuilder:
(
BuildContext context,
TextEditingController controller,
FocusNode focusNode,
VoidCallback onFieldSubmitted,
) {
return TextFormField(
controller: controller,
focusNode: focusNode,
onFieldSubmitted: widget.onFieldSubmitted,
decoration: InputDecoration(
labelText: widget.labelText,
hintText: widget.hintText,
labelStyle: widget.disabled
? TextStyle(color: Colors.black87)
: TextStyle(color: widget.color),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: widget.color, width: 2.0),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: widget.color.withValues()),
),
disabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey, width: 1.0),
),
prefixStyle: TextStyle(color: widget.color),
),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
);
},
optionsViewBuilder:
(
BuildContext context,
AutocompleteOnSelected<AccountOptionsView> onSelected,
Iterable<AccountOptionsView> options,
) {
final highlightedIndex = AutocompleteHighlightedOption.of(context);
return _TransactionOptionsView(
focusNode: widget.focusNode,
highlightedIndex: highlightedIndex,
onSelected: onSelected,
options: options,
icon: widget.icon,
);
},
);
}
}
class _TransactionOptionsView extends StatefulWidget {
const _TransactionOptionsView({
required this.focusNode,
required this.highlightedIndex,
required this.onSelected,
required this.options,
required this.icon,
});
final FocusNode focusNode;
final int highlightedIndex;
final AutocompleteOnSelected<AccountOptionsView> onSelected;
final Iterable<AccountOptionsView> options;
final IconData icon;
@override
State<_TransactionOptionsView> createState() => _TransactionOptionsViewState();
}
class _TransactionOptionsViewState extends State<_TransactionOptionsView> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
widget.focusNode.onKeyEvent = _onKeyEvent;
}
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (node.hasFocus == false) {
return KeyEventResult.ignored;
}
if (event is! KeyDownEvent) {
return KeyEventResult.ignored;
}
if (event.logicalKey == LogicalKeyboardKey.enter) {
final view = widget.options.elementAt(widget.highlightedIndex);
widget.onSelected(view);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
void dispose() {
widget.focusNode.onKeyEvent = null;
super.dispose();
}
@override
void didUpdateWidget(_TransactionOptionsView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.highlightedIndex != oldWidget.highlightedIndex) {
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (!mounted) {
return;
}
final BuildContext? highlightedContext = GlobalObjectKey(
widget.options.elementAt(widget.highlightedIndex),
).currentContext;
if (highlightedContext == null) {
_scrollController.jumpTo(
widget.highlightedIndex == 0 ? 0.0 : _scrollController.position.maxScrollExtent,
);
} else {
Scrollable.ensureVisible(highlightedContext, alignment: 0.5);
}
}, debugLabel: 'AutocompleteOptions.ensureVisible');
}
}
@override
Widget build(BuildContext context) {
return FocusScope(
canRequestFocus: false,
child: Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
controller: _scrollController,
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: widget.options.length,
itemBuilder: (BuildContext context, int index) {
final view = widget.options.elementAt(index);
final isHighlighted = widget.highlightedIndex == index;
return ListTile(
key: GlobalObjectKey(view),
tileColor: isHighlighted ? Theme.of(context).highlightColor : null,
dense: true,
leading: Icon(
view.isCreateNew ? Icons.add_circle_outline : widget.icon,
size: 20,
color: view.isCreateNew ? Colors.green : null,
),
title: Text(
view.isCreateNew ? 'Create "${view.account.name}"' : view.account.name,
style: view.isCreateNew ? const TextStyle(color: Colors.green) : null,
),
onTap: () => widget.onSelected(view),
);
},
),
),
),
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment