Tutorials
Build a Flutter catalog search
Create a complete search screen with a pure Dart UseCase, Flutter bindings, input policy, ownership, and deterministic tests.
Build a Flutter catalog search
This tutorial builds one small but complete Flutter feature. The UI sends intent and renders snapshots. A pure Dart UseCase owns search coordination, while a repository owns data access.
The finished flow uses:
SearchCatalogas the typed Command;- Debounce for rapid input;
restartfor an older active search in the same catalog;UseCaseProviderfor ownership;UseCaseBuilderfor rendering;UseCaseTestObserverandTestUseCaseClockfor deterministic verification.
Create the project
flutter create arktelos_catalog
cd arktelos_catalog
flutter pub add usecase_forge:^0.1.0-dev.2
flutter pub add usecase_forge_flutter:^0.1.0-dev.1
flutter pub add --dev usecase_forge_test:^0.1.0-dev.1
PROJECT STRUCTURE
lib/
├── catalog_repository.dart
├── catalog_search.dart
├── catalog_screen.dart
└── main.dart
test/
└── catalog_search_test.dart
Keep data access outside the UseCase
The repository contract says how search data is obtained. It does not decide when a request is admitted, replaced, or cancelled.
abstract interface class CatalogRepository {
Future<List<String>> search(String query);
}
final class DemoCatalogRepository implements CatalogRepository {
@override
Future<List<String>> search(String query) async {
await Future<void>.delayed(const Duration(milliseconds: 120));
const items = ['Dart SDK', 'Flutter', 'pub.dev', 'UseCase Forge'];
final normalized = query.toLowerCase();
return items.where((item) => item.toLowerCase().contains(normalized)).toList();
}
}
Model State and Command
State contains only values required to render the screen. Command carries the intent and defines the group used by input and processing policies.
import 'package:usecase_forge/usecase_forge.dart';
import 'catalog_repository.dart';
enum CatalogStatus { idle, searching, ready }
final class CatalogState {
const CatalogState({required this.query, required this.status, required this.items});
const CatalogState.idle() : query = '', status = CatalogStatus.idle, items = const [];
final String query;
final CatalogStatus status;
final List<String> items;
@override
bool operator ==(Object other) =>
other is CatalogState && other.query == query &&
other.status == status && _sameItems(other.items, items);
@override
int get hashCode => Object.hash(query, status, Object.hashAll(items));
}
bool _sameItems(List<String> left, List<String> right) {
if (left.length != right.length) return false;
for (var index = 0; index < left.length; index++) {
if (left[index] != right[index]) return false;
}
return true;
}
final class SearchCatalog extends UseCaseCommand {
const SearchCatalog(this.query);
final String query;
@override
Object get executionKey => 'main-catalog';
}
Register the lifecycle policy
Debounce removes obsolete commands that have not started. restart requests cancellation when a newer accepted Command meets active work in the same group.
final class CatalogSearchUseCase extends UseCase<CatalogState> {
CatalogSearchUseCase(this.repository, {UseCaseClock? clock})
: super(initialState: const CatalogState.idle(), clock: clock) {
registerCommand<SearchCatalog>(
_search,
instructions: UseCaseInstructionOverrides(
input: UseCaseInputInstructionOverrides(
debounce: UseCaseDebounceInstructions(
duration: const Duration(milliseconds: 300),
),
),
processing: const UseCaseProcessingInstructionOverrides(
existingExecutionPolicy: UseCaseExistingExecutionPolicy.restart,
),
),
);
}
final CatalogRepository repository;
Future<void> _search(
SearchCatalog command,
UseCaseExecutionContext<CatalogState> context,
) async {
context.publish(CatalogState(
query: command.query,
status: CatalogStatus.searching,
items: context.snapshot.state.items,
));
final items = await repository.search(command.query);
if (context.isCancellationRequested) return;
context.publish(CatalogState(
query: command.query,
status: CatalogStatus.ready,
items: items,
));
}
}
Cancellation is cooperative. A real network adapter should also cancel its underlying request when its client supports cancellation; checking the context prevents a stale response from being published.
Connect the UseCase to Flutter
The Provider creates and closes the UseCase. The screen reads the exact type to send a Command and rebuilds from the current Snapshot.
import 'package:flutter/material.dart';
import 'package:usecase_forge/usecase_forge.dart';
import 'package:usecase_forge_flutter/usecase_forge_flutter.dart';
import 'catalog_search.dart';
final class CatalogScreen extends StatelessWidget {
const CatalogScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Catalog')),
body: Column(children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: const InputDecoration(labelText: 'Search'),
onChanged: (query) => context
.readUseCase<CatalogSearchUseCase>()
.add(SearchCatalog(query)),
),
),
Expanded(
child: UseCaseBuilder<CatalogSearchUseCase, CatalogState>(
builder: (context, snapshot) {
final state = snapshot.state;
if (state.status == CatalogStatus.searching) {
return const Center(child: CircularProgressIndicator());
}
return ListView(
children: [for (final item in state.items) ListTile(title: Text(item))],
);
},
),
),
]),
);
}
}
Install the ownership boundary
import 'package:flutter/material.dart';
import 'package:usecase_forge_flutter/usecase_forge_flutter.dart';
import 'catalog_repository.dart';
import 'catalog_screen.dart';
import 'catalog_search.dart';
void main() => runApp(const CatalogApp());
final class CatalogApp extends StatelessWidget {
const CatalogApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: UseCaseProvider<CatalogSearchUseCase>(
create: (_) => CatalogSearchUseCase(DemoCatalogRepository()),
child: const CatalogScreen(),
),
);
}
}
Use UseCaseProvider.value instead when an outer composition root owns the instance. The reading widget must not close an externally owned UseCase.
Verify the policy without real waiting
Inject TestUseCaseClock, advance the Debounce boundary, and wait for a terminal event rather than sleeping.
import 'package:flutter_test/flutter_test.dart';
import 'package:usecase_forge_test/usecase_forge_test.dart';
import '../lib/catalog_repository.dart';
import '../lib/catalog_search.dart';
final class ImmediateCatalogRepository implements CatalogRepository {
@override
Future<List<String>> search(String query) async => ['Dart SDK'];
}
void main() {
test('the latest query reaches terminal history', () async {
final clock = TestUseCaseClock(DateTime.utc(2030));
final useCase = CatalogSearchUseCase(ImmediateCatalogRepository(), clock: clock);
final observer = UseCaseTestObserver<CatalogState>(useCase);
addTearDown(() async {
await useCase.close();
await observer.cancel();
});
useCase
..add(const SearchCatalog('da'))
..add(const SearchCatalog('dart'));
clock.advance(const Duration(milliseconds: 300));
await observer.waitForHistory(hasLength(1));
expect(useCase.state.state.query, 'dart');
expect(useCase.state.state.status, CatalogStatus.ready);
});
}