Tutorial: a filterable list
In about fifteen minutes you’ll build a list that narrows live as you type — touching the three things every Fleury app is made of: state, layout, and input.
Continue in the my_app project from Getting started.
If you skipped it, create the project now:
fleury create my_app --dependency-source=gitcd my_appThe code below imports package:my_app and names its root widget MyApp, the
class the generated bin/run_app.dart starts. With a different project name,
use your package name in the imports and the class name from your
bin/run_app.dart.
What we’re building
Section titled “What we’re building”This — a text field over a filtered list with a live count. It’s the finished app, running in your browser; type into it and watch the list narrow:
1. A static list
Section titled “1. A static list”Start with a stateless screen that just renders some data — replace everything
in lib/app.dart:
import 'package:fleury/fleury.dart';
const _languages = [ 'Dart', 'Rust', 'Go', 'Python', 'TypeScript', 'Elixir', 'Zig', 'Swift', 'Kotlin', 'Haskell',];
class MyApp extends StatelessWidget { const MyApp({super.key});
@override Widget build(BuildContext context) { return const FleuryApp(title: 'Filter', home: FilterApp()); }}
class FilterApp extends StatelessWidget { const FilterApp({super.key});
@override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.all(1), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final name in _languages) Text(name), ], ), );}The existing test also needs to follow the screen you just replaced. Use this small smoke test for the rest of the tutorial:
import 'package:fleury_test/fleury_test.dart';import 'package:my_app/app.dart';import 'package:test/test.dart';
void main() { testWidgets('shows the language list', (tester) { tester.pumpWidget(const MyApp()); expect(tester.renderToString(emptyMark: ' '), contains('Dart')); });}Run dart test to check it. Then start the app with fleury run (or press F5
in VS Code). The list appears one item per row, padded a cell off the edge.
Nothing moves yet — let’s make it react.
Leave the app running for the rest of the tutorial. Saving reloads it in place
and keeps its state, so you see most steps as soon as you save them; step 2 is
the one change that needs a restart. (Ctrl+C quits.)
2. Hold state
Section titled “2. Hold state”Filtering means the screen changes over time, so we need a StatefulWidget and a
place to keep the query. Swap FilterApp for a stateful version:
class FilterApp extends StatefulWidget { const FilterApp({super.key});
@override State<FilterApp> createState() => _FilterAppState();}
class _FilterAppState extends State<FilterApp> { String _query = '';
List<String> get _matches => _languages .where((name) => name.toLowerCase().contains(_query.toLowerCase())) .toList();
@override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.all(1), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final name in _matches) Text(name), ], ), );}_matches derives the visible list from _query every build. _query is still
always '', so nothing’s filtered — until we wire up the input. (Until then the
analyzer suggests making _query final; step 3 assigns it.)
Turning a stateless widget into a stateful one is a change a reload can’t
apply, so restart after saving this step: in a fleury run session, press
Ctrl+G, then F5, or use the editor’s Restart action if you launched
with VS Code’s F5. If F5 doesn’t reach the app, see
When F5 doesn’t restart.
3. Add the text field
Section titled “3. Add the text field”TextInput.onChanged reports the current text. Store that value in _query
with setState, and the list filters as you type:
class _FilterAppState extends State<FilterApp> { String _query = '';
List<String> get _matches => _languages .where((name) => name.toLowerCase().contains(_query.toLowerCase())) .toList();
@override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.all(1), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextInput( autofocus: true, placeholder: 'Filter languages…', onChanged: (value) => setState(() => _query = value), ), const SizedBox(height: 1), for (final name in _matches) Text(name), ], ), );}Save, and the field appears above the list; type, and the list narrows.
onChanged calls setState on every edit; build recomputes _matches from
_query, and Fleury writes only the cells that changed to the terminal. That’s
the whole reactive loop. autofocus: true gives the field keyboard focus as
soon as it appears, so you can type straight away.
4. Layout and polish
Section titled “4. Layout and polish”Two touches make it feel finished: a count of the matches, and an empty state
when nothing matches. Expanded gives the list area the rest of the height
under the field. Here’s the final build:
@overrideWidget build(BuildContext context) { final matches = _matches; return Padding( padding: const EdgeInsets.all(1), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextInput( autofocus: true, placeholder: 'Filter languages…', onChanged: (value) => setState(() => _query = value), ), const SizedBox(height: 1), Text( '${matches.length} of ${_languages.length}', style: const CellStyle(dim: true), ), const SizedBox(height: 1), Expanded( child: matches.isEmpty ? const Text('No matches', style: CellStyle(dim: true)) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [for (final name in matches) Text(name)], ), ), ], ), );}Save once more. The count updates as you type, and a query with no hits (try
zz) shows the empty state. In a terminal shorter than the list, the rows that
don’t fit are cut off; a ListView scrolls instead, which
Lists & scrolling covers.
That’s a complete, interactive Fleury app: state held in a State, an input
reported through onChanged, a derived list, and a layout that fills the screen.
Here is the finished lib/app.dart:
import 'package:fleury/fleury.dart';
const _languages = [ 'Dart', 'Rust', 'Go', 'Python', 'TypeScript', 'Elixir', 'Zig', 'Swift', 'Kotlin', 'Haskell',];
class MyApp extends StatelessWidget { const MyApp({super.key});
@override Widget build(BuildContext context) { return const FleuryApp(title: 'Filter', home: FilterApp()); }}
class FilterApp extends StatefulWidget { const FilterApp({super.key});
@override State<FilterApp> createState() => _FilterAppState();}
class _FilterAppState extends State<FilterApp> { String _query = '';
List<String> get _matches => _languages .where((name) => name.toLowerCase().contains(_query.toLowerCase())) .toList();
@override Widget build(BuildContext context) { final matches = _matches; return Padding( padding: const EdgeInsets.all(1), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextInput( autofocus: true, placeholder: 'Filter languages…', onChanged: (value) => setState(() => _query = value), ), const SizedBox(height: 1), Text( '${matches.length} of ${_languages.length}', style: const CellStyle(dim: true), ), const SizedBox(height: 1), Expanded( child: matches.isEmpty ? const Text('No matches', style: CellStyle(dim: true)) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [for (final name in matches) Text(name)], ), ), ], ), ); }}Where to go next
Section titled “Where to go next”- Make the list scrollable and selectable with the arrow keys:
Lists & scrolling covers
ListViewandListController. - Test the filtering: Testing shows how to fill a text field from a test and check what renders.
- Swap the plain
ColumnofTextfor a richer widget — aTree,DataTable, orSelect— from the widget reference. They are already available from your Fleury import. - Preview the app in a browser on macOS or Linux:
fleury serve --spawnruns it and streams it to a tab, as in step 5 of Getting started. - Browse the guides for forms, navigation, key handling, and more.