Skip to content

Instantly share code, notes, and snippets.

@saltedpotatos
Created January 7, 2026 18:02
Show Gist options
  • Select an option

  • Save saltedpotatos/1945afaa0baa888432276240ad317b1b to your computer and use it in GitHub Desktop.

Select an option

Save saltedpotatos/1945afaa0baa888432276240ad317b1b to your computer and use it in GitHub Desktop.
//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