Руководства
Создание поиска по каталогу на Flutter
Соберите полноценный экран поиска с UseCase на чистом Dart, Flutter-интеграцией, политиками ввода, явным владением и детерминированными тестами.
Создание поиска по каталогу на Flutter
В этом руководстве мы создадим небольшую, но законченную функцию Flutter-приложения. Интерфейс отправляет намерение и отображает Snapshot. UseCase на чистом Dart координирует поиск, а репозиторий отвечает за получение данных.
Готовый сценарий использует:
SearchCatalogкак типизированный Command;- Debounce для быстрого ввода;
restartдля устаревшего активного поиска в том же каталоге;UseCaseProviderкак границу владения;UseCaseBuilderдля построения интерфейса;UseCaseTestObserverиTestUseCaseClockдля детерминированной проверки.
Создайте проект
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
Отделите получение данных от UseCase
Контракт репозитория определяет способ получения результатов. Он не решает, когда запрос допустить, заменить или отменить.
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();
}
}
Опишите State и Command
State содержит только значения, необходимые экрану. Command передаёт намерение и задаёт группу, в которой работают политики ввода и выполнения.
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';
}
Зарегистрируйте политики жизненного цикла
Debounce удаляет устаревшие Command, которые ещё не начали выполняться. restart запрашивает отмену, когда новый допущенный Command встречает активную работу в той же группе.
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,
));
}
}
Отмена кооперативна. Сетевой адаптер должен отменять базовый запрос, если клиент это поддерживает; проверка контекста не позволяет опубликовать устаревший ответ.
Подключите UseCase к Flutter
Provider создаёт и закрывает UseCase. Экран читает точный тип для отправки Command и перестраивается из текущего 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))],
);
},
),
),
]),
);
}
}
Установите границу владения
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(),
),
);
}
}
Используйте UseCaseProvider.value, если экземпляром владеет внешний корень композиции. Виджет, который только читает внешний UseCase, не должен его закрывать.
Проверьте политику без реального ожидания
Передайте TestUseCaseClock, продвиньте время до границы Debounce и дождитесь конечного события вместо фиксированной задержки.
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);
});
}