Руководства

Создание поиска по каталогу на Flutter

Соберите полноценный экран поиска с UseCase на чистом Dart, Flutter-интеграцией, политиками ввода, явным владением и детерминированными тестами.

v0.1.0-devСредний уровеньusecase_forge_flutter

Создание поиска по каталогу на Flutter

В этом руководстве мы создадим небольшую, но законченную функцию Flutter-приложения. Интерфейс отправляет намерение и отображает Snapshot. UseCase на чистом Dart координирует поиск, а репозиторий отвечает за получение данных.

Готовый сценарий использует:

  • SearchCatalog как типизированный Command;
  • Debounce для быстрого ввода;
  • restart для устаревшего активного поиска в том же каталоге;
  • UseCaseProvider как границу владения;
  • UseCaseBuilder для построения интерфейса;
  • UseCaseTestObserver и TestUseCaseClock для детерминированной проверки.
01

Создайте проект

Терминал
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

Отделите получение данных от UseCase

Контракт репозитория определяет способ получения результатов. Он не решает, когда запрос допустить, заменить или отменить.

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

Опишите State и Command

State содержит только значения, необходимые экрану. Command передаёт намерение и задаёт группу, в которой работают политики ввода и выполнения.

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

Зарегистрируйте политики жизненного цикла

Debounce удаляет устаревшие Command, которые ещё не начали выполняться. restart запрашивает отмену, когда новый допущенный Command встречает активную работу в той же группе.

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,
    ));
  }
}

Отмена кооперативна. Сетевой адаптер должен отменять базовый запрос, если клиент это поддерживает; проверка контекста не позволяет опубликовать устаревший ответ.

05

Подключите UseCase к Flutter

Provider создаёт и закрывает UseCase. Экран читает точный тип для отправки Command и перестраивается из текущего 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

Установите границу владения

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(),
      ),
    );
  }
}

Используйте UseCaseProvider.value, если экземпляром владеет внешний корень композиции. Виджет, который только читает внешний UseCase, не должен его закрывать.

07

Проверьте политику без реального ожидания

Передайте TestUseCaseClock, продвиньте время до границы Debounce и дождитесь конечного события вместо фиксированной задержки.

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

Инженерные системы для программного обеспечения, которое обязано выдерживать нагрузку.