Recipe 2 — Flutter Todos
시나리오
BLoC 공식 튜토리얼 Flutter Todos를 Flutist로 구현합니다. 할 일 추가·완료·삭제·필터링·통계 기능을 포함하며, SharedPreferences로 로컬 저장합니다. Microfeature Architecture를 적용해 TodosApi를 interface로 분리하고, 구현체(LocalStorageTodosApi)를 composition root에서만 주입합니다. FakeTodosApi로 BLoC 테스트를 완전히 격리합니다.
Todos 피처를 micro 타입으로 생성합니다. 5개 레이어(interface · implementation · testing · tests · example)가 자동으로 만들어집니다.
$ 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 생성
todos_interface는 모델·API 계약만 공개합니다. 구현체(todos_implementation)는 composition root(app)에서만 참조됩니다.
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, // ✅ 구현체 주입
],
),
],
);
todos_interface는 Todo 모델과 TodosApi abstract class만 공개합니다. 어떤 저장 방식도 알지 못합니다.
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];
}
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});
}
todos_implementation은 LocalStorageTodosApi(SharedPreferences 저장)와 세 BLoC(TodosOverviewBloc, EditTodoBloc, StatsBloc)를 포함합니다. composition root만 이 패키지를 참조할 수 있습니다.
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);
}
}
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,
),
);
}
}
todos_testing은 인메모리 FakeTodosApi를 제공합니다. 네트워크·디스크 없이 BLoC를 완전히 격리해서 테스트할 수 있습니다. 이 패키지는 _tests와 _example에서만 참조 가능합니다.
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);
}
}
todos_tests는 FakeTodosApi를 주입해 BLoC를 테스트합니다. SharedPreferences나 실제 파일 시스템이 전혀 없어도 됩니다.
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)))),
);
},
);
});
}
composition root(app)에서만 LocalStorageTodosApi를 인스턴스화해 BLoC에 주입합니다. 나머지 앱 코드는 TodosApi interface만 봅니다.
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)));
}
$ 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 유입 금지