Recipe 2 — Flutter Todos

시나리오

BLoC 공식 튜토리얼 Flutter Todos를 Flutist로 구현합니다. 할 일 추가·완료·삭제·필터링·통계 기능을 포함하며, SharedPreferences로 로컬 저장합니다. Microfeature Architecture를 적용해 TodosApi를 interface로 분리하고, 구현체(LocalStorageTodosApi)를 composition root에서만 주입합니다. FakeTodosApi로 BLoC 테스트를 완전히 격리합니다.

micro BLoC SharedPreferences Fake API
1
프로젝트 초기화 & 피처 모듈 생성

Todos 피처를 micro 타입으로 생성합니다. 5개 레이어(interface · implementation · testing · tests · example)가 자동으로 만들어집니다.

TERMINAL
$ flutist init
✓ flutter_todos/ 프로젝트 생성
✓ project.dart, package.dart 생성

$ flutist create --name todos --path features --options micro
✓ features/todos/todos_interface      생성
✓ features/todos/todos_implementation 생성
✓ features/todos/todos_testing        생성
✓ features/todos/todos_tests          생성
✓ features/todos/todos_example        생성
2
project.dart 의존성 선언

todos_interface는 모델·API 계약만 공개합니다. 구현체(todos_implementation)는 composition root(app)에서만 참조됩니다.

project.dart
final project = Project(
  name: 'flutter_todos',
  options: const ProjectOptions(compositionRoots: ['app']),
  modules: [

    // ── todos (micro) ─────────────────────────────────
    Module(
      name: 'todos_interface',
      dependencies: [
        package.dependencies.equatable,
        package.dependencies.uuid,
      ],
    ),
    Module(
      name: 'todos_implementation',
      dependencies: [
        package.dependencies.flutterBloc,
        package.dependencies.sharedPreferences,
      ],
      modules: [package.modules.todosInterface],
    ),
    Module(
      name: 'todos_testing',
      dependencies: [package.dependencies.rxdart],
      modules: [package.modules.todosInterface],
    ),
    Module(
      name: 'todos_tests',
      devDependencies: [
        package.dependencies.test,
        package.dependencies.blocTest,
        package.dependencies.mocktail,
      ],
      modules: [
        package.modules.todosImplementation,
        package.modules.todosTesting,
      ],
    ),
    Module(
      name: 'todos_example',
      modules: [
        package.modules.todosImplementation,
        package.modules.todosTesting,
      ],
    ),

    // ── app (composition root) ────────────────────────
    Module(
      name: 'app',
      modules: [
        package.modules.todosImplementation,   // ✅ 구현체 주입
      ],
    ),
  ],
);
3
Interface 레이어 — Todo 모델 & TodosApi 계약

todos_interfaceTodo 모델과 TodosApi abstract class만 공개합니다. 어떤 저장 방식도 알지 못합니다.

features/todos/todos_interface/lib/src/todo.dart
import 'package:equatable/equatable.dart';
import 'package:uuid/uuid.dart';

class Todo extends Equatable {
  Todo({
    String? id,
    required this.title,
    this.description = '',
    this.isCompleted = false,
  }) : id = id ?? const Uuid().v4();

  final String id;
  final String title;
  final String description;
  final bool isCompleted;

  Todo copyWith({
    String? id,
    String? title,
    String? description,
    bool? isCompleted,
  }) => Todo(
    id: id ?? this.id,
    title: title ?? this.title,
    description: description ?? this.description,
    isCompleted: isCompleted ?? this.isCompleted,
  );

  @override
  List<Object?> get props => [id, title, description, isCompleted];
}
features/todos/todos_interface/lib/src/todos_api.dart
import 'todo.dart';

abstract interface class TodosApi {
  Stream<List<Todo>> getTodos();
  Future<void> saveTodo(Todo todo);
  Future<void> deleteTodo(String id);
  Future<int> clearCompleted();
  Future<int> completeAll({required bool isCompleted});
}
4
Implementation 레이어 — 구현체 & BLoC

todos_implementationLocalStorageTodosApi(SharedPreferences 저장)와 세 BLoC(TodosOverviewBloc, EditTodoBloc, StatsBloc)를 포함합니다. composition root만 이 패키지를 참조할 수 있습니다.

features/todos/todos_implementation/lib/src/data/local_storage_todos_api.dart
import 'dart:convert';
import 'package:rxdart/subjects.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todos_interface/todos_interface.dart';

class LocalStorageTodosApi implements TodosApi {
  LocalStorageTodosApi({
    required SharedPreferences plugin,
  }) : _plugin = plugin {
    final todosJson = _plugin.getString(_kTodosKey);
    if (todosJson != null) {
      final todos = (jsonDecode(todosJson) as List)
          .map((e) => Todo.fromJson(e as Map<String, dynamic>))
          .toList();
      _todoStreamController.add(todos);
    } else {
      _todoStreamController.add(const []);
    }
  }

  static const String _kTodosKey = '__todos_key__';

  final SharedPreferences _plugin;
  final _todoStreamController =
      BehaviorSubject<List<Todo>>.seeded(const []);

  @override
  Stream<List<Todo>> getTodos() => _todoStreamController.asBroadcastStream();

  @override
  Future<void> saveTodo(Todo todo) {
    final todos = [..._todoStreamController.value];
    final idx = todos.indexWhere((t) => t.id == todo.id);
    if (idx >= 0) todos[idx] = todo;
    else todos.add(todo);
    _todoStreamController.add(todos);
    return _plugin.setString(_kTodosKey, jsonEncode(todos));
  }

  @override
  Future<void> deleteTodo(String id) {
    final todos = [..._todoStreamController.value]
      ..removeWhere((t) => t.id == id);
    _todoStreamController.add(todos);
    return _plugin.setString(_kTodosKey, jsonEncode(todos));
  }

  @override
  Future<int> clearCompleted() {
    final todos = [..._todoStreamController.value];
    final completed = todos.where((t) => t.isCompleted).length;
    todos.removeWhere((t) => t.isCompleted);
    _todoStreamController.add(todos);
    _plugin.setString(_kTodosKey, jsonEncode(todos));
    return Future.value(completed);
  }

  @override
  Future<int> completeAll({required bool isCompleted}) {
    final todos = _todoStreamController.value
        .map((t) => t.copyWith(isCompleted: isCompleted))
        .toList();
    final changed = todos.where((t) => t.isCompleted == isCompleted).length;
    _todoStreamController.add(todos);
    _plugin.setString(_kTodosKey, jsonEncode(todos));
    return Future.value(changed);
  }
}
features/todos/todos_implementation/lib/src/bloc/todos_overview_bloc.dart (발췌)
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:todos_interface/todos_interface.dart';

class TodosOverviewBloc extends Bloc<TodosOverviewEvent, TodosOverviewState> {
  TodosOverviewBloc({required TodosApi todosApi})
      : _todosApi = todosApi,
        super(const TodosOverviewState()) {
    on<TodosOverviewSubscriptionRequested>(_onSubscriptionRequested);
    on<TodosOverviewTodoCompletionToggled>(_onTodoCompletionToggled);
    on<TodosOverviewTodoDeleted>(_onTodoDeleted);
    on<TodosOverviewUndoDeletionRequested>(_onUndoDeletionRequested);
    on<TodosOverviewFilterChanged>(_onFilterChanged);
    on<TodosOverviewToggleAllRequested>(_onToggleAllRequested);
    on<TodosOverviewClearCompletedRequested>(_onClearCompletedRequested);
  }

  final TodosApi _todosApi;  // ← interface에만 의존 ✅

  Future<void> _onSubscriptionRequested(
    TodosOverviewSubscriptionRequested event,
    Emitter<TodosOverviewState> emit,
  ) async {
    emit(state.copyWith(status: () => TodosOverviewStatus.loading));
    await emit.forEach<List<Todo>>(
      _todosApi.getTodos(),
      onData: (todos) => state.copyWith(
        status: () => TodosOverviewStatus.success,
        todos: () => todos,
      ),
    );
  }
}
5
Testing 레이어 — FakeTodosApi

todos_testing은 인메모리 FakeTodosApi를 제공합니다. 네트워크·디스크 없이 BLoC를 완전히 격리해서 테스트할 수 있습니다. 이 패키지는 _tests_example에서만 참조 가능합니다.

features/todos/todos_testing/lib/src/fake_todos_api.dart
import 'package:rxdart/subjects.dart';
import 'package:todos_interface/todos_interface.dart';

class FakeTodosApi implements TodosApi {
  FakeTodosApi({List<Todo>? initialTodos})
      : _streamController = BehaviorSubject<List<Todo>>.seeded(
          initialTodos ?? [],
        );

  final BehaviorSubject<List<Todo>> _streamController;

  @override
  Stream<List<Todo>> getTodos() => _streamController.asBroadcastStream();

  @override
  Future<void> saveTodo(Todo todo) {
    final todos = [..._streamController.value];
    final idx = todos.indexWhere((t) => t.id == todo.id);
    if (idx >= 0) todos[idx] = todo; else todos.add(todo);
    return Future.value(_streamController.add(todos));
  }

  @override
  Future<void> deleteTodo(String id) {
    final todos = [..._streamController.value]
      ..removeWhere((t) => t.id == id);
    return Future.value(_streamController.add(todos));
  }

  @override
  Future<int> clearCompleted() {
    final todos = [..._streamController.value];
    final completed = todos.where((t) => t.isCompleted).length;
    todos.removeWhere((t) => t.isCompleted);
    _streamController.add(todos);
    return Future.value(completed);
  }

  @override
  Future<int> completeAll({required bool isCompleted}) {
    final todos = _streamController.value
        .map((t) => t.copyWith(isCompleted: isCompleted))
        .toList();
    _streamController.add(todos);
    return Future.value(todos.length);
  }
}
6
Tests 레이어 — BLoC 테스트

todos_testsFakeTodosApi를 주입해 BLoC를 테스트합니다. SharedPreferences나 실제 파일 시스템이 전혀 없어도 됩니다.

features/todos/todos_tests/test/todos_overview_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:test/test.dart';
import 'package:todos_implementation/todos_implementation.dart';
import 'package:todos_interface/todos_interface.dart';
import 'package:todos_testing/todos_testing.dart';

void main() {
  late FakeTodosApi api;

  final tTodos = [
    Todo(id: '1', title: 'Buy milk'),
    Todo(id: '2', title: 'Read a book', isCompleted: true),
    Todo(id: '3', title: 'Write tests'),
  ];

  setUp(() {
    api = FakeTodosApi(initialTodos: tTodos);
  });

  group('TodosOverviewBloc', () {
    blocTest<TodosOverviewBloc, TodosOverviewState>(
      'emits todos on subscription',
      build: () => TodosOverviewBloc(todosApi: api),
      act: (b) => b.add(const TodosOverviewSubscriptionRequested()),
      expect: () => [
        isA<TodosOverviewState>()
          .having((s) => s.status, 'status', TodosOverviewStatus.success)
          .having((s) => s.todos, 'todos', tTodos),
      ],
    );

    blocTest<TodosOverviewBloc, TodosOverviewState>(
      'toggles todo completion',
      build: () => TodosOverviewBloc(todosApi: api),
      seed: () => TodosOverviewState(
        status: TodosOverviewStatus.success,
        todos: tTodos,
      ),
      act: (b) => b.add(
        TodosOverviewTodoCompletionToggled(todo: tTodos.first, isCompleted: true),
      ),
      verify: (_) {
        expect(
          api.getTodos(),
          emits(contains(isA<Todo>().having((t) => t.isCompleted, 'isCompleted', true))),
        );
      },
    );

    blocTest<TodosOverviewBloc, TodosOverviewState>(
      'clears completed todos',
      build: () => TodosOverviewBloc(todosApi: api),
      seed: () => TodosOverviewState(
        status: TodosOverviewStatus.success,
        todos: tTodos,
      ),
      act: (b) => b.add(const TodosOverviewClearCompletedRequested()),
      verify: (_) {
        expect(
          api.getTodos(),
          emits(isNot(contains(isA<Todo>().having((t) => t.isCompleted, 'isCompleted', true)))),
        );
      },
    );
  });
}
7
App 조립 & generate 검증

composition root(app)에서만 LocalStorageTodosApi를 인스턴스화해 BLoC에 주입합니다. 나머지 앱 코드는 TodosApi interface만 봅니다.

app/lib/app.dart (발췌)
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:todos_implementation/todos_implementation.dart';

class App extends StatelessWidget {
  const App({required this.todosApi, super.key});

  final LocalStorageTodosApi todosApi; // composition root만 구현체를 앎 ✅

  @override
  Widget build(BuildContext context) {
    return RepositoryProvider.value(
      value: todosApi,
      child: const AppView(),
    );
  }
}

// main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferences.getInstance();
  runApp(App(todosApi: LocalStorageTodosApi(plugin: prefs)));
}
TERMINAL
$ flutist generate

✓ [B1] app → todos_implementation: composition root OK
✓ [B1] todos_tests → todos_implementation: 같은 피처 OK
✓ [B1] todos_example → todos_implementation: 같은 피처 OK
✓ [B2] todos_testing 참조: tests·example만 — OK
✓ [B3] Example layer references: OK
✓ [B5] Circular dependencies: OK
✓ pubspec.yaml 7개 동기화 완료

최종 구조

flutter_todos/
  app/                                  # Composition Root
    lib/app.dart                        # LocalStorageTodosApi 주입
    lib/main.dart
  features/
    todos/
      todos_interface/                  # Todo 모델, TodosApi (abstract)
        lib/src/todo.dart
        lib/src/todos_api.dart
      todos_implementation/             # LocalStorageTodosApi + 3 BLoC + UI
        lib/src/data/local_storage_todos_api.dart
        lib/src/bloc/todos_overview_bloc.dart
        lib/src/bloc/edit_todo_bloc.dart
        lib/src/bloc/stats_bloc.dart
        lib/src/view/todos_page.dart
      todos_testing/                    # FakeTodosApi (인메모리)
        lib/src/fake_todos_api.dart
      todos_tests/                      # BLoC 단위 테스트
        test/todos_overview_bloc_test.dart
        test/stats_bloc_test.dart
        test/edit_todo_bloc_test.dart
      todos_example/                    # 독립 실행 데모 앱
  flutist/
    flutist_gen.dart                    # ⚠ 자동 생성 — 수정 금지

# 의존성 규칙 (Microfeature):
# app                  → todos_implementation  ✅ composition root만 구현체 참조
# todos_implementation → todos_interface       ✅ interface에만 의존
# todos_testing        → todos_interface       ✅ interface에만 의존
# todos_tests          → impl + testing        ✅ 테스트 레이어만 허용
# todos_example        → impl + testing        ✅ example 레이어만 허용
# 다른 피처             → todos_testing         ✗ 프로덕션 코드에 Fake 유입 금지