Created
January 7, 2026 18:02
-
-
Save saltedpotatos/1945afaa0baa888432276240ad317b1b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //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/services.dart'; | |
| 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; | |
| } | |
| void main() { | |
| 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 acccount"); | |
| } | |
| }, | |
| 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 acccount"); | |
| } | |
| }, | |
| 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> { | |
| // final TextEditingController _textEditingController = TextEditingController(); | |
| int _highlightedIndex = -1; | |
| bool _showOptionsOnEmpty = false; | |
| final ScrollController _scrollController = ScrollController(); | |
| @override | |
| void dispose() { | |
| // _textEditingController.dispose(); | |
| _scrollController.dispose(); | |
| super.dispose(); | |
| } | |
| void _scrollToHighlightedItem(int optionsLength) { | |
| if (_highlightedIndex == -1 || !_scrollController.hasClients) return; | |
| const double itemHeight = | |
| 56.0; // Approximate height of a ListTile with dense: true | |
| const double maxHeight = 200.0; | |
| final double targetScrollOffset = _highlightedIndex * itemHeight; | |
| final double currentScrollOffset = _scrollController.offset; | |
| final double visibleHeight = maxHeight; | |
| // Check if the highlighted item is above the visible area | |
| if (targetScrollOffset < currentScrollOffset) { | |
| _scrollController.animateTo( | |
| targetScrollOffset, | |
| duration: const Duration(milliseconds: 200), | |
| curve: Curves.easeInOut, | |
| ); | |
| } | |
| // Check if the highlighted item is below the visible area | |
| else if (targetScrollOffset + itemHeight > | |
| currentScrollOffset + visibleHeight) { | |
| _scrollController.animateTo( | |
| targetScrollOffset + itemHeight - visibleHeight, | |
| duration: const Duration(milliseconds: 200), | |
| curve: Curves.easeInOut, | |
| ); | |
| } | |
| } | |
| List<Account> _getOptions(TextEditingValue textEditingValue) { | |
| if (textEditingValue.text.isEmpty) { | |
| return widget.accounts.toList(); | |
| } | |
| final matches = widget.accounts.where((Account acc) { | |
| return acc.name.toLowerCase().contains( | |
| textEditingValue.text.toLowerCase(), | |
| ); | |
| }).toList(); | |
| final input = textEditingValue.text.trim(); | |
| if (widget.createNewAccount != null) { | |
| if (input.isNotEmpty && | |
| !widget.accounts.any( | |
| (cat) => cat.name.toLowerCase() == input.toLowerCase(), | |
| )) { | |
| final createNewAccount = Account(id: "", name: 'Create "$input"'); | |
| matches.add(createNewAccount); | |
| } | |
| } | |
| return matches; | |
| } | |
| Future<void> _handleSelection( | |
| Account selection, { | |
| TextEditingController? controller, | |
| FocusNode? focusNode, | |
| }) async { | |
| final effectiveController = controller ?? widget.textController; | |
| Account? finalSelection; | |
| if (widget.createNewAccount == null) { | |
| finalSelection = selection; | |
| } else { | |
| // Check if this is the 'Create...' option. | |
| if (selection.id.isEmpty) { | |
| final newAccount = await widget.createNewAccount!(selection.name); | |
| final (account, errorMessage) = newAccount; | |
| finalSelection = account; | |
| } else { | |
| // It's a regular selection. | |
| finalSelection = selection; | |
| } | |
| } | |
| widget.onAccountSelected(finalSelection); | |
| WidgetsBinding.instance.addPostFrameCallback((_) { | |
| if (mounted) { | |
| //See fixme above | |
| effectiveController.text = finalSelection?.name ?? selection.name; | |
| FocusScope.of(context).nextFocus(); | |
| setState(() { | |
| _highlightedIndex = -1; | |
| _showOptionsOnEmpty = false; // Reset state on selection | |
| }); | |
| } | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Autocomplete<Account>( | |
| textEditingController: widget.textController, | |
| focusNode: widget.focusNode, | |
| displayStringForOption: (acc) => acc.name, | |
| optionsBuilder: _getOptions, | |
| onSelected: (Account selection) { | |
| _handleSelection(selection); | |
| // After selecting, move to the next field. | |
| // FocusScope.of(context).nextFocus(); | |
| }, | |
| fieldViewBuilder: | |
| ( | |
| BuildContext context, | |
| TextEditingController _, | |
| FocusNode _, | |
| VoidCallback onFieldSubmitted, | |
| ) { | |
| // widget.textController.value = fieldController.value; | |
| widget.focusNode.addListener(() { | |
| if (!widget.focusNode.hasFocus) { | |
| if (context.mounted) { | |
| setState(() { | |
| _showOptionsOnEmpty = false; | |
| _highlightedIndex = -1; | |
| }); | |
| } | |
| } | |
| }); | |
| widget.focusNode.onKeyEvent = (node, event) { | |
| if (event is! KeyDownEvent) return KeyEventResult.ignored; | |
| if (event.logicalKey == LogicalKeyboardKey.tab) { | |
| if (HardwareKeyboard.instance.isShiftPressed) { | |
| node.previousFocus(); | |
| } else { | |
| node.nextFocus(); | |
| } | |
| return KeyEventResult.handled; | |
| } | |
| if (event.logicalKey == LogicalKeyboardKey.escape) { | |
| if (_showOptionsOnEmpty) { | |
| if (context.mounted) { | |
| setState(() { | |
| _showOptionsOnEmpty = false; | |
| _highlightedIndex = -1; | |
| }); | |
| } | |
| return KeyEventResult.handled; | |
| } | |
| return KeyEventResult.ignored; | |
| } | |
| final options = _getOptions(widget.textController.value); | |
| if (widget.textController.text.isEmpty && | |
| !_showOptionsOnEmpty && | |
| event.logicalKey == LogicalKeyboardKey.arrowDown) { | |
| if (context.mounted) { | |
| setState(() { | |
| _showOptionsOnEmpty = true; | |
| _highlightedIndex = 0; | |
| }); | |
| } | |
| return KeyEventResult.handled; | |
| } | |
| if (widget.textController.text.isNotEmpty && | |
| _showOptionsOnEmpty) { | |
| if (context.mounted) { | |
| setState(() => _showOptionsOnEmpty = false); | |
| } | |
| } | |
| if (options.isEmpty) { | |
| if (_highlightedIndex != -1) { | |
| if (context.mounted) { | |
| setState(() => _highlightedIndex = -1); | |
| } | |
| } | |
| return KeyEventResult.ignored; | |
| } | |
| if (event.logicalKey == LogicalKeyboardKey.arrowDown) { | |
| if (context.mounted) { | |
| setState( | |
| () => _highlightedIndex = | |
| (_highlightedIndex + 1) % options.length, | |
| ); | |
| _scrollToHighlightedItem(options.length); | |
| } | |
| return KeyEventResult.handled; | |
| } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { | |
| if (context.mounted) { | |
| setState( | |
| () => _highlightedIndex = | |
| (_highlightedIndex - 1 + options.length) % | |
| options.length, | |
| ); | |
| _scrollToHighlightedItem(options.length); | |
| } | |
| return KeyEventResult.handled; | |
| } else if (event.logicalKey == LogicalKeyboardKey.enter && | |
| _highlightedIndex != -1) { | |
| var selectedOption = options.elementAt(_highlightedIndex); | |
| if (selectedOption.name.startsWith('Create "') && | |
| selectedOption.id.isEmpty) { | |
| final newAccountName = selectedOption.name.substring( | |
| 8, | |
| selectedOption.name.length - 1, | |
| ); | |
| selectedOption = Account(id: "", name: newAccountName); | |
| } | |
| _handleSelection( | |
| selectedOption, | |
| controller: widget.textController, | |
| focusNode: widget.focusNode, | |
| ); | |
| return KeyEventResult.handled; | |
| } | |
| return KeyEventResult.ignored; | |
| }; | |
| return TextFormField( | |
| controller: widget.textController, | |
| focusNode: widget.focusNode, | |
| onFieldSubmitted: widget.onFieldSubmitted, | |
| onChanged: (text) { | |
| setState(() {}); | |
| }, | |
| 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<Account> onSelected, | |
| Iterable<Account> options, | |
| ) { | |
| if (widget.textController.text.isEmpty && !_showOptionsOnEmpty) { | |
| return const SizedBox.shrink(); | |
| } | |
| return _TransactionOptionsView( | |
| onSelected: onSelected, | |
| options: options, | |
| highlightedIndex: _highlightedIndex, | |
| icon: widget.icon, | |
| scrollController: _scrollController, | |
| ); | |
| }, | |
| ); | |
| } | |
| } | |
| class _TransactionOptionsView extends StatelessWidget { | |
| const _TransactionOptionsView({ | |
| required this.onSelected, | |
| required this.options, | |
| required this.highlightedIndex, | |
| required this.icon, | |
| required this.scrollController, | |
| }); | |
| final AutocompleteOnSelected<Account> onSelected; | |
| final Iterable<Account> options; | |
| final int highlightedIndex; | |
| final IconData icon; | |
| final ScrollController scrollController; | |
| Color? _setOptionColor(bool isCreateNew, Account option) { | |
| if (isCreateNew) { | |
| return Colors.green; | |
| } else { | |
| return null; | |
| } | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return 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: options.length, | |
| itemBuilder: (BuildContext context, int index) { | |
| final Account option = options.elementAt(index); | |
| final bool isCreateNew = | |
| option.name.startsWith('Create "') && option.id.isEmpty; | |
| final bool isHighlighted = highlightedIndex == index; | |
| return ListTile( | |
| tileColor: isHighlighted | |
| ? Theme.of(context).highlightColor | |
| : null, | |
| dense: true, | |
| leading: Icon( | |
| isCreateNew ? Icons.add_circle_outline : icon, | |
| size: 20, | |
| color: _setOptionColor(isCreateNew, option), | |
| ), | |
| title: Text( | |
| option.name, | |
| style: isCreateNew | |
| ? const TextStyle(color: Colors.green) | |
| : null, | |
| ), | |
| onTap: () { | |
| if (option.name.startsWith('Create "') && option.id.isEmpty) { | |
| // Extract the new category name and add it | |
| final newAccountName = option.name.substring( | |
| 8, | |
| option.name.length - 1, | |
| ); | |
| final acc = Account(id: "", name: newAccountName); | |
| onSelected(acc); | |
| } else { | |
| onSelected(option); | |
| } | |
| }, | |
| ); | |
| }, | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment