Tutorials

Build a Flutter catalog search

Create a complete search screen with a pure Dart UseCase, Flutter bindings, input policy, ownership, and deterministic tests.

v0.1.0-devIntermediateusecase_forge_flutter

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:

  • SearchCatalog as the typed Command;
  • Debounce for rapid input;
  • restart for an older active search in the same catalog;
  • UseCaseProvider for ownership;
  • UseCaseBuilder for rendering;
  • UseCaseTestObserver and TestUseCaseClock for deterministic verification.
01

Create the project

Terminal
shell
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

text
lib/
├── catalog_repository.dart
├── catalog_search.dart
├── catalog_screen.dart
└── main.dart
test/
└── catalog_search_test.dart
02

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.

lib/catalog_repository.dart
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();
  }
}
03

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.

lib/catalog_search.dart
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';
}
04

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.

lib/catalog_search.dart
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.

05

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.

lib/catalog_screen.dart
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))],
              );
            },
          ),
        ),
      ]),
    );
  }
}
06

Install the ownership boundary

lib/main.dart
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.

07

Verify the policy without real waiting

Inject TestUseCaseClock, advance the Debounce boundary, and wait for a terminal event rather than sleeping.

test/catalog_search_test.dart
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);
  });
}
ARKTELOS

Purpose-built engineering systems for software that has to hold.