오늘도 공부
Flutter Riverpod + Drift 완벽 가이드 본문
실전 프로젝트로 배우는 종합 튜토리얼
1. 소개 및 개념
1.1 왜 Riverpod + Drift를 함께 사용하는가?
현대 Flutter 앱 개발에서 로컬 데이터 영속성과 상태 관리는 핵심 요소입니다. Drift와 Riverpod의 조합은 다음과 같은 강력한 이점을 제공합니다:
Drift의 강점
- 타입 안전성: 컴파일 타임에 SQL 쿼리 검증
- 리액티브 쿼리: Stream 기반 실시간 데이터 업데이트
- 마이그레이션 관리: 체계적인 스키마 버전 관리
- 성능: SQLite의 빠른 성능과 최적화
- 크로스 플랫폼: 모바일, 웹, 데스크톱 지원
Riverpod의 강점
- 의존성 주입: 깔끔한 아키텍처 구현
- 컴파일 타임 안정성: Provider 타입 체크
- 테스트 용이성: Mock 객체로 쉬운 테스팅
- 상태 관리: 선언적이고 예측 가능한 상태 흐름
- 성능 최적화: 자동 캐싱과 메모이제이션
조합의 시너지
┌─────────────────────────────────────────────────┐
│ UI Layer │
│ (Widgets consuming Riverpod Providers) │
└───────────────────┬─────────────────────────────┘
│
┌───────────────────▼─────────────────────────────┐
│ State Management Layer │
│ (Riverpod Providers + Business Logic) │
└───────────────────┬─────────────────────────────┘
│
┌───────────────────▼─────────────────────────────┐
│ Repository Layer │
│ (Abstract interfaces + Implementation) │
└───────────────────┬─────────────────────────────┘
│
┌───────────────────▼─────────────────────────────┐
│ Data Source Layer │
│ (Drift Database + DAO Pattern) │
└─────────────────────────────────────────────────┘
1.2 프로젝트 아키텍처 설계 철학
우리가 구축할 아키텍처는 Clean Architecture와 Repository Pattern을 기반으로 합니다:
계층별 책임
1. Presentation Layer (UI)
- 사용자 인터랙션 처리
- Riverpod Provider를 통한 데이터 구독
- 상태 변화에 따른 UI 렌더링
2. Domain Layer (Business Logic)
- Use Case 정의
- 비즈니스 규칙 구현
- Repository 인터페이스 정의
3. Data Layer (데이터 접근)
- Drift 데이터베이스 구현
- Repository 구현체
- 데이터 변환 로직
데이터 흐름
User Action → Widget → Provider → Repository → Database
↑ ↓
└───────── Stream Update ───────────┘
1.3 우리가 만들 앱의 기능 명세
Todo 관리 시스템을 구축하며 다음 기능들을 구현합니다:
- 기본 CRUD 기능
- Todo 생성, 읽기, 수정, 삭제
- 카테고리 관리
- 태그 시스템
- 고급 기능
- 검색 및 필터링
- 정렬 (날짜, 우선순위, 완료 여부)
- 통계 및 대시보드
- 데이터 동기화
- 사용자 경험
- 오프라인 지원
- 실시간 업데이트
- 낙관적 UI 업데이트
- 에러 핸들링
2. 프로젝트 초기 설정
2.1 Flutter 프로젝트 생성
터미널에서 다음 명령어를 실행합니다:
# 프로젝트 생성
flutter create todo_drift_riverpod
# 프로젝트 디렉토리로 이동
cd todo_drift_riverpod
# 프로젝트 구조 확인
tree -L 2
2.2 의존성 설정
pubspec.yaml 파일을 다음과 같이 수정합니다:
name: todo_drift_riverpod
description: A comprehensive Flutter app using Drift and Riverpod
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# 상태 관리
flutter_riverpod: ^2.4.9
riverpod_annotation: ^2.3.3
# 데이터베이스
drift: ^2.14.1
sqlite3_flutter_libs: ^0.5.18
# 경로 관리
path_provider: ^2.1.1
path: ^1.8.3
# 유틸리티
intl: ^0.18.1 # 날짜 포맷팅
uuid: ^4.2.1 # 고유 ID 생성
collection: ^1.18.0
# UI
cupertino_icons: ^1.0.6
dev_dependencies:
flutter_test:
sdk: flutter
# 코드 생성
build_runner: ^2.4.7
riverpod_generator: ^2.3.9
drift_dev: ^2.14.1
# 린팅
flutter_lints: ^3.0.1
custom_lint: ^0.5.7
riverpod_lint: ^2.3.7
# 테스팅
mockito: ^5.4.4
flutter:
uses-material-design: true
의존성을 설치합니다:
flutter pub get
2.3 프로젝트 구조 설계
다음과 같은 폴더 구조를 생성합니다:
lib/
├── main.dart
├── core/
│ ├── constants/
│ │ ├── app_constants.dart
│ │ └── database_constants.dart
│ ├── utils/
│ │ ├── date_utils.dart
│ │ └── string_utils.dart
│ └── errors/
│ └── exceptions.dart
├── data/
│ ├── database/
│ │ ├── app_database.dart
│ │ ├── app_database.g.dart
│ │ ├── tables/
│ │ │ ├── todos_table.dart
│ │ │ ├── categories_table.dart
│ │ │ └── tags_table.dart
│ │ └── daos/
│ │ ├── todo_dao.dart
│ │ ├── category_dao.dart
│ │ └── tag_dao.dart
│ └── repositories/
│ ├── todo_repository_impl.dart
│ ├── category_repository_impl.dart
│ └── tag_repository_impl.dart
├── domain/
│ ├── models/
│ │ ├── todo_model.dart
│ │ ├── category_model.dart
│ │ └── tag_model.dart
│ ├── repositories/
│ │ ├── todo_repository.dart
│ │ ├── category_repository.dart
│ │ └── tag_repository.dart
│ └── usecases/
│ ├── get_todos_usecase.dart
│ ├── create_todo_usecase.dart
│ └── update_todo_usecase.dart
├── presentation/
│ ├── providers/
│ │ ├── database_provider.dart
│ │ ├── todo_provider.dart
│ │ ├── category_provider.dart
│ │ └── filter_provider.dart
│ ├── screens/
│ │ ├── home_screen.dart
│ │ ├── todo_detail_screen.dart
│ │ └── settings_screen.dart
│ └── widgets/
│ ├── todo_list_item.dart
│ ├── category_chip.dart
│ └── filter_bottom_sheet.dart
└── app.dart
이 구조를 생성하는 스크립트:
# 디렉토리 생성 스크립트 (setup_structure.sh)
mkdir -p lib/core/{constants,utils,errors}
mkdir -p lib/data/database/{tables,daos}
mkdir -p lib/data/repositories
mkdir -p lib/domain/{models,repositories,usecases}
mkdir -p lib/presentation/{providers,screens,widgets}
# 기본 파일 생성
touch lib/core/constants/app_constants.dart
touch lib/core/constants/database_constants.dart
touch lib/core/utils/date_utils.dart
touch lib/core/utils/string_utils.dart
touch lib/core/errors/exceptions.dart
2.4 분석 옵션 설정
analysis_options.yaml 파일을 생성합니다:
include: package:flutter_lints/flutter.yaml
analyzer:
plugins:
- custom_lint
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
errors:
invalid_annotation_target: ignore
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
linter:
rules:
- always_declare_return_types
- always_put_required_named_parameters_first
- annotate_overrides
- avoid_print
- avoid_unnecessary_containers
- avoid_void_async
- await_only_futures
- camel_case_types
- cancel_subscriptions
- constant_identifier_names
- control_flow_in_finally
- empty_catches
- empty_constructor_bodies
- library_private_types_in_public_api
- no_duplicate_case_values
- prefer_const_constructors
- prefer_const_constructors_in_immutables
- prefer_final_fields
- prefer_if_null_operators
- prefer_is_empty
- prefer_is_not_empty
- prefer_single_quotes
- sort_child_properties_last
- unawaited_futures
- unnecessary_await_in_return
- unnecessary_null_in_if_null_operators
- use_key_in_widget_constructors
3. Drift 데이터베이스 구축
3.1 데이터베이스 상수 정의
먼저 데이터베이스 관련 상수를 정의합니다.
lib/core/constants/database_constants.dart:
/// 데이터베이스 관련 상수 정의
class DatabaseConstants {
DatabaseConstants._();
/// 데이터베이스 파일 이름
static const String databaseName = 'todo_app.db';
/// 현재 스키마 버전
static const int schemaVersion = 1;
/// 테이블 이름
static const String todosTable = 'todos';
static const String categoriesTable = 'categories';
static const String tagsTable = 'tags';
static const String todoTagsTable = 'todo_tags';
/// 기본 카테고리
static const String defaultCategoryName = '일반';
static const String workCategoryName = '업무';
static const String personalCategoryName = '개인';
static const String shoppingCategoryName = '쇼핑';
}
lib/core/constants/app_constants.dart:
/// 앱 전역 상수
class AppConstants {
AppConstants._();
/// 날짜 포맷
static const String dateFormat = 'yyyy-MM-dd';
static const String timeFormat = 'HH:mm';
static const String dateTimeFormat = 'yyyy-MM-dd HH:mm';
/// 우선순위
static const int priorityLow = 1;
static const int priorityMedium = 2;
static const int priorityHigh = 3;
/// 정렬 옵션
static const String sortByDate = 'date';
static const String sortByPriority = 'priority';
static const String sortByTitle = 'title';
/// 필터 옵션
static const String filterAll = 'all';
static const String filterActive = 'active';
static const String filterCompleted = 'completed';
}
3.2 테이블 정의
Todos 테이블
lib/data/database/tables/todos_table.dart:
import 'package:drift/drift.dart';
/// Todos 테이블 정의
@DataClassName('TodoEntity')
class Todos extends Table {
/// 기본 키 (자동 증가)
IntColumn get id => integer().autoIncrement()();
/// Todo 제목
TextColumn get title => text().withLength(min: 1, max: 200)();
/// Todo 내용 (선택사항)
TextColumn get description => text().nullable()();
/// 완료 여부
BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
/// 우선순위 (1: 낮음, 2: 보통, 3: 높음)
IntColumn get priority => integer().withDefault(const Constant(2))();
/// 카테고리 ID (외래 키)
IntColumn get categoryId => integer().nullable().references(Categories, #id)();
/// 마감일 (선택사항)
DateTimeColumn get dueDate => dateTime().nullable()();
/// 생성일
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
/// 수정일
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
/// 완료일 (완료된 경우)
DateTimeColumn get completedAt => dateTime().nullable()();
/// 색상 코드 (16진수 문자열)
TextColumn get color => text().nullable()();
/// 알림 설정 여부
BoolColumn get hasReminder => boolean().withDefault(const Constant(false))();
/// 알림 시간
DateTimeColumn get reminderTime => dateTime().nullable()();
/// 반복 설정 (none, daily, weekly, monthly)
TextColumn get repeatType => text().withDefault(const Constant('none'))();
/// 메모
TextColumn get notes => text().nullable()();
}
Categories 테이블
lib/data/database/tables/categories_table.dart:
import 'package:drift/drift.dart';
/// 카테고리 테이블 정의
@DataClassName('CategoryEntity')
class Categories extends Table {
/// 기본 키
IntColumn get id => integer().autoIncrement()();
/// 카테고리 이름
TextColumn get name => text().withLength(min: 1, max: 50)();
/// 카테고리 색상 (16진수)
TextColumn get color => text().withLength(min: 6, max: 8)();
/// 아이콘 코드
TextColumn get iconCode => text().nullable()();
/// 생성일
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
/// 표시 순서
IntColumn get displayOrder => integer().withDefault(const Constant(0))();
/// 삭제 여부 (소프트 삭제)
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
}
Tags 테이블
lib/data/database/tables/tags_table.dart:
import 'package:drift/drift.dart';
/// 태그 테이블 정의
@DataClassName('TagEntity')
class Tags extends Table {
/// 기본 키
IntColumn get id => integer().autoIncrement()();
/// 태그 이름
TextColumn get name => text().withLength(min: 1, max: 30)();
/// 태그 색상
TextColumn get color => text().withLength(min: 6, max: 8)();
/// 생성일
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
TodoTags 중간 테이블 (다대다 관계)
lib/data/database/tables/todo_tags_table.dart:
import 'package:drift/drift.dart';
import 'todos_table.dart';
import 'tags_table.dart';
/// Todo와 Tag의 다대다 관계를 위한 중간 테이블
@DataClassName('TodoTagEntity')
class TodoTags extends Table {
/// Todo ID
IntColumn get todoId => integer().references(Todos, #id, onDelete: KeyAction.cascade)();
/// Tag ID
IntColumn get tagId => integer().references(Tags, #id, onDelete: KeyAction.cascade)();
/// 생성일
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {todoId, tagId};
}
3.3 데이터베이스 클래스 생성
lib/data/database/app_database.dart:
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import '../../core/constants/database_constants.dart';
import 'tables/todos_table.dart';
import 'tables/categories_table.dart';
import 'tables/tags_table.dart';
import 'tables/todo_tags_table.dart';
part 'app_database.g.dart';
/// 메인 데이터베이스 클래스
@DriftDatabase(tables: [
Todos,
Categories,
Tags,
TodoTags,
])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
/// 테스트용 생성자
AppDatabase.forTesting(QueryExecutor executor) : super(executor);
@override
int get schemaVersion => DatabaseConstants.schemaVersion;
/// 데이터베이스 마이그레이션 전략
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
await _insertDefaultData();
},
onUpgrade: (Migrator m, int from, int to) async {
// 버전별 마이그레이션 로직
// if (from < 2) {
// await m.addColumn(todos, todos.newColumn);
// }
},
beforeOpen: (details) async {
// 외래 키 제약 조건 활성화
await customStatement('PRAGMA foreign_keys = ON');
if (details.wasCreated) {
print('데이터베이스가 생성되었습니다.');
}
},
);
}
/// 기본 데이터 삽입
Future<void> _insertDefaultData() async {
// 기본 카테고리 추가
await into(categories).insert(
CategoriesCompanion.insert(
name: DatabaseConstants.defaultCategoryName,
color: 'FF9E9E9E',
displayOrder: const Value(0),
),
);
await into(categories).insert(
CategoriesCompanion.insert(
name: DatabaseConstants.workCategoryName,
color: 'FF2196F3',
displayOrder: const Value(1),
),
);
await into(categories).insert(
CategoriesCompanion.insert(
name: DatabaseConstants.personalCategoryName,
color: 'FF4CAF50',
displayOrder: const Value(2),
),
);
await into(categories).insert(
CategoriesCompanion.insert(
name: DatabaseConstants.shoppingCategoryName,
color: 'FFFF9800',
displayOrder: const Value(3),
),
);
}
/// 데이터베이스 연결 해제
Future<void> close() {
return super.close();
}
}
/// 데이터베이스 파일 연결 설정
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, DatabaseConstants.databaseName));
return NativeDatabase.createInBackground(file, logStatements: true);
});
}
3.4 DAO (Data Access Object) 패턴 구현
DAO 패턴을 사용하여 각 테이블의 데이터 접근 로직을 캡슐화합니다.
Todo DAO
lib/data/database/daos/todo_dao.dart:
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/todos_table.dart';
import '../tables/categories_table.dart';
import '../tables/tags_table.dart';
import '../tables/todo_tags_table.dart';
part 'todo_dao.g.dart';
/// Todo 데이터 접근 객체
@DriftAccessor(tables: [Todos, Categories, Tags, TodoTags])
class TodoDao extends DatabaseAccessor<AppDatabase> with _$TodoDaoMixin {
TodoDao(super.db);
// ==================== 기본 CRUD ====================
/// 모든 Todo 조회 (Stream)
Stream<List<TodoEntity>> watchAllTodos() {
return select(todos).watch();
}
/// 모든 Todo 조회 (Future)
Future<List<TodoEntity>> getAllTodos() {
return select(todos).get();
}
/// ID로 Todo 조회
Future<TodoEntity?> getTodoById(int id) {
return (select(todos)..where((t) => t.id.equals(id))).getSingleOrNull();
}
/// ID로 Todo 조회 (Stream)
Stream<TodoEntity?> watchTodoById(int id) {
return (select(todos)..where((t) => t.id.equals(id))).watchSingleOrNull();
}
/// Todo 생성
Future<int> createTodo(TodosCompanion todo) {
return into(todos).insert(todo);
}
/// Todo 업데이트
Future<bool> updateTodo(TodoEntity todo) {
return update(todos).replace(todo);
}
/// Todo 삭제
Future<int> deleteTodo(int id) {
return (delete(todos)..where((t) => t.id.equals(id))).go();
}
/// Todo 엔티티 삭제
Future<int> deleteTodoEntity(TodoEntity todo) {
return delete(todos).delete(todo);
}
// ==================== 필터링 쿼리 ====================
/// 완료된 Todo 조회
Stream<List<TodoEntity>> watchCompletedTodos() {
return (select(todos)
..where((t) => t.isCompleted.equals(true))
..orderBy([
(t) => OrderingTerm(expression: t.completedAt, mode: OrderingMode.desc),
]))
.watch();
}
/// 미완료 Todo 조회
Stream<List<TodoEntity>> watchActiveTodos() {
return (select(todos)
..where((t) => t.isCompleted.equals(false))
..orderBy([
(t) => OrderingTerm(expression: t.priority, mode: OrderingMode.desc),
(t) => OrderingTerm(expression: t.dueDate),
]))
.watch();
}
/// 카테고리별 Todo 조회
Stream<List<TodoEntity>> watchTodosByCategory(int categoryId) {
return (select(todos)
..where((t) => t.categoryId.equals(categoryId))
..orderBy([
(t) => OrderingTerm(expression: t.createdAt, mode: OrderingMode.desc),
]))
.watch();
}
/// 우선순위별 Todo 조회
Stream<List<TodoEntity>> watchTodosByPriority(int priority) {
return (select(todos)
..where((t) => t.priority.equals(priority))
..orderBy([
(t) => OrderingTerm(expression: t.dueDate),
]))
.watch();
}
/// 오늘 마감인 Todo 조회
Stream<List<TodoEntity>> watchTodosDueToday() {
final now = DateTime.now();
final startOfDay = DateTime(now.year, now.month, now.day);
final endOfDay = startOfDay.add(const Duration(days: 1));
return (select(todos)
..where((t) =>
t.dueDate.isBiggerOrEqualValue(startOfDay) &
t.dueDate.isSmallerThanValue(endOfDay))
..orderBy([
(t) => OrderingTerm(expression: t.dueDate),
]))
.watch();
}
/// 기한이 지난 Todo 조회
Stream<List<TodoEntity>> watchOverdueTodos() {
final now = DateTime.now();
return (select(todos)
..where((t) =>
t.isCompleted.equals(false) &
t.dueDate.isSmallerThanValue(now))
..orderBy([
(t) => OrderingTerm(expression: t.dueDate),
]))
.watch();
}
// ==================== 검색 쿼리 ====================
/// 제목으로 Todo 검색
Stream<List<TodoEntity>> searchTodosByTitle(String query) {
final searchPattern = '%$query%';
return (select(todos)
..where((t) => t.title.like(searchPattern))
..orderBy([
(t) => OrderingTerm(expression: t.createdAt, mode: OrderingMode.desc),
]))
.watch();
}
/// 제목 또는 설명으로 Todo 검색
Stream<List<TodoEntity>> searchTodos(String query) {
final searchPattern = '%$query%';
return (select(todos)
..where((t) =>
t.title.like(searchPattern) |
t.description.like(searchPattern))
..orderBy([
(t) => OrderingTerm(expression: t.createdAt, mode: OrderingMode.desc),
]))
.watch();
}
// ==================== 복잡한 쿼리 ====================
/// 카테고리 정보와 함께 Todo 조회
Stream<List<TodoWithCategory>> watchTodosWithCategory() {
final query = select(todos).join([
leftOuterJoin(categories, categories.id.equalsExp(todos.categoryId)),
]);
return query.watch().map((rows) {
return rows.map((row) {
return TodoWithCategory(
todo: row.readTable(todos),
category: row.readTableOrNull(categories),
);
}).toList();
});
}
/// 특정 Todo의 태그 조회
Stream<List<TagEntity>> watchTagsForTodo(int todoId) {
final query = select(tags).join([
innerJoin(todoTags, todoTags.tagId.equalsExp(tags.id)),
])..where(todoTags.todoId.equals(todoId));
return query.watch().map((rows) {
return rows.map((row) => row.readTable(tags)).toList();
});
}
/// 태그와 함께 모든 Todo 조회
Future<List<TodoWithTags>> getTodosWithTags() async {
final allTodos = await getAllTodos();
final result = <TodoWithTags>[];
for (final todo in allTodos) {
final tags = await (select(this.tags).join([
innerJoin(todoTags, todoTags.tagId.equalsExp(this.tags.id)),
])..where(todoTags.todoId.equals(todo.id)))
.get()
.then((rows) => rows.map((row) => row.readTable(this.tags)).toList());
result.add(TodoWithTags(todo: todo, tags: tags));
}
return result;
}
// ==================== 통계 쿼리 ====================
/// 전체 Todo 개수
Future<int> getTotalTodoCount() async {
final count = countAll();
final query = selectOnly(todos)..addColumns([count]);
final result = await query.getSingle();
return result.read(count) ?? 0;
}
/// 완료된 Todo 개수
Future<int> getCompletedTodoCount() async {
final count = countAll();
final query = selectOnly(todos)
..addColumns([count])
..where(todos.isCompleted.equals(true));
final result = await query.getSingle();
return result.read(count) ?? 0;
}
/// 카테고리별 Todo 개수
Future<Map<int, int>> getTodoCountByCategory() async {
final count = countAll();
final query = selectOnly(todos)
..addColumns([todos.categoryId, count])
..where(todos.categoryId.isNotNull())
..groupBy([todos.categoryId]);
final results = await query.get();
return {
for (final row in results)
row.read(todos.categoryId)!: row.read(count) ?? 0
};
}
/// 우선순위별 Todo 개수
Future<Map<int, int>> getTodoCountByPriority() async {
final count = countAll();
final query = selectOnly(todos)
..addColumns([todos.priority, count])
..groupBy([todos.priority]);
final results = await query.get();
return {
for (final row in results)
row.read(todos.priority)!: row.read(count) ?? 0
};
}
// ==================== 벌크 작업 ====================
/// 여러 Todo 완료 상태 변경
Future<void> updateTodosCompletionStatus(List<int> todoIds, bool isCompleted) {
return transaction(() async {
for (final id in todoIds) {
await (update(todos)..where((t) => t.id.equals(id))).write(
TodosCompanion(
isCompleted: Value(isCompleted),
completedAt: Value(isCompleted ? DateTime.now() : null),
updatedAt: Value(DateTime.now()),
),
);
}
});
}
/// 완료된 Todo 모두 삭제
Future<int> deleteAllCompletedTodos() {
return (delete(todos)..where((t) => t.isCompleted.equals(true))).go();
}
/// 카테고리의 모든 Todo 삭제
Future<int> deleteTodosByCategory(int categoryId) {
return (delete(todos)..where((t) => t.categoryId.equals(categoryId))).go();
}
// ==================== Todo-Tag 관계 관리 ====================
/// Todo에 태그 추가
Future<void> addTagToTodo(int todoId, int tagId) {
return into(todoTags).insert(
TodoTagsCompanion.insert(
todoId: todoId,
tagId: tagId,
),
);
}
/// Todo에서 태그 제거
Future<void> removeTagFromTodo(int todoId, int tagId) {
return (delete(todoTags)
..where((tt) =>
tt.todoId.equals(todoId) &
tt.tagId.equals(tagId)))
.go();
}
/// Todo의 모든 태그 제거
Future<void> removeAllTagsFromTodo(int todoId) {
return (delete(todoTags)..where((tt) => tt.todoId.equals(todoId))).go();
}
/// Todo의 태그 일괄 업데이트
Future<void> updateTodoTags(int todoId, List<int> tagIds) {
return transaction(() async {
// 기존 태그 모두 제거
await removeAllTagsFromTodo(todoId);
// 새 태그 추가
for (final tagId in tagIds) {
await addTagToTodo(todoId, tagId);
}
});
}
// ==================== 커스텀 정렬 ====================
/// 정렬 옵션에 따른 Todo 조회
Stream<List<TodoEntity>> watchTodosSorted({
required String sortBy,
bool ascending = true,
}) {
final query = select(todos);
switch (sortBy) {
case 'title':
query.orderBy([
(t) => OrderingTerm(
expression: t.title,
mode: ascending ? OrderingMode.asc : OrderingMode.desc,
),
]);
break;
case 'priority':
query.orderBy([
(t) => OrderingTerm(
expression: t.priority,
mode: ascending ? OrderingMode.asc : OrderingMode.desc,
),
]);
break;
case 'dueDate':
query.orderBy([
(t) => OrderingTerm(
expression: t.dueDate,
mode: ascending ? OrderingMode.asc : OrderingMode.desc,
),
]);
break;
case 'createdAt':
default:
query.orderBy([
(t) => OrderingTerm(
expression: t.createdAt,
mode: ascending ? OrderingMode.asc : OrderingMode.desc,
),
]);
}
return query.watch();
}
}
/// Todo와 Category를 함께 반환하는 클래스
class TodoWithCategory {
final TodoEntity todo;
final CategoryEntity? category;
TodoWithCategory({
required this.todo,
this.category,
});
}
/// Todo와 Tags를 함께 반환하는 클래스
class TodoWithTags {
final TodoEntity todo;
final List<TagEntity> tags;
TodoWithTags({
required this.todo,
required this.tags,
});
}
Category DAO
lib/data/database/daos/category_dao.dart:
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/categories_table.dart';
import '../tables/todos_table.dart';
part 'category_dao.g.dart';
/// 카테고리 데이터 접근 객체
@DriftAccessor(tables: [Categories, Todos])
class CategoryDao extends DatabaseAccessor<AppDatabase> with _$CategoryDaoMixin {
CategoryDao(super.db);
// ==================== 기본 CRUD ====================
/// 모든 카테고리 조회 (Stream)
Stream<List<CategoryEntity>> watchAllCategories() {
return (select(categories)
..where((c) => c.isDeleted.equals(false))
..orderBy([
(c) => OrderingTerm(expression: c.displayOrder),
]))
.watch();
}
/// 모든 카테고리 조회 (Future)
Future<List<CategoryEntity>> getAllCategories() {
return (select(categories)
..where((c) => c.isDeleted.equals(false))
..orderBy([
(c) => OrderingTerm(expression: c.displayOrder),
]))
.get();
}
/// ID로 카테고리 조회
Future<CategoryEntity?> getCategoryById(int id) {
return (select(categories)..where((c) => c.id.equals(id))).getSingleOrNull();
}
/// ID로 카테고리 조회 (Stream)
Stream<CategoryEntity?> watchCategoryById(int id) {
return (select(categories)..where((c) => c.id.equals(id))).watchSingleOrNull();
}
/// 카테고리 생성
Future<int> createCategory(CategoriesCompanion category) {
return into(categories).insert(category);
}
/// 카테고리 업데이트
Future<bool> updateCategory(CategoryEntity category) {
return update(categories).replace(category);
}
/// 카테고리 삭제 (소프트 삭제)
Future<void> deleteCategory(int id) {
return (update(categories)..where((c) => c.id.equals(id)))
.write(const CategoriesCompanion(isDeleted: Value(true)));
}
/// 카테고리 영구 삭제
Future<int> permanentlyDeleteCategory(int id) {
return (delete(categories)..where((c) => c.id.equals(id))).go();
}
// ==================== 카테고리 통계 ====================
/// 카테고리별 Todo 개수 조회
Future<Map<CategoryEntity, int>> getCategoriesWithTodoCount() async {
final allCategories = await getAllCategories();
final result = <CategoryEntity, int>{};
for (final category in allCategories) {
final count = countAll();
final query = selectOnly(todos)
..addColumns([count])
..where(todos.categoryId.equals(category.id));
final todoCount = await query.getSingle().then((row) => row.read(count) ?? 0);
result[category] = todoCount;
}
return result;
}
/// 카테고리별 Todo 개수 조회 (Stream)
Stream<List<CategoryWithCount>> watchCategoriesWithTodoCount() {
final query = select(categories).join([
leftOuterJoin(todos, todos.categoryId.equalsExp(categories.id)),
])
..where(categories.isDeleted.equals(false))
..groupBy([categories.id]);
return query.watch().map((rows) {
return rows.map((row) {
final category = row.readTable(categories);
final todoCount = row.read(todos.id.count()) ?? 0;
return CategoryWithCount(category: category, todoCount: todoCount);
}).toList();
});
}
// ==================== 카테고리 정렬 ====================
/// 카테고리 표시 순서 변경
Future<void> updateCategoryOrder(int categoryId, int newOrder) {
return (update(categories)..where((c) => c.id.equals(categoryId)))
.write(CategoriesCompanion(displayOrder: Value(newOrder)));
}
/// 여러 카테고리의 순서 일괄 변경
Future<void> reorderCategories(Map<int, int> categoryOrders) {
return transaction(() async {
for (final entry in categoryOrders.entries) {
await updateCategoryOrder(entry.key, entry.value);
}
});
}
// ==================== 검색 ====================
/// 이름으로 카테고리 검색
Future<List<CategoryEntity>> searchCategoriesByName(String query) {
final searchPattern = '%$query%';
return (select(categories)
..where((c) =>
c.name.like(searchPattern) &
c.isDeleted.equals(false)))
.get();
}
/// 카테고리 이름 중복 확인
Future<bool> isCategoryNameExists(String name, {int? excludeId}) async {
final query = select(categories)
..where((c) =>
c.name.equals(name) &
c.isDeleted.equals(false));
if (excludeId != null) {
query.where((c) => c.id.equals(excludeId).not());
}
final result = await query.getSingleOrNull();
return result != null;
}
}
/// 카테고리와 Todo 개수를 함께 반환하는 클래스
class CategoryWithCount {
final CategoryEntity category;
final int todoCount;
CategoryWithCount({
required this.category,
required this.todoCount,
});
}
Tag DAO
lib/data/database/daos/tag_dao.dart:
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/tags_table.dart';
import '../tables/todo_tags_table.dart';
import '../tables/todos_table.dart';
part 'tag_dao.g.dart';
/// 태그 데이터 접근 객체
@DriftAccessor(tables: [Tags, TodoTags, Todos])
class TagDao extends DatabaseAccessor<AppDatabase> with _$TagDaoMixin {
TagDao(super.db);
// ==================== 기본 CRUD ====================
/// 모든 태그 조회 (Stream)
Stream<List<TagEntity>> watchAllTags() {
return (select(tags)
..orderBy([
(t) => OrderingTerm(expression: t.name),
]))
.watch();
}
/// 모든 태그 조회 (Future)
Future<List<TagEntity>> getAllTags() {
return (select(tags)
..orderBy([
(t) => OrderingTerm(expression: t.name),
]))
.get();
}
/// ID로 태그 조회
Future<TagEntity?> getTagById(int id) {
return (select(tags)..where((t) => t.id.equals(id))).getSingleOrNull();
}
/// 태그 생성
Future<int> createTag(TagsCompanion tag) {
return into(tags).insert(tag);
}
/// 태그 업데이트
Future<bool> updateTag(TagEntity tag) {
return update(tags).replace(tag);
}
/// 태그 삭제
Future<int> deleteTag(int id) async {
return transaction(() async {
// 먼저 관련된 TodoTags 삭제
await (delete(todoTags)..where((tt) => tt.tagId.equals(id))).go();
// 그 다음 태그 삭제
return await (delete(tags)..where((t) => t.id.equals(id))).go();
});
}
// ==================== 태그 통계 ====================
/// 태그별 사용 횟수 조회
Future<Map<TagEntity, int>> getTagsWithUsageCount() async {
final allTags = await getAllTags();
final result = <TagEntity, int>{};
for (final tag in allTags) {
final count = countAll();
final query = selectOnly(todoTags)
..addColumns([count])
..where(todoTags.tagId.equals(tag.id));
final usageCount = await query.getSingle().then((row) => row.read(count) ?? 0);
result[tag] = usageCount;
}
return result;
}
/// 태그별 사용 횟수 조회 (Stream)
Stream<List<TagWithCount>> watchTagsWithUsageCount() {
final query = select(tags).join([
leftOuterJoin(todoTags, todoTags.tagId.equalsExp(tags.id)),
])..groupBy([tags.id]);
return query.watch().map((rows) {
return rows.map((row) {
final tag = row.readTable(tags);
final usageCount = row.read(todoTags.todoId.count()) ?? 0;
return TagWithCount(tag: tag, usageCount: usageCount);
}).toList();
});
}
/// 가장 많이 사용된 태그 조회
Future<List<TagWithCount>> getMostUsedTags({int limit = 10}) async {
final tagsWithCount = await getTagsWithUsageCount();
final sortedList = tagsWithCount.entries
.map((e) => TagWithCount(tag: e.key, usageCount: e.value))
.toList()
..sort((a, b) => b.usageCount.compareTo(a.usageCount));
return sortedList.take(limit).toList();
}
// ==================== 검색 ====================
/// 이름으로 태그 검색
Future<List<TagEntity>> searchTagsByName(String query) {
final searchPattern = '%$query%';
return (select(tags)..where((t) => t.name.like(searchPattern))).get();
}
/// 태그 이름 중복 확인
Future<bool> isTagNameExists(String name, {int? excludeId}) async {
final query = select(tags)..where((t) => t.name.equals(name));
if (excludeId != null) {
query.where((t) => t.id.equals(excludeId).not());
}
final result = await query.getSingleOrNull();
return result != null;
}
/// 사용되지 않는 태그 조회
Future<List<TagEntity>> getUnusedTags() async {
final allTags = await getAllTags();
final usedTagIds = await selectOnly(todoTags)
.get()
.then((rows) => rows.map((row) => row.read(todoTags.tagId)!).toSet());
return allTags.where((tag) => !usedTagIds.contains(tag.id)).toList();
}
/// 사용되지 않는 태그 삭제
Future<void> deleteUnusedTags() async {
final unusedTags = await getUnusedTags();
await transaction(() async {
for (final tag in unusedTags) {
await deleteTag(tag.id);
}
});
}
// ==================== Todo별 태그 조회 ====================
/// 특정 Todo의 태그 조회
Future<List<TagEntity>> getTagsForTodo(int todoId) {
final query = select(tags).join([
innerJoin(todoTags, todoTags.tagId.equalsExp(tags.id)),
])..where(todoTags.todoId.equals(todoId));
return query.get().then((rows) => rows.map((row) => row.readTable(tags)).toList());
}
/// 특정 Todo의 태그 조회 (Stream)
Stream<List<TagEntity>> watchTagsForTodo(int todoId) {
final query = select(tags).join([
innerJoin(todoTags, todoTags.tagId.equalsExp(tags.id)),
])..where(todoTags.todoId.equals(todoId));
return query.watch().map((rows) => rows.map((row) => row.readTable(tags)).toList());
}
/// 특정 태그를 가진 Todo 조회
Future<List<TodoEntity>> getTodosByTag(int tagId) {
final query = select(todos).join([
innerJoin(todoTags, todoTags.todoId.equalsExp(todos.id)),
])..where(todoTags.tagId.equals(tagId));
return query.get().then((rows) => rows.map((row) => row.readTable(todos)).toList());
}
/// 특정 태그를 가진 Todo 조회 (Stream)
Stream<List<TodoEntity>> watchTodosByTag(int tagId) {
final query = select(todos).join([
innerJoin(todoTags, todoTags.todoId.equalsExp(todos.id)),
])..where(todoTags.tagId.equals(tagId));
return query.watch().map((rows) => rows.map((row) => row.readTable(todos)).toList());
}
}
/// 태그와 사용 횟수를 함께 반환하는 클래스
class TagWithCount {
final TagEntity tag;
final int usageCount;
TagWithCount({
required this.tag,
required this.usageCount,
});
}
3.5 코드 생성 실행
이제 Drift가 필요한 코드를 생성하도록 합니다:
# 코드 생성
flutter pub run build_runner build --delete-conflicting-outputs
# 또는 watch 모드로 실행 (파일 변경 시 자동 생성)
flutter pub run build_runner watch --delete-conflicting-outputs
생성된 파일들:
- app_database.g.dart
- todo_dao.g.dart
- category_dao.g.dart
- tag_dao.g.dart
4. Riverpod 프로바이더 설계
이제 Riverpod을 사용하여 상태 관리 레이어를 구축합니다.
4.1 데이터베이스 프로바이더
lib/presentation/providers/database_provider.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/database/app_database.dart';
import '../../data/database/daos/todo_dao.dart';
import '../../data/database/daos/category_dao.dart';
import '../../data/database/daos/tag_dao.dart';
/// 앱 데이터베이스 인스턴스 프로바이더
final appDatabaseProvider = Provider<AppDatabase>((ref) {
final database = AppDatabase();
// Provider가 dispose될 때 데이터베이스 연결 종료
ref.onDispose(() {
database.close();
});
return database;
});
/// Todo DAO 프로바이더
final todoDaoProvider = Provider<TodoDao>((ref) {
final database = ref.watch(appDatabaseProvider);
return TodoDao(database);
});
/// Category DAO 프로바이더
final categoryDaoProvider = Provider<CategoryDao>((ref) {
final database = ref.watch(appDatabaseProvider);
return CategoryDao(database);
});
/// Tag DAO 프로바이더
final tagDaoProvider = Provider<TagDao>((ref) {
final database = ref.watch(appDatabaseProvider);
return TagDao(database);
});
4.2 Repository 인터페이스 정의
먼저 도메인 레이어에 Repository 인터페이스를 정의합니다.
lib/domain/repositories/todo_repository.dart:
import '../../data/database/app_database.dart';
import '../../data/database/daos/todo_dao.dart';
/// Todo Repository 인터페이스
abstract class TodoRepository {
// ==================== 기본 CRUD ====================
Stream<List<TodoEntity>> watchAllTodos();
Future<List<TodoEntity>> getAllTodos();
Future<TodoEntity?> getTodoById(int id);
Stream<TodoEntity?> watchTodoById(int id);
Future<int> createTodo(TodosCompanion todo);
Future<bool> updateTodo(TodoEntity todo);
Future<int> deleteTodo(int id);
// ==================== 필터링 ====================
Stream<List<TodoEntity>> watchCompletedTodos();
Stream<List<TodoEntity>> watchActiveTodos();
Stream<List<TodoEntity>> watchTodosByCategory(int categoryId);
Stream<List<TodoEntity>> watchTodosByPriority(int priority);
Stream<List<TodoEntity>> watchTodosDueToday();
Stream<List<TodoEntity>> watchOverdueTodos();
// ==================== 검색 ====================
Stream<List<TodoEntity>> searchTodos(String query);
// ==================== 통계 ====================
Future<int> getTotalTodoCount();
Future<int> getCompletedTodoCount();
Future<Map<int, int>> getTodoCountByCategory();
Future<Map<int, int>> getTodoCountByPriority();
// ==================== 벌크 작업 ====================
Future<void> updateTodosCompletionStatus(List<int> todoIds, bool isCompleted);
Future<int> deleteAllCompletedTodos();
// ==================== Todo-Tag 관계 ====================
Future<void> addTagToTodo(int todoId, int tagId);
Future<void> removeTagFromTodo(int todoId, int tagId);
Future<void> updateTodoTags(int todoId, List<int> tagIds);
Stream<List<TagEntity>> watchTagsForTodo(int todoId);
// ==================== 복잡한 쿼리 ====================
Stream<List<TodoWithCategory>> watchTodosWithCategory();
Future<List<TodoWithTags>> getTodosWithTags();
// ==================== 정렬 ====================
Stream<List<TodoEntity>> watchTodosSorted({
required String sortBy,
bool ascending,
});
}
lib/domain/repositories/category_repository.dart:
import '../../data/database/app_database.dart';
import '../../data/database/daos/category_dao.dart';
/// Category Repository 인터페이스
abstract class CategoryRepository {
Stream<List<CategoryEntity>> watchAllCategories();
Future<List<CategoryEntity>> getAllCategories();
Future<CategoryEntity?> getCategoryById(int id);
Stream<CategoryEntity?> watchCategoryById(int id);
Future<int> createCategory(CategoriesCompanion category);
Future<bool> updateCategory(CategoryEntity category);
Future<void> deleteCategory(int id);
Future<void> updateCategoryOrder(int categoryId, int newOrder);
Future<void> reorderCategories(Map<int, int> categoryOrders);
Future<bool> isCategoryNameExists(String name, {int? excludeId});
Stream<List<CategoryWithCount>> watchCategoriesWithTodoCount();
}
lib/domain/repositories/tag_repository.dart:
import '../../data/database/app_database.dart';
import '../../data/database/daos/tag_dao.dart';
/// Tag Repository 인터페이스
abstract class TagRepository {
Stream<List<TagEntity>> watchAllTags();
Future<List<TagEntity>> getAllTags();
Future<TagEntity?> getTagById(int id);
Future<int> createTag(TagsCompanion tag);
Future<bool> updateTag(TagEntity tag);
Future<int> deleteTag(int id);
Future<List<TagEntity>> searchTagsByName(String query);
Future<bool> isTagNameExists(String name, {int? excludeId});
Future<List<TagEntity>> getUnusedTags();
Future<void> deleteUnusedTags();
Stream<List<TagWithCount>> watchTagsWithUsageCount();
Future<List<TagWithCount>> getMostUsedTags({int limit});
Future<List<TagEntity>> getTagsForTodo(int todoId);
Stream<List<TagEntity>> watchTagsForTodo(int todoId);
Stream<List<TodoEntity>> watchTodosByTag(int tagId);
}
4.3 Repository 구현
lib/data/repositories/todo_repository_impl.dart:
import '../../domain/repositories/todo_repository.dart';
import '../database/app_database.dart';
import '../database/daos/todo_dao.dart';
/// Todo Repository 구현
class TodoRepositoryImpl implements TodoRepository {
final TodoDao _todoDao;
TodoRepositoryImpl(this._todoDao);
@override
Stream<List<TodoEntity>> watchAllTodos() => _todoDao.watchAllTodos();
@override
Future<List<TodoEntity>> getAllTodos() => _todoDao.getAllTodos();
@override
Future<TodoEntity?> getTodoById(int id) => _todoDao.getTodoById(id);
@override
Stream<TodoEntity?> watchTodoById(int id) => _todoDao.watchTodoById(id);
@override
Future<int> createTodo(TodosCompanion todo) => _todoDao.createTodo(todo);
@override
Future<bool> updateTodo(TodoEntity todo) => _todoDao.updateTodo(todo);
@override
Future<int> deleteTodo(int id) => _todoDao.deleteTodo(id);
@override
Stream<List<TodoEntity>> watchCompletedTodos() => _todoDao.watchCompletedTodos();
@override
Stream<List<TodoEntity>> watchActiveTodos() => _todoDao.watchActiveTodos();
@override
Stream<List<TodoEntity>> watchTodosByCategory(int categoryId) =>
_todoDao.watchTodosByCategory(categoryId);
@override
Stream<List<TodoEntity>> watchTodosByPriority(int priority) =>
_todoDao.watchTodosByPriority(priority);
@override
Stream<List<TodoEntity>> watchTodosDueToday() => _todoDao.watchTodosDueToday();
@override
Stream<List<TodoEntity>> watchOverdueTodos() => _todoDao.watchOverdueTodos();
@override
Stream<List<TodoEntity>> searchTodos(String query) => _todoDao.searchTodos(query);
@override
Future<int> getTotalTodoCount() => _todoDao.getTotalTodoCount();
@override
Future<int> getCompletedTodoCount() => _todoDao.getCompletedTodoCount();
@override
Future<Map<int, int>> getTodoCountByCategory() => _todoDao.getTodoCountByCategory();
@override
Future<Map<int, int>> getTodoCountByPriority() => _todoDao.getTodoCountByPriority();
@override
Future<void> updateTodosCompletionStatus(List<int> todoIds, bool isCompleted) =>
_todoDao.updateTodosCompletionStatus(todoIds, isCompleted);
@override
Future<int> deleteAllCompletedTodos() => _todoDao.deleteAllCompletedTodos();
@override
Future<void> addTagToTodo(int todoId, int tagId) =>
_todoDao.addTagToTodo(todoId, tagId);
@override
Future<void> removeTagFromTodo(int todoId, int tagId) =>
_todoDao.removeTagFromTodo(todoId, tagId);
@override
Future<void> updateTodoTags(int todoId, List<int> tagIds) =>
_todoDao.updateTodoTags(todoId, tagIds);
@override
Stream<List<TagEntity>> watchTagsForTodo(int todoId) =>
_todoDao.watchTagsForTodo(todoId);
@override
Stream<List<TodoWithCategory>> watchTodosWithCategory() =>
_todoDao.watchTodosWithCategory();
@override
Future<List<TodoWithTags>> getTodosWithTags() => _todoDao.getTodosWithTags();
@override
Stream<List<TodoEntity>> watchTodosSorted({
required String sortBy,
bool ascending = true,
}) =>
_todoDao.watchTodosSorted(sortBy: sortBy, ascending: ascending);
}
lib/data/repositories/category_repository_impl.dart:
import '../../domain/repositories/category_repository.dart';
import '../database/app_database.dart';
import '../database/daos/category_dao.dart';
/// Category Repository 구현
class CategoryRepositoryImpl implements CategoryRepository {
final CategoryDao _categoryDao;
CategoryRepositoryImpl(this._categoryDao);
@override
Stream<List<CategoryEntity>> watchAllCategories() =>
_categoryDao.watchAllCategories();
@override
Future<List<CategoryEntity>> getAllCategories() =>
_categoryDao.getAllCategories();
@override
Future<CategoryEntity?> getCategoryById(int id) =>
_categoryDao.getCategoryById(id);
@override
Stream<CategoryEntity?> watchCategoryById(int id) =>
_categoryDao.watchCategoryById(id);
@override
Future<int> createCategory(CategoriesCompanion category) =>
_categoryDao.createCategory(category);
@override
Future<bool> updateCategory(CategoryEntity category) =>
_categoryDao.updateCategory(category);
@override
Future<void> deleteCategory(int id) => _categoryDao.deleteCategory(id);
@override
Future<void> updateCategoryOrder(int categoryId, int newOrder) =>
_categoryDao.updateCategoryOrder(categoryId, newOrder);
@override
Future<void> reorderCategories(Map<int, int> categoryOrders) =>
_categoryDao.reorderCategories(categoryOrders);
@override
Future<bool> isCategoryNameExists(String name, {int? excludeId}) =>
_categoryDao.isCategoryNameExists(name, excludeId: excludeId);
@override
Stream<List<CategoryWithCount>> watchCategoriesWithTodoCount() =>
_categoryDao.watchCategoriesWithTodoCount();
}
lib/data/repositories/tag_repository_impl.dart:
import '../../domain/repositories/tag_repository.dart';
import '../database/app_database.dart';
import '../database/daos/tag_dao.dart';
/// Tag Repository 구현
class TagRepositoryImpl implements TagRepository {
final TagDao _tagDao;
TagRepositoryImpl(this._tagDao);
@override
Stream<List<TagEntity>> watchAllTags() => _tagDao.watchAllTags();
@override
Future<List<TagEntity>> getAllTags() => _tagDao.getAllTags();
@override
Future<TagEntity?> getTagById(int id) => _tagDao.getTagById(id);
@override
Future<int> createTag(TagsCompanion tag) => _tagDao.createTag(tag);
@override
Future<bool> updateTag(TagEntity tag) => _tagDao.updateTag(tag);
@override
Future<int> deleteTag(int id) => _tagDao.deleteTag(id);
@override
Future<List<TagEntity>> searchTagsByName(String query) =>
_tagDao.searchTagsByName(query);
@override
Future<bool> isTagNameExists(String name, {int? excludeId}) =>
_tagDao.isTagNameExists(name, excludeId: excludeId);
@override
Future<List<TagEntity>> getUnusedTags() => _tagDao.getUnusedTags();
@override
Future<void> deleteUnusedTags() => _tagDao.deleteUnusedTags();
@override
Stream<List<TagWithCount>> watchTagsWithUsageCount() =>
_tagDao.watchTagsWithUsageCount();
@override
Future<List<TagWithCount>> getMostUsedTags({int limit = 10}) =>
_tagDao.getMostUsedTags(limit: limit);
@override
Future<List<TagEntity>> getTagsForTodo(int todoId) =>
_tagDao.getTagsForTodo(todoId);
@override
Stream<List<TagEntity>> watchTagsForTodo(int todoId) =>
_tagDao.watchTagsForTodo(todoId);
@override
Stream<List<TodoEntity>> watchTodosByTag(int tagId) =>
_tagDao.watchTodosByTag(tagId);
}
4.4 Repository 프로바이더
lib/presentation/providers/repository_providers.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/repositories/todo_repository.dart';
import '../../domain/repositories/category_repository.dart';
import '../../domain/repositories/tag_repository.dart';
import '../../data/repositories/todo_repository_impl.dart';
import '../../data/repositories/category_repository_impl.dart';
import '../../data/repositories/tag_repository_impl.dart';
import 'database_provider.dart';
/// Todo Repository 프로바이더
final todoRepositoryProvider = Provider<TodoRepository>((ref) {
final todoDao = ref.watch(todoDaoProvider);
return TodoRepositoryImpl(todoDao);
});
/// Category Repository 프로바이더
final categoryRepositoryProvider = Provider<CategoryRepository>((ref) {
final categoryDao = ref.watch(categoryDaoProvider);
return CategoryRepositoryImpl(categoryDao);
});
/// Tag Repository 프로바이더
final tagRepositoryProvider = Provider<TagRepository>((ref) {
final tagDao = ref.watch(tagDaoProvider);
return TagRepositoryImpl(tagDao);
});
5. Repository 패턴 구현
5.1 상태 관리 모델 정의
Riverpod의 StateNotifier를 사용하여 상태를 관리합니다.
lib/presentation/providers/todo_provider.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart' as drift;
import '../../data/database/app_database.dart';
import '../../domain/repositories/todo_repository.dart';
import 'repository_providers.dart';
/// Todo 목록 상태
class TodoListState {
final List<TodoEntity> todos;
final bool isLoading;
final String? error;
final String sortBy;
final bool ascending;
final String filterType;
TodoListState({
this.todos = const [],
this.isLoading = false,
this.error,
this.sortBy = 'createdAt',
this.ascending = false,
this.filterType = 'all',
});
TodoListState copyWith({
List<TodoEntity>? todos,
bool? isLoading,
String? error,
String? sortBy,
bool? ascending,
String? filterType,
}) {
return TodoListState(
todos: todos ?? this.todos,
isLoading: isLoading ?? this.isLoading,
error: error,
sortBy: sortBy ?? this.sortBy,
ascending: ascending ?? this.ascending,
filterType: filterType ?? this.filterType,
);
}
}
/// Todo 목록 Notifier
class TodoListNotifier extends StateNotifier<TodoListState> {
final TodoRepository _repository;
TodoListNotifier(this._repository) : super(TodoListState()) {
_init();
}
void _init() {
// 초기 데이터 로드
_watchTodos();
}
void _watchTodos() {
Stream<List<TodoEntity>> stream;
switch (state.filterType) {
case 'active':
stream = _repository.watchActiveTodos();
break;
case 'completed':
stream = _repository.watchCompletedTodos();
break;
case 'all':
default:
stream = _repository.watchTodosSorted(
sortBy: state.sortBy,
ascending: state.ascending,
);
}
stream.listen(
(todos) {
state = state.copyWith(todos: todos, isLoading: false);
},
onError: (error) {
state = state.copyWith(error: error.toString(), isLoading: false);
},
);
}
/// Todo 생성
Future<void> createTodo({
required String title,
String? description,
int? categoryId,
int priority = 2,
DateTime? dueDate,
bool hasReminder = false,
DateTime? reminderTime,
}) async {
try {
final companion = TodosCompanion.insert(
title: title,
description: drift.Value(description),
categoryId: drift.Value(categoryId),
priority: drift.Value(priority),
dueDate: drift.Value(dueDate),
hasReminder: drift.Value(hasReminder),
reminderTime: drift.Value(reminderTime),
);
await _repository.createTodo(companion);
} catch (e) {
state = state.copyWith(error: '할 일 생성 실패: ${e.toString()}');
}
}
/// Todo 업데이트
Future<void> updateTodo(TodoEntity todo) async {
try {
await _repository.updateTodo(
todo.copyWith(updatedAt: DateTime.now()),
);
} catch (e) {
state = state.copyWith(error: '할 일 업데이트 실패: ${e.toString()}');
}
}
/// Todo 삭제
Future<void> deleteTodo(int id) async {
try {
await _repository.deleteTodo(id);
} catch (e) {
state = state.copyWith(error: '할 일 삭제 실패: ${e.toString()}');
}
}
/// Todo 완료 토글
Future<void> toggleTodoCompletion(TodoEntity todo) async {
try {
final updatedTodo = todo.copyWith(
isCompleted: !todo.isCompleted,
completedAt: drift.Value(!todo.isCompleted ? DateTime.now() : null),
updatedAt: DateTime.now(),
);
await _repository.updateTodo(updatedTodo);
} catch (e) {
state = state.copyWith(error: '할 일 상태 변경 실패: ${e.toString()}');
}
}
/// 필터 변경
void setFilter(String filterType) {
state = state.copyWith(filterType: filterType);
_watchTodos();
}
/// 정렬 옵션 변경
void setSortOption(String sortBy, {bool? ascending}) {
state = state.copyWith(
sortBy: sortBy,
ascending: ascending ?? state.ascending,
);
_watchTodos();
}
/// 정렬 순서 토글
void toggleSortOrder() {
state = state.copyWith(ascending: !state.ascending);
_watchTodos();
}
/// 완료된 할 일 모두 삭제
Future<void> deleteAllCompleted() async {
try {
await _repository.deleteAllCompletedTodos();
} catch (e) {
state = state.copyWith(error: '완료된 할 일 삭제 실패: ${e.toString()}');
}
}
}
/// Todo 목록 Provider
final todoListProvider = StateNotifierProvider<TodoListNotifier, TodoListState>((ref) {
final repository = ref.watch(todoRepositoryProvider);
return TodoListNotifier(repository);
});
/// 특정 Todo Provider
final todoByIdProvider = StreamProvider.family<TodoEntity?, int>((ref, id) {
final repository = ref.watch(todoRepositoryProvider);
return repository.watchTodoById(id);
});
/// Todo 통계 Provider
final todoStatsProvider = FutureProvider<TodoStats>((ref) async {
final repository = ref.watch(todoRepositoryProvider);
final total = await repository.getTotalTodoCount();
final completed = await repository.getCompletedTodoCount();
final countByCategory = await repository.getTodoCountByCategory();
final countByPriority = await repository.getTodoCountByPriority();
return TodoStats(
total: total,
completed: completed,
active: total - completed,
countByCategory: countByCategory,
countByPriority: countByPriority,
);
});
/// Todo 통계 모델
class TodoStats {
final int total;
final int completed;
final int active;
final Map<int, int> countByCategory;
final Map<int, int> countByPriority;
TodoStats({
required this.total,
required this.completed,
required this.active,
required this.countByCategory,
required this.countByPriority,
});
double get completionRate => total > 0 ? (completed / total) * 100 : 0;
}
/// 오늘 마감 Todo Provider
final todosDueTodayProvider = StreamProvider<List<TodoEntity>>((ref) {
final repository = ref.watch(todoRepositoryProvider);
return repository.watchTodosDueToday();
});
/// 기한 초과 Todo Provider
final overdueLoadosProvider = StreamProvider<List<TodoEntity>>((ref) {
final repository = ref.watch(todoRepositoryProvider);
return repository.watchOverdueTodos();
});
lib/presentation/providers/category_provider.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart' as drift;
import '../../data/database/app_database.dart';
import '../../data/database/daos/category_dao.dart';
import '../../domain/repositories/category_repository.dart';
import 'repository_providers.dart';
/// 모든 카테고리 Provider
final allCategoriesProvider = StreamProvider<List<CategoryEntity>>((ref) {
final repository = ref.watch(categoryRepositoryProvider);
return repository.watchAllCategories();
});
/// 특정 카테고리 Provider
final categoryByIdProvider = StreamProvider.family<CategoryEntity?, int>((ref, id) {
final repository = ref.watch(categoryRepositoryProvider);
return repository.watchCategoryById(id);
});
/// Todo 개수를 포함한 카테고리 Provider
final categoriesWithCountProvider = StreamProvider<List<CategoryWithCount>>((ref) {
final repository = ref.watch(categoryRepositoryProvider);
return repository.watchCategoriesWithTodoCount();
});
/// 카테고리 상태
class CategoryState {
final List<CategoryEntity> categories;
final bool isLoading;
final String? error;
CategoryState({
this.categories = const [],
this.isLoading = false,
this.error,
});
CategoryState copyWith({
List<CategoryEntity>? categories,
bool? isLoading,
String? error,
}) {
return CategoryState(
categories: categories ?? this.categories,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
/// 카테고리 Notifier
class CategoryNotifier extends StateNotifier<CategoryState> {
final CategoryRepository _repository;
CategoryNotifier(this._repository) : super(CategoryState());
/// 카테고리 생성
Future<void> createCategory({
required String name,
required String color,
String? iconCode,
}) async {
try {
state = state.copyWith(isLoading: true);
// 이름 중복 체크
final exists = await _repository.isCategoryNameExists(name);
if (exists) {
state = state.copyWith(
isLoading: false,
error: '이미 존재하는 카테고리 이름입니다.',
);
return;
}
final companion = CategoriesCompanion.insert(
name: name,
color: color,
iconCode: drift.Value(iconCode),
);
await _repository.createCategory(companion);
state = state.copyWith(isLoading: false, error: null);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: '카테고리 생성 실패: ${e.toString()}',
);
}
}
/// 카테고리 업데이트
Future<void> updateCategory(CategoryEntity category) async {
try {
state = state.copyWith(isLoading: true);
// 이름 중복 체크 (자신 제외)
final exists = await _repository.isCategoryNameExists(
category.name,
excludeId: category.id,
);
if (exists) {
state = state.copyWith(
isLoading: false,
error: '이미 존재하는 카테고리 이름입니다.',
);
return;
}
await _repository.updateCategory(category);
state = state.copyWith(isLoading: false, error: null);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: '카테고리 업데이트 실패: ${e.toString()}',
);
}
}
/// 카테고리 삭제
Future<void> deleteCategory(int id) async {
try {
state = state.copyWith(isLoading: true);
await _repository.deleteCategory(id);
state = state.copyWith(isLoading: false, error: null);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: '카테고리 삭제 실패: ${e.toString()}',
);
}
}
/// 카테고리 순서 변경
Future<void> reorderCategories(Map<int, int> categoryOrders) async {
try {
state = state.copyWith(isLoading: true);
await _repository.reorderCategories(categoryOrders);
state = state.copyWith(isLoading: false, error: null);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: '카테고리 순서 변경 실패: ${e.toString()}',
);
}
}
/// 에러 초기화
void clearError() {
state = state.copyWith(error: null);
}
}
/// 카테고리 Provider
final categoryProvider = StateNotifierProvider<CategoryNotifier, CategoryState>((ref) {
final repository = ref.watch(categoryRepositoryProvider);
return CategoryNotifier(repository);
});
lib/presentation/providers/tag_provider.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart' as drift;
import '../../data/database/app_database.dart';
import '../../data/database/daos/tag_dao.dart';
import '../../domain/repositories/tag_repository.dart';
import 'repository_providers.dart';
/// 모든 태그 Provider
final allTagsProvider = StreamProvider<List<TagEntity>>((ref) {
final repository = ref.watch(tagRepositoryProvider);
return repository.watchAllTags();
});
/// 사용 횟수를 포함한 태그 Provider
final tagsWithUsageCountProvider = StreamProvider<List<TagWithCount>>((ref) {
final repository = ref.watch(tagRepositoryProvider);
return repository.watchTagsWithUsageCount();
});
/// 특정 Todo의 태그 Provider
final tagsForTodoProvider = StreamProvider.family<List<TagEntity>, int>((ref, todoId) {
final repository = ref.watch(tagRepositoryProvider);
return repository.watchTagsForTodo(todoId);
});
/// 특정 태그의 Todo 목록 Provider
final todosByTagProvider = StreamProvider.family<List<TodoEntity>, int>((ref, tagId) {
final repository = ref.watch(tagRepositoryProvider);
return repository.watchTodosByTag(tagId);
});
/// 태그 상태
class TagState {
final List<TagEntity> tags;
final bool isLoading;
final String? error;
TagState({
this.tags = const [],
this.isLoading = false,
this.error,
});
TagState copyWith({
List<TagEntity>? tags,
bool? isLoading,
String? error,
}) {
return TagState(
tags: tags ?? this.tags,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
/// 태그 Notifier
class TagNotifier extends StateNotifier<TagState> {
final TagRepository _repository;
TagNotifier(this._repository) : super(TagState());
/// 태그 생성
Future<void> createTag({
required String name,
required String color,
}) async {
try {
state = state.copyWith(isLoading: true);
// 이름 중복 체크
final exists = await _repository.isTagNameExists(name);
if (exists) {
state = state.copyWith(
isLoading: false,
error: '이미 존재하는 태그 이름입니다.',
);
return;
}
final companion = TagsCompanion.insert(
name: name,
color: color,
);
await _repository.createTag(companion);
state = state.copyWith(isLoading: false, error: null);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: '태그 생성 실패: ${e.toString()}',
);
}
}
/// 태그 업데이트
Future<void> updateTag(TagEntity tag) async {
try {
state = state.copyWith(isLoading: true);
// 이름 중복 체크 (자신 제외)
final exists = await _repository.isTagNameExists(
tag.name,
excludeId: tag.id,
);
if (exists) {
state = state.copyWith(
isLoading: false,
error: '이미 존재하는 태그 이름입니다.',
);
return;
}
await _repository.updateTag(tag);
state = state.copyWith(isLoading: false, error: null);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: '태그 업데이트 실패: ${e.toString()}',
);
}
}
/// 태그 삭제
Future<void> deleteTag(int id) async {
try {
state = state.copyWith(isLoading: true);
await _repository.deleteTag(id);
state = state.copyWith(isLoading: false, error: null);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: '태그 삭제 실패: ${e.toString()}',
);
}
}
/// 사용되지 않는 태그 삭제
Future<void> deleteUnusedTags() async {
try {
state = state.copyWith(isLoading: true);
await _repository.deleteUnusedTags();
state = state.copyWith(isLoading: false, error: null);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: '사용되지 않는 태그 삭제 실패: ${e.toString()}',
);
}
}
/// 에러 초기화
void clearError() {
state = state.copyWith(error: null);
}
}
/// 태그 Provider
final tagProvider = StateNotifierProvider<TagNotifier, TagState>((ref) {
final repository = ref.watch(tagRepositoryProvider);
return TagNotifier(repository);
});
5.2 검색 및 필터 프로바이더
lib/presentation/providers/search_provider.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/database/app_database.dart';
import '../../domain/repositories/todo_repository.dart';
import 'repository_providers.dart';
/// 검색 쿼리 상태 Provider
final searchQueryProvider = StateProvider<String>((ref) => '');
/// 검색 결과 Provider
final searchResultsProvider = StreamProvider<List<TodoEntity>>((ref) {
final repository = ref.watch(todoRepositoryProvider);
final query = ref.watch(searchQueryProvider);
if (query.isEmpty) {
return Stream.value([]);
}
return repository.searchTodos(query);
});
/// 필터 옵션 상태
class FilterOptions {
final String? categoryId;
final int? priority;
final bool? isCompleted;
final DateTime? dueDateStart;
final DateTime? dueDateEnd;
FilterOptions({
this.categoryId,
this.priority,
this.isCompleted,
this.dueDateStart,
this.dueDateEnd,
});
FilterOptions copyWith({
String? categoryId,
int? priority,
bool? isCompleted,
DateTime? dueDateStart,
DateTime? dueDateEnd,
}) {
return FilterOptions(
categoryId: categoryId ?? this.categoryId,
priority: priority ?? this.priority,
isCompleted: isCompleted ?? this.isCompleted,
dueDateStart: dueDateStart ?? this.dueDateStart,
dueDateEnd: dueDateEnd ?? this.dueDateEnd,
);
}
bool get hasActiveFilters =>
categoryId != null ||
priority != null ||
isCompleted != null ||
dueDateStart != null ||
dueDateEnd != null;
void clear() {
// 모든 필터 초기화
}
}
/// 필터 옵션 Provider
final filterOptionsProvider = StateProvider<FilterOptions>((ref) {
return FilterOptions();
});
6. 실전 Todo 앱 구현
6.1 메인 앱 설정
lib/main.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
lib/app.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'presentation/screens/home_screen.dart';
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
title: 'Todo App',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
),
themeMode: ThemeMode.system,
home: const HomeScreen(),
);
}
}
6.2 홈 화면 구현
lib/presentation/screens/home_screen.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/todo_provider.dart';
import '../providers/category_provider.dart';
import '../widgets/todo_list_item.dart';
import '../widgets/filter_bottom_sheet.dart';
import '../widgets/add_todo_dialog.dart';
import '../widgets/stats_card.dart';
import 'package:drift/drift.dart' as drift;
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
int _selectedIndex = 0;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
setState(() {
_selectedIndex = _tabController.index;
});
_updateFilter();
}
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _updateFilter() {
final notifier = ref.read(todoListProvider.notifier);
switch (_selectedIndex) {
case 0:
notifier.setFilter('all');
break;
case 1:
notifier.setFilter('active');
break;
case 2:
notifier.setFilter('completed');
break;
}
}
@override
Widget build(BuildContext context) {
final todoState = ref.watch(todoListProvider);
final statsAsync = ref.watch(todoStatsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Todo 앱'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '전체'),
Tab(text: '진행중'),
Tab(text: '완료'),
],
),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
// TODO: 검색 화면으로 이동
},
),
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const FilterBottomSheet(),
);
},
),
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'deleteCompleted':
_showDeleteCompletedDialog();
break;
case 'settings':
// TODO: 설정 화면으로 이동
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'deleteCompleted',
child: Text('완료된 항목 삭제'),
),
const PopupMenuItem(
value: 'settings',
child: Text('설정'),
),
],
),
],
),
body: Column(
children: [
// 통계 카드
statsAsync.when(
data: (stats) => StatsCard(stats: stats),
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
),
// Todo 목록
Expanded(
child: todoState.isLoading
? const Center(child: CircularProgressIndicator())
: todoState.error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 16),
Text(todoState.error!),
],
),
)
: todoState.todos.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox_outlined,
size: 80,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'할 일이 없습니다',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
),
),
],
),
)
: RefreshIndicator(
onRefresh: () async {
// 새로고침 로직
ref.invalidate(todoListProvider);
},
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: todoState.todos.length,
itemBuilder: (context, index) {
final todo = todoState.todos[index];
return TodoListItem(
todo: todo,
onTap: () {
// TODO: 상세 화면으로 이동
},
onToggle: () {
ref
.read(todoListProvider.notifier)
.toggleTodoCompletion(todo);
},
onDelete: () {
_showDeleteConfirmDialog(todo.id);
},
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
showDialog(
context: context,
builder: (context) => const AddTodoDialog(),
);
},
icon: const Icon(Icons.add),
label: const Text('할 일 추가'),
),
);
}
void _showDeleteCompletedDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('완료된 항목 삭제'),
content: const Text('완료된 모든 할 일을 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
ref.read(todoListProvider.notifier).deleteAllCompleted();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('완료된 항목이 삭제되었습니다')),
);
},
child: const Text('삭제'),
),
],
),
);
}
void _showDeleteConfirmDialog(int todoId) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('할 일 삭제'),
content: const Text('이 할 일을 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
ref.read(todoListProvider.notifier).deleteTodo(todoId);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('할 일이 삭제되었습니다')),
);
},
child: const Text('삭제'),
),
],
),
);
}
}
6.3 Todo 목록 아이템 위젯
lib/presentation/widgets/todo_list_item.dart:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../data/database/app_database.dart';
class TodoListItem extends StatelessWidget {
final TodoEntity todo;
final VoidCallback onTap;
final VoidCallback onToggle;
final VoidCallback onDelete;
const TodoListItem({
super.key,
required this.todo,
required this.onTap,
required this.onToggle,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isOverdue = todo.dueDate != null &&
!todo.isCompleted &&
todo.dueDate!.isBefore(DateTime.now());
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// 체크박스
Checkbox(
value: todo.isCompleted,
onChanged: (_) => onToggle(),
shape: const CircleBorder(),
),
const SizedBox(width: 8),
// Todo 내용
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 제목
Text(
todo.title,
style: theme.textTheme.bodyLarge?.copyWith(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: null,
color: todo.isCompleted
? theme.colorScheme.onSurface.withOpacity(0.5)
: null,
),
),
if (todo.description != null) ...[
const SizedBox(height: 4),
Text(
todo.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 8),
// 메타 정보
Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (todo.dueDate != null)
_buildChip(
context,
icon: Icons.calendar_today,
label: DateFormat('MM/dd').format(todo.dueDate!),
color: isOverdue ? Colors.red : null,
),
if (todo.priority > 1)
_buildChip(
context,
icon: Icons.flag,
label: _getPriorityText(todo.priority),
color: _getPriorityColor(todo.priority),
),
if (todo.hasReminder)
_buildChip(
context,
icon: Icons.notifications,
label: '알림',
),
],
),
],
),
),
// 삭제 버튼
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
color: theme.colorScheme.error,
),
],
),
),
),
);
}
Widget _buildChip(
BuildContext context, {
required IconData icon,
required String label,
Color? color,
}) {
final theme = Theme.of(context);
final effectiveColor = color ?? theme.colorScheme.primary;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: effectiveColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: effectiveColor),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: effectiveColor,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
String _getPriorityText(int priority) {
switch (priority) {
case 3:
return '높음';
case 2:
return '보통';
case 1:
default:
return '낮음';
}
}
Color _getPriorityColor(int priority) {
switch (priority) {
case 3:
return Colors.red;
case 2:
return Colors.orange;
case 1:
default:
return Colors.grey;
}
}
}
6.4 통계 카드 위젯
lib/presentation/widgets/stats_card.dart:
import 'package:flutter/material.dart';
import '../providers/todo_provider.dart';
class StatsCard extends StatelessWidget {
final TodoStats stats;
const StatsCard({
super.key,
required this.stats,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primaryContainer,
theme.colorScheme.secondaryContainer,
],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatItem(
icon: Icons.list_alt,
label: '전체',
value: stats.total.toString(),
color: theme.colorScheme.primary,
),
_StatItem(
icon: Icons.pending_actions,
label: '진행중',
value: stats.active.toString(),
color: Colors.orange,
),
_StatItem(
icon: Icons.check_circle,
label: '완료',
value: stats.completed.toString(),
color: Colors.green,
),
],
),
const SizedBox(height: 16),
// 진행률 바
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'완료율',
style: theme.textTheme.titleSmall,
),
Text(
'${stats.completionRate.toStringAsFixed(1)}%',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: stats.completionRate / 100,
minHeight: 8,
backgroundColor: Colors.white.withOpacity(0.3),
valueColor: AlwaysStoppedAnimation<Color>(
Colors.green,
),
),
),
],
),
],
),
);
}
}
class _StatItem extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final Color color;
const _StatItem({
required this.icon,
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.black.withOpacity(0.6),
),
),
],
);
}
}
6.5 할 일 추가 다이얼로그
lib/presentation/widgets/add_todo_dialog.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../providers/todo_provider.dart';
import '../providers/category_provider.dart';
class AddTodoDialog extends ConsumerStatefulWidget {
const AddTodoDialog({super.key});
@override
ConsumerState<AddTodoDialog> createState() => _AddTodoDialogState();
}
class _AddTodoDialogState extends ConsumerState<AddTodoDialog> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
int _priority = 2;
int? _selectedCategoryId;
DateTime? _dueDate;
bool _hasReminder = false;
DateTime? _reminderTime;
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final categoriesAsync = ref.watch(allCategoriesProvider);
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
constraints: const BoxConstraints(maxWidth: 500),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'새 할 일 추가',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
// 제목
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: '제목',
hintText: '무엇을 할까요?',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.title),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '제목을 입력해주세요';
}
return null;
},
autofocus: true,
),
const SizedBox(height: 16),
// 설명
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: '설명 (선택사항)',
hintText: '세부 내용을 입력하세요',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.description),
),
maxLines: 3,
),
const SizedBox(height: 16),
// 카테고리
categoriesAsync.when(
data: (categories) => DropdownButtonFormField<int>(
value: _selectedCategoryId,
decoration: const InputDecoration(
labelText: '카테고리',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
items: [
const DropdownMenuItem(
value: null,
child: Text('카테고리 없음'),
),
...categories.map((category) {
return DropdownMenuItem(
value: category.id,
child: Text(category.name),
);
}),
],
onChanged: (value) {
setState(() {
_selectedCategoryId = value;
});
},
),
loading: () => const CircularProgressIndicator(),
error: (_, __) => const Text('카테고리를 불러올 수 없습니다'),
),
const SizedBox(height: 16),
// 우선순위
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('우선순위', style: TextStyle(fontSize: 16)),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(
value: 1,
label: Text('낮음'),
icon: Icon(Icons.low_priority),
),
ButtonSegment(
value: 2,
label: Text('보통'),
icon: Icon(Icons.remove),
),
ButtonSegment(
value: 3,
label: Text('높음'),
icon: Icon(Icons.priority_high),
),
],
selected: {_priority},
onSelectionChanged: (Set<int> newSelection) {
setState(() {
_priority = newSelection.first;
});
},
),
],
),
const SizedBox(height: 16),
// 마감일
ListTile(
leading: const Icon(Icons.calendar_today),
title: Text(_dueDate == null
? '마감일 설정'
: DateFormat('yyyy-MM-dd').format(_dueDate!)),
trailing: _dueDate == null
? null
: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
_dueDate = null;
});
},
),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _dueDate ?? DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (date != null) {
setState(() {
_dueDate = date;
});
}
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Colors.grey[300]!),
),
),
const SizedBox(height: 16),
// 알림
CheckboxListTile(
value: _hasReminder,
onChanged: (value) {
setState(() {
_hasReminder = value ?? false;
if (_hasReminder && _reminderTime == null) {
_reminderTime = DateTime.now().add(const Duration(hours: 1));
}
});
},
title: const Text('알림 설정'),
secondary: const Icon(Icons.notifications),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Colors.grey[300]!),
),
),
const SizedBox(height: 24),
// 버튼
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _saveTodo,
child: const Text('추가'),
),
],
),
],
),
),
),
),
);
}
void _saveTodo() async {
if (_formKey.currentState!.validate()) {
await ref.read(todoListProvider.notifier).createTodo(
title: _titleController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
categoryId: _selectedCategoryId,
priority: _priority,
dueDate: _dueDate,
hasReminder: _hasReminder,
reminderTime: _reminderTime,
);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('할 일이 추가되었습니다')),
);
}
}
}
}
6.6 필터 Bottom Sheet
lib/presentation/widgets/filter_bottom_sheet.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/todo_provider.dart';
class FilterBottomSheet extends ConsumerStatefulWidget {
const FilterBottomSheet({super.key});
@override
ConsumerState<FilterBottomSheet> createState() => _FilterBottomSheetState();
}
class _FilterBottomSheetState extends ConsumerState<FilterBottomSheet> {
String _sortBy = 'createdAt';
bool _ascending = false;
@override
void initState() {
super.initState();
final state = ref.read(todoListProvider);
_sortBy = state.sortBy;
_ascending = state.ascending;
}
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
decoration: const BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'정렬 및 필터',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const Divider(),
const SizedBox(height: 16),
// 정렬 기준
Text(
'정렬 기준',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
_buildSortChip('생성일', 'createdAt'),
_buildSortChip('제목', 'title'),
_buildSortChip('우선순위', 'priority'),
_buildSortChip('마감일', 'dueDate'),
],
),
const SizedBox(height: 16),
// 정렬 순서
SwitchListTile(
value: _ascending,
onChanged: (value) {
setState(() {
_ascending = value;
});
},
title: const Text('오름차순 정렬'),
subtitle: Text(_ascending ? '오래된 항목부터' : '최신 항목부터'),
),
const SizedBox(height: 24),
// 적용 버튼
FilledButton(
onPressed: () {
ref.read(todoListProvider.notifier).setSortOption(
_sortBy,
ascending: _ascending,
);
Navigator.pop(context);
},
child: const Text('적용'),
),
],
),
);
}
Widget _buildSortChip(String label, String value) {
final isSelected = _sortBy == value;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_sortBy = value;
});
},
);
}
}
7. 고급 기능 구현
7.1 Todo 상세 화면
lib/presentation/screens/todo_detail_screen.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:drift/drift.dart' as drift;
import '../../data/database/app_database.dart';
import '../providers/todo_provider.dart';
import '../providers/category_provider.dart';
import '../providers/tag_provider.dart';
import '../widgets/edit_todo_dialog.dart';
class TodoDetailScreen extends ConsumerWidget {
final int todoId;
const TodoDetailScreen({
super.key,
required this.todoId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todoAsync = ref.watch(todoByIdProvider(todoId));
final tagsAsync = ref.watch(tagsForTodoProvider(todoId));
return Scaffold(
appBar: AppBar(
title: const Text('할 일 상세'),
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
todoAsync.whenData((todo) {
if (todo != null) {
showDialog(
context: context,
builder: (context) => EditTodoDialog(todo: todo),
);
}
});
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
_showDeleteDialog(context, ref);
},
),
],
),
body: todoAsync.when(
data: (todo) {
if (todo == null) {
return const Center(
child: Text('할 일을 찾을 수 없습니다'),
);
}
return _buildContent(context, ref, todo, tagsAsync);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Text('오류 발생: $error'),
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
todoAsync.whenData((todo) {
if (todo != null) {
ref.read(todoListProvider.notifier).toggleTodoCompletion(todo);
}
});
},
icon: Icon(
todoAsync.value?.isCompleted == true
? Icons.check_circle
: Icons.check_circle_outline,
),
label: Text(
todoAsync.value?.isCompleted == true ? '완료 취소' : '완료',
),
),
);
}
Widget _buildContent(
BuildContext context,
WidgetRef ref,
TodoEntity todo,
AsyncValue<List<TagEntity>> tagsAsync,
) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 상태 배지
if (todo.isCompleted)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: Colors.green, size: 16),
SizedBox(width: 4),
Text(
'완료됨',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 16),
// 제목
Text(
todo.title,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
decoration: todo.isCompleted ? TextDecoration.lineThrough : null,
),
),
const SizedBox(height: 24),
// 설명
if (todo.description != null) ...[
_buildSection(
context,
'설명',
Text(
todo.description!,
style: theme.textTheme.bodyLarge,
),
),
const SizedBox(height: 24),
],
// 세부 정보
_buildSection(
context,
'세부 정보',
Column(
children: [
_buildInfoRow(
context,
Icons.flag,
'우선순위',
_getPriorityText(todo.priority),
),
const Divider(),
if (todo.dueDate != null)
_buildInfoRow(
context,
Icons.calendar_today,
'마감일',
DateFormat('yyyy년 MM월 dd일').format(todo.dueDate!),
),
if (todo.dueDate != null) const Divider(),
_buildInfoRow(
context,
Icons.access_time,
'생성일',
DateFormat('yyyy-MM-dd HH:mm').format(todo.createdAt),
),
const Divider(),
_buildInfoRow(
context,
Icons.update,
'수정일',
DateFormat('yyyy-MM-dd HH:mm').format(todo.updatedAt),
),
if (todo.completedAt != null) ...[
const Divider(),
_buildInfoRow(
context,
Icons.check_circle,
'완료일',
DateFormat('yyyy-MM-dd HH:mm').format(todo.completedAt!),
),
],
],
),
),
const SizedBox(height: 24),
// 카테고리
_buildSection(
context,
'카테고리',
_buildCategoryWidget(ref, todo.categoryId),
),
const SizedBox(height: 24),
// 태그
_buildSection(
context,
'태그',
tagsAsync.when(
data: (tags) => tags.isEmpty
? const Text('태그가 없습니다')
: Wrap(
spacing: 8,
runSpacing: 8,
children: tags.map((tag) {
return Chip(
label: Text(tag.name),
backgroundColor: Color(
int.parse('FF${tag.color}', radix: 16),
).withOpacity(0.2),
);
}).toList(),
),
loading: () => const CircularProgressIndicator(),
error: (_, __) => const Text('태그를 불러올 수 없습니다'),
),
),
const SizedBox(height: 24),
// 알림
if (todo.hasReminder) ...[
_buildSection(
context,
'알림',
_buildInfoRow(
context,
Icons.notifications,
'알림 시간',
todo.reminderTime != null
? DateFormat('yyyy-MM-dd HH:mm').format(todo.reminderTime!)
: '설정되지 않음',
),
),
const SizedBox(height: 24),
],
// 메모
if (todo.notes != null) ...[
_buildSection(
context,
'메모',
Text(
todo.notes!,
style: theme.textTheme.bodyMedium,
),
),
],
],
),
);
}
Widget _buildSection(BuildContext context, String title, Widget content) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: content,
),
],
);
}
Widget _buildInfoRow(
BuildContext context,
IconData icon,
String label,
String value,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
);
}
Widget _buildCategoryWidget(WidgetRef ref, int? categoryId) {
if (categoryId == null) {
return const Text('카테고리 없음');
}
final categoryAsync = ref.watch(categoryByIdProvider(categoryId));
return categoryAsync.when(
data: (category) {
if (category == null) {
return const Text('카테고리를 찾을 수 없습니다');
}
return Chip(
avatar: CircleAvatar(
backgroundColor: Color(
int.parse('FF${category.color}', radix: 16),
),
),
label: Text(category.name),
);
},
loading: () => const CircularProgressIndicator(),
error: (_, __) => const Text('카테고리를 불러올 수 없습니다'),
);
}
String _getPriorityText(int priority) {
switch (priority) {
case 3:
return '높음';
case 2:
return '보통';
case 1:
default:
return '낮음';
}
}
void _showDeleteDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('할 일 삭제'),
content: const Text('이 할 일을 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
ref.read(todoListProvider.notifier).deleteTodo(todoId);
Navigator.pop(context); // 다이얼로그 닫기
Navigator.pop(context); // 상세 화면 닫기
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('할 일이 삭제되었습니다')),
);
},
child: const Text('삭제'),
),
],
),
);
}
}
8. 상태 관리 심화
8.1 AsyncNotifier를 사용한 비동기 상태 관리
Riverpod 2.0+에서는 AsyncNotifier를 사용하여 비동기 작업을 더 우아하게 처리할 수 있습니다.
lib/presentation/providers/async_todo_provider.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart' as drift;
import '../../data/database/app_database.dart';
import '../../domain/repositories/todo_repository.dart';
import 'repository_providers.dart';
/// AsyncNotifier를 사용한 Todo 상태 관리
class AsyncTodoNotifier extends AsyncNotifier<List<TodoEntity>> {
late TodoRepository _repository;
@override
Future<List<TodoEntity>> build() async {
_repository = ref.watch(todoRepositoryProvider);
// 초기 데이터 로드
return await _repository.getAllTodos();
}
/// Todo 생성 (낙관적 업데이트)
Future<void> createTodo({
required String title,
String? description,
int? categoryId,
int priority = 2,
}) async {
// 현재 상태 가져오기
final currentState = state.valueOrNull ?? [];
// 임시 Todo 생성 (낙관적 업데이트)
final tempTodo = TodoEntity(
id: -1, // 임시 ID
title: title,
description: description,
isCompleted: false,
priority: priority,
categoryId: categoryId,
dueDate: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
completedAt: null,
color: null,
hasReminder: false,
reminderTime: null,
repeatType: 'none',
notes: null,
);
// 낙관적으로 UI 업데이트
state = AsyncValue.data([tempTodo, ...currentState]);
try {
// 실제 데이터베이스에 저장
final companion = TodosCompanion.insert(
title: title,
description: drift.Value(description),
categoryId: drift.Value(categoryId),
priority: drift.Value(priority),
);
final newId = await _repository.createTodo(companion);
// 실제 데이터로 교체
final realTodo = await _repository.getTodoById(newId);
if (realTodo != null) {
state = AsyncValue.data([
realTodo,
...currentState,
]);
}
} catch (e, stackTrace) {
// 오류 발생 시 이전 상태로 복원
state = AsyncValue.data(currentState);
// 오류 상태 설정
state = AsyncValue.error(e, stackTrace);
}
}
/// Todo 삭제 (낙관적 업데이트)
Future<void> deleteTodo(int id) async {
final currentState = state.valueOrNull ?? [];
// 낙관적으로 삭제
state = AsyncValue.data(
currentState.where((todo) => todo.id != id).toList(),
);
try {
await _repository.deleteTodo(id);
} catch (e, stackTrace) {
// 오류 발생 시 복원
state = AsyncValue.data(currentState);
state = AsyncValue.error(e, stackTrace);
}
}
/// Todo 완료 토글 (낙관적 업데이트)
Future<void> toggleTodo(TodoEntity todo) async {
final currentState = state.valueOrNull ?? [];
// 낙관적으로 업데이트
final updatedTodo = todo.copyWith(
isCompleted: !todo.isCompleted,
completedAt: drift.Value(!todo.isCompleted ? DateTime.now() : null),
);
state = AsyncValue.data(
currentState.map((t) => t.id == todo.id ? updatedTodo : t).toList(),
);
try {
await _repository.updateTodo(updatedTodo);
} catch (e, stackTrace) {
// 오류 발생 시 복원
state = AsyncValue.data(currentState);
state = AsyncValue.error(e, stackTrace);
}
}
/// 새로고침
Future<void> refresh() async {
state = const AsyncValue.loading();
try {
final todos = await _repository.getAllTodos();
state = AsyncValue.data(todos);
} catch (e, stackTrace) {
state = AsyncValue.error(e, stackTrace);
}
}
}
/// AsyncNotifier Provider
final asyncTodoProvider = AsyncNotifierProvider<AsyncTodoNotifier, List<TodoEntity>>(
AsyncTodoNotifier.new,
);
8.2 Family Provider로 매개변수화된 상태 관리
lib/presentation/providers/parameterized_providers.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/database/app_database.dart';
import '../../domain/repositories/todo_repository.dart';
import 'repository_providers.dart';
/// 카테고리별 Todo 개수 Provider (캐싱됨)
final todosCountByCategoryProvider = FutureProvider.family<int, int>((ref, categoryId) async {
final repository = ref.watch(todoRepositoryProvider);
final counts = await repository.getTodoCountByCategory();
return counts[categoryId] ?? 0;
});
/// 우선순위별 Todo 목록 Provider
final todosByPriorityProvider = StreamProvider.family<List<TodoEntity>, int>((ref, priority) {
final repository = ref.watch(todoRepositoryProvider);
return repository.watchTodosByPriority(priority);
});
/// 날짜 범위별 Todo 목록 Provider
class DateRange {
final DateTime start;
final DateTime end;
DateRange({required this.start, required this.end});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DateRange &&
runtimeType == other.runtimeType &&
start == other.start &&
end == other.end;
@override
int get hashCode => start.hashCode ^ end.hashCode;
}
final todosByDateRangeProvider = FutureProvider.family<List<TodoEntity>, DateRange>(
(ref, dateRange) async {
final repository = ref.watch(todoRepositoryProvider);
final allTodos = await repository.getAllTodos();
return allTodos.where((todo) {
if (todo.dueDate == null) return false;
return todo.dueDate!.isAfter(dateRange.start) &&
todo.dueDate!.isBefore(dateRange.end);
}).toList();
},
);
/// 검색 결과 Provider (Debounce 적용)
final debouncedSearchProvider = StreamProvider.family<List<TodoEntity>, String>(
(ref, query) async* {
// 300ms 대기 (디바운스)
await Future.delayed(const Duration(milliseconds: 300));
final repository = ref.watch(todoRepositoryProvider);
yield* repository.searchTodos(query);
},
);
8.3 Provider 조합 및 의존성 관리
lib/presentation/providers/combined_providers.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/database/app_database.dart';
import '../../data/database/daos/todo_dao.dart';
import 'todo_provider.dart';
import 'category_provider.dart';
import 'tag_provider.dart';
import 'repository_providers.dart';
/// 대시보드 데이터를 결합한 Provider
class DashboardData {
final int totalTodos;
final int activeTodos;
final int completedTodos;
final List<TodoEntity> todayTodos;
final List<TodoEntity> overdueTodos;
final int totalCategories;
final int totalTags;
DashboardData({
required this.totalTodos,
required this.activeTodos,
required this.completedTodos,
required this.todayTodos,
required this.overdueTodos,
required this.totalCategories,
required this.totalTags,
});
}
final dashboardProvider = FutureProvider<DashboardData>((ref) async {
final todoRepository = ref.watch(todoRepositoryProvider);
final categoryRepository = ref.watch(categoryRepositoryProvider);
final tagRepository = ref.watch(tagRepositoryProvider);
// 병렬로 데이터 가져오기
final results = await Future.wait([
todoRepository.getTotalTodoCount(),
todoRepository.getCompletedTodoCount(),
todoRepository.getAllTodos(),
categoryRepository.getAllCategories(),
tagRepository.getAllTags(),
]);
final totalTodos = results[0] as int;
final completedTodos = results[1] as int;
final allTodos = results[2] as List<TodoEntity>;
final categories = results[3] as List<CategoryEntity>;
final tags = results[4] as List<TagEntity>;
final now = DateTime.now();
final startOfDay = DateTime(now.year, now.month, now.day);
final endOfDay = startOfDay.add(const Duration(days: 1));
final todayTodos = allTodos.where((todo) {
return todo.dueDate != null &&
todo.dueDate!.isAfter(startOfDay) &&
todo.dueDate!.isBefore(endOfDay);
}).toList();
final overdueTodos = allTodos.where((todo) {
return !todo.isCompleted &&
todo.dueDate != null &&
todo.dueDate!.isBefore(now);
}).toList();
return DashboardData(
totalTodos: totalTodos,
activeTodos: totalTodos - completedTodos,
completedTodos: completedTodos,
todayTodos: todayTodos,
overdueTodos: overdueTodos,
totalCategories: categories.length,
totalTags: tags.length,
);
});
/// 카테고리별 Todo 통계
class CategoryStats {
final CategoryEntity category;
final int totalCount;
final int activeCount;
final int completedCount;
CategoryStats({
required this.category,
required this.totalCount,
required this.activeCount,
required this.completedCount,
});
}
final categoryStatsProvider = FutureProvider<List<CategoryStats>>((ref) async {
final categoryRepository = ref.watch(categoryRepositoryProvider);
final todoRepository = ref.watch(todoRepositoryProvider);
final categories = await categoryRepository.getAllCategories();
final allTodos = await todoRepository.getAllTodos();
return categories.map((category) {
final categoryTodos = allTodos.where((todo) => todo.categoryId == category.id);
final activeCount = categoryTodos.where((todo) => !todo.isCompleted).length;
final completedCount = categoryTodos.where((todo) => todo.isCompleted).length;
return CategoryStats(
category: category,
totalCount: categoryTodos.length,
activeCount: activeCount,
completedCount: completedCount,
);
}).toList();
});
/// 최근 활동 로그
class ActivityLog {
final DateTime date;
final String action;
final String description;
ActivityLog({
required this.date,
required this.action,
required this.description,
});
}
final recentActivityProvider = FutureProvider<List<ActivityLog>>((ref) async {
final todoRepository = ref.watch(todoRepositoryProvider);
final allTodos = await todoRepository.getAllTodos();
// 최근 업데이트된 순으로 정렬
final sortedTodos = allTodos.toList()
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return sortedTodos.take(10).map((todo) {
String action;
DateTime date;
if (todo.completedAt != null) {
action = '완료';
date = todo.completedAt!;
} else {
action = '생성';
date = todo.createdAt;
}
return ActivityLog(
date: date,
action: action,
description: todo.title,
);
}).toList();
});
8.4 Notifier 라이프사이클 관리
lib/presentation/providers/lifecycle_provider.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
/// 자동 새로고침 기능이 있는 Provider
class AutoRefreshNotifier extends StateNotifier<int> {
Timer? _timer;
final Ref _ref;
AutoRefreshNotifier(this._ref) : super(0) {
_startAutoRefresh();
}
void _startAutoRefresh() {
// 5분마다 자동 새로고침
_timer = Timer.periodic(const Duration(minutes: 5), (timer) {
_refresh();
});
}
void _refresh() {
// Provider 무효화하여 새로고침
_ref.invalidate(dashboardProvider);
state++;
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
final autoRefreshProvider = StateNotifierProvider<AutoRefreshNotifier, int>(
(ref) => AutoRefreshNotifier(ref),
);
/// 앱 생명주기 관리 Provider
class AppLifecycleNotifier extends StateNotifier<AppLifecycleState> {
AppLifecycleNotifier() : super(AppLifecycleState.resumed);
void onResume() {
state = AppLifecycleState.resumed;
}
void onPause() {
state = AppLifecycleState.paused;
}
void onInactive() {
state = AppLifecycleState.inactive;
}
void onDetached() {
state = AppLifecycleState.detached;
}
}
enum AppLifecycleState {
resumed,
paused,
inactive,
detached,
}
final appLifecycleProvider = StateNotifierProvider<AppLifecycleNotifier, AppLifecycleState>(
(ref) => AppLifecycleNotifier(),
);
8.5 캐싱 및 메모이제이션 전략
lib/presentation/providers/cached_providers.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/database/app_database.dart';
import 'repository_providers.dart';
/// 캐시 키
class CacheKey {
static const String todos = 'todos';
static const String categories = 'categories';
static const String tags = 'tags';
}
/// 메모리 캐시 Provider
final cacheProvider = Provider<Map<String, dynamic>>((ref) {
return {};
});
/// 캐시된 Todo 목록 Provider
final cachedTodosProvider = FutureProvider<List<TodoEntity>>((ref) async {
final cache = ref.watch(cacheProvider);
final repository = ref.watch(todoRepositoryProvider);
// 캐시 확인
if (cache.containsKey(CacheKey.todos)) {
final cachedData = cache[CacheKey.todos];
if (cachedData is CachedData<List<TodoEntity>>) {
// 캐시가 5분 이내면 사용
if (DateTime.now().difference(cachedData.timestamp).inMinutes < 5) {
return cachedData.data;
}
}
}
// 캐시가 없거나 만료되면 새로 가져오기
final todos = await repository.getAllTodos();
// 캐시 저장
cache[CacheKey.todos] = CachedData(
data: todos,
timestamp: DateTime.now(),
);
return todos;
});
/// 캐시 데이터 래퍼
class CachedData<T> {
final T data;
final DateTime timestamp;
CachedData({
required this.data,
required this.timestamp,
});
bool isExpired(Duration maxAge) {
return DateTime.now().difference(timestamp) > maxAge;
}
}
/// 스마트 캐싱 Provider (자동 무효화)
class SmartCacheNotifier<T> extends StateNotifier<AsyncValue<T>> {
final Future<T> Function() _loadData;
final Duration _maxAge;
DateTime? _lastUpdate;
SmartCacheNotifier({
required Future<T> Function() loadData,
Duration maxAge = const Duration(minutes: 5),
}) : _loadData = loadData,
_maxAge = maxAge,
super(const AsyncValue.loading()) {
_init();
}
Future<void> _init() async {
await refresh();
}
Future<void> refresh() async {
if (_shouldRefresh()) {
state = const AsyncValue.loading();
try {
final data = await _loadData();
state = AsyncValue.data(data);
_lastUpdate = DateTime.now();
} catch (e, stackTrace) {
state = AsyncValue.error(e, stackTrace);
}
}
}
bool _shouldRefresh() {
if (_lastUpdate == null) return true;
return DateTime.now().difference(_lastUpdate!) > _maxAge;
}
void invalidate() {
_lastUpdate = null;
}
}
8.6 상태 영속화
lib/presentation/providers/persisted_state_provider.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
/// 영속화된 필터 설정
class PersistedFilterSettings {
final String sortBy;
final bool ascending;
final String filterType;
PersistedFilterSettings({
required this.sortBy,
required this.ascending,
required this.filterType,
});
Map<String, dynamic> toJson() => {
'sortBy': sortBy,
'ascending': ascending,
'filterType': filterType,
};
factory PersistedFilterSettings.fromJson(Map<String, dynamic> json) {
return PersistedFilterSettings(
sortBy: json['sortBy'] as String? ?? 'createdAt',
ascending: json['ascending'] as bool? ?? false,
filterType: json['filterType'] as String? ?? 'all',
);
}
static const defaultSettings = PersistedFilterSettings(
sortBy: 'createdAt',
ascending: false,
filterType: 'all',
);
}
/// SharedPreferences Provider
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError('SharedPreferences must be initialized');
});
/// 영속화된 필터 설정 Notifier
class PersistedFilterNotifier extends StateNotifier<PersistedFilterSettings> {
final SharedPreferences _prefs;
static const String _key = 'filter_settings';
PersistedFilterNotifier(this._prefs)
: super(PersistedFilterSettings.defaultSettings) {
_loadSettings();
}
void _loadSettings() {
final jsonString = _prefs.getString(_key);
if (jsonString != null) {
try {
final json = jsonDecode(jsonString) as Map<String, dynamic>;
state = PersistedFilterSettings.fromJson(json);
} catch (e) {
// 파싱 실패 시 기본값 사용
state = PersistedFilterSettings.defaultSettings;
}
}
}
Future<void> updateSortBy(String sortBy) async {
state = PersistedFilterSettings(
sortBy: sortBy,
ascending: state.ascending,
filterType: state.filterType,
);
await _saveSettings();
}
Future<void> toggleSortOrder() async {
state = PersistedFilterSettings(
sortBy: state.sortBy,
ascending: !state.ascending,
filterType: state.filterType,
);
await _saveSettings();
}
Future<void> updateFilterType(String filterType) async {
state = PersistedFilterSettings(
sortBy: state.sortBy,
ascending: state.ascending,
filterType: filterType,
);
await _saveSettings();
}
Future<void> _saveSettings() async {
final jsonString = jsonEncode(state.toJson());
await _prefs.setString(_key, jsonString);
}
Future<void> reset() async {
state = PersistedFilterSettings.defaultSettings;
await _prefs.remove(_key);
}
}
// Provider는 SharedPreferences 초기화 후에 사용
// final persistedFilterProvider = StateNotifierProvider<PersistedFilterNotifier, PersistedFilterSettings>(
// (ref) {
// final prefs = ref.watch(sharedPreferencesProvider);
// return PersistedFilterNotifier(prefs);
// },
// );
8.7 Global State와 Scoped State
lib/presentation/providers/scoped_providers.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 전역 테마 상태
final themeProvider = StateNotifierProvider<ThemeNotifier, ThemeMode>((ref) {
return ThemeNotifier();
});
class ThemeNotifier extends StateNotifier<ThemeMode> {
ThemeNotifier() : super(ThemeMode.system);
void setThemeMode(ThemeMode mode) {
state = mode;
}
void toggleTheme() {
state = state == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
}
}
/// 페이지별 Scoped 상태
class PageState {
final int currentPage;
final bool isLoading;
PageState({
required this.currentPage,
required this.isLoading,
});
PageState copyWith({
int? currentPage,
bool? isLoading,
}) {
return PageState(
currentPage: currentPage ?? this.currentPage,
isLoading: isLoading ?? this.isLoading,
);
}
}
/// ProviderScope를 사용한 Scoped Provider
final pageStateProvider = StateProvider<PageState>((ref) {
return PageState(currentPage: 0, isLoading: false);
});
/// Scoped Provider를 사용하는 위젯 예시
class ScopedExample extends StatelessWidget {
const ScopedExample({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
// 이 scope 내에서만 다른 값으로 override
pageStateProvider.overrideWith((ref) {
return PageState(currentPage: 1, isLoading: true);
}),
],
child: const ChildWidget(),
);
}
}
class ChildWidget extends ConsumerWidget {
const ChildWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pageState = ref.watch(pageStateProvider);
return Text('Current Page: ${pageState.currentPage}');
}
}
9. 에러 핸들링
9.1 에러 타입 정의
lib/core/errors/exceptions.dart:
/// 베이스 예외 클래스
abstract class AppException implements Exception {
final String message;
final String? code;
final dynamic originalError;
final StackTrace? stackTrace;
AppException({
required this.message,
this.code,
this.originalError,
this.stackTrace,
});
@override
String toString() => 'AppException: $message (code: $code)';
}
/// 데이터베이스 예외
class DatabaseException extends AppException {
DatabaseException({
required super.message,
super.code,
super.originalError,
super.stackTrace,
});
@override
String toString() => 'DatabaseException: $message';
}
/// 검증 예외
class ValidationException extends AppException {
final Map<String, String>? fieldErrors;
ValidationException({
required super.message,
super.code,
this.fieldErrors,
super.originalError,
super.stackTrace,
});
@override
String toString() => 'ValidationException: $message';
}
/// 네트워크 예외 (추후 동기화 기능용)
class NetworkException extends AppException {
NetworkException({
required super.message,
super.code,
super.originalError,
super.stackTrace,
});
@override
String toString() => 'NetworkException: $message';
}
/// 권한 예외
class PermissionException extends AppException {
PermissionException({
required super.message,
super.code,
super.originalError,
super.stackTrace,
});
@override
String toString() => 'PermissionException: $message';
}
/// 비즈니스 로직 예외
class BusinessLogicException extends AppException {
BusinessLogicException({
required super.message,
super.code,
super.originalError,
super.stackTrace,
});
@override
String toString() => 'BusinessLogicException: $message';
}
9.2 Result 패턴 구현
lib/core/utils/result.dart:
/// Result 타입 (성공 또는 실패)
sealed class Result<T> {
const Result();
}
/// 성공 케이스
class Success<T> extends Result<T> {
final T data;
const Success(this.data);
@override
String toString() => 'Success(data: $data)';
}
/// 실패 케이스
class Failure<T> extends Result<T> {
final String message;
final String? code;
final dynamic error;
final StackTrace? stackTrace;
const Failure({
required this.message,
this.code,
this.error,
this.stackTrace,
});
@override
String toString() => 'Failure(message: $message, code: $code)';
}
/// Result 확장 메서드
extension ResultExtension<T> on Result<T> {
/// 성공 여부 확인
bool get isSuccess => this is Success<T>;
/// 실패 여부 확인
bool get isFailure => this is Failure<T>;
/// 데이터 가져오기 (실패 시 null)
T? get dataOrNull => switch (this) {
Success(data: final data) => data,
Failure() => null,
};
/// 데이터 가져오기 (실패 시 예외 발생)
T get data => switch (this) {
Success(data: final data) => data,
Failure(message: final message) =>
throw Exception('Cannot get data from Failure: $message'),
};
/// 에러 메시지 가져오기
String? get errorMessage => switch (this) {
Success() => null,
Failure(message: final message) => message,
};
/// 매핑
Result<R> map<R>(R Function(T data) transform) {
return switch (this) {
Success(data: final data) => Success(transform(data)),
Failure(
message: final message,
code: final code,
error: final error,
stackTrace: final stackTrace
) =>
Failure(
message: message,
code: code,
error: error,
stackTrace: stackTrace,
),
};
}
/// flatMap
Result<R> flatMap<R>(Result<R> Function(T data) transform) {
return switch (this) {
Success(data: final data) => transform(data),
Failure(
message: final message,
code: final code,
error: final error,
stackTrace: final stackTrace
) =>
Failure(
message: message,
code: code,
error: error,
stackTrace: stackTrace,
),
};
}
/// when 패턴 매칭
R when<R>({
required R Function(T data) success,
required R Function(String message, String? code) failure,
}) {
return switch (this) {
Success(data: final data) => success(data),
Failure(message: final message, code: final code) =>
failure(message, code),
};
}
/// 비동기 when
Future<R> whenAsync<R>({
required Future<R> Function(T data) success,
required Future<R> Function(String message, String? code) failure,
}) async {
return switch (this) {
Success(data: final data) => await success(data),
Failure(message: final message, code: final code) =>
await failure(message, code),
};
}
}
/// Result를 반환하는 함수 래퍼
Future<Result<T>> runCatching<T>(Future<T> Function() action) async {
try {
final result = await action();
return Success(result);
} on AppException catch (e) {
return Failure(
message: e.message,
code: e.code,
error: e,
stackTrace: e.stackTrace,
);
} catch (e, stackTrace) {
return Failure(
message: e.toString(),
error: e,
stackTrace: stackTrace,
);
}
}
/// 동기 버전
Result<T> runCatchingSync<T>(T Function() action) {
try {
final result = action();
return Success(result);
} on AppException catch (e) {
return Failure(
message: e.message,
code: e.code,
error: e,
stackTrace: e.stackTrace,
);
} catch (e, stackTrace) {
return Failure(
message: e.toString(),
error: e,
stackTrace: stackTrace,
);
}
}
9.3 에러 처리 Repository 래퍼
lib/data/repositories/safe_todo_repository.dart:
import '../../core/utils/result.dart';
import '../../core/errors/exceptions.dart';
import '../../domain/repositories/todo_repository.dart';
import '../../data/database/app_database.dart';
import 'package:drift/drift.dart';
/// 에러 처리가 포함된 안전한 Todo Repository
class SafeTodoRepository {
final TodoRepository _repository;
SafeTodoRepository(this._repository);
/// 안전한 Todo 생성
Future<Result<int>> createTodoSafe(TodosCompanion todo) async {
return runCatching(() async {
// 검증
final title = todo.title.value;
if (title.trim().isEmpty) {
throw ValidationException(
message: '제목을 입력해주세요',
code: 'EMPTY_TITLE',
);
}
if (title.length > 200) {
throw ValidationException(
message: '제목은 200자를 초과할 수 없습니다',
code: 'TITLE_TOO_LONG',
);
}
try {
return await _repository.createTodo(todo);
} on DriftException catch (e, stackTrace) {
throw DatabaseException(
message: '할 일 생성 중 오류가 발생했습니다',
code: 'CREATE_TODO_ERROR',
originalError: e,
stackTrace: stackTrace,
);
}
});
}
/// 안전한 Todo 업데이트
Future<Result<bool>> updateTodoSafe(TodoEntity todo) async {
return runCatching(() async {
// 검증
if (todo.title.trim().isEmpty) {
throw ValidationException(
message: '제목을 입력해주세요',
code: 'EMPTY_TITLE',
);
}
try {
return await _repository.updateTodo(todo);
} on DriftException catch (e, stackTrace) {
throw DatabaseException(
message: '할 일 업데이트 중 오류가 발생했습니다',
code: 'UPDATE_TODO_ERROR',
originalError: e,
stackTrace: stackTrace,
);
}
});
}
/// 안전한 Todo 삭제
Future<Result<int>> deleteTodoSafe(int id) async {
return runCatching(() async {
if (id <= 0) {
throw ValidationException(
message: '유효하지 않은 ID입니다',
code: 'INVALID_ID',
);
}
try {
return await _repository.deleteTodo(id);
} on DriftException catch (e, stackTrace) {
throw DatabaseException(
message: '할 일 삭제 중 오류가 발생했습니다',
code: 'DELETE_TODO_ERROR',
originalError: e,
stackTrace: stackTrace,
);
}
});
}
/// 안전한 Todo 조회
Future<Result<List<TodoEntity>>> getAllTodosSafe() async {
return runCatching(() async {
try {
return await _repository.getAllTodos();
} on DriftException catch (e, stackTrace) {
throw DatabaseException(
message: '할 일 목록을 불러오는 중 오류가 발생했습니다',
code: 'FETCH_TODOS_ERROR',
originalError: e,
stackTrace: stackTrace,
);
}
});
}
}
9.4 에러 핸들링 Provider
lib/presentation/providers/error_handling_provider.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/errors/exceptions.dart';
import '../../core/utils/result.dart';
import '../../data/database/app_database.dart';
import 'repository_providers.dart';
import '../repositories/safe_todo_repository.dart';
/// 안전한 Todo Repository Provider
final safeTodoRepositoryProvider = Provider<SafeTodoRepository>((ref) {
final repository = ref.watch(todoRepositoryProvider);
return SafeTodoRepository(repository);
});
/// 에러 상태를 포함한 Todo Notifier
class ErrorHandlingTodoNotifier extends StateNotifier<AsyncValue<List<TodoEntity>>> {
final SafeTodoRepository _repository;
ErrorHandlingTodoNotifier(this._repository) : super(const AsyncValue.loading()) {
_loadTodos();
}
Future<void> _loadTodos() async {
state = const AsyncValue.loading();
final result = await _repository.getAllTodosSafe();
state = result.when(
success: (todos) => AsyncValue.data(todos),
failure: (message, code) => AsyncValue.error(
AppException(message: message, code: code),
StackTrace.current,
),
);
}
Future<Result<void>> createTodo(TodosCompanion todo) async {
final result = await _repository.createTodoSafe(todo);
return result.when(
success: (_) {
_loadTodos(); // 성공 시 목록 새로고침
return const Success(null);
},
failure: (message, code) => Failure(
message: message,
code: code,
),
);
}
Future<Result<void>> updateTodo(TodoEntity todo) async {
final result = await _repository.updateTodoSafe(todo);
return result.when(
success: (_) {
_loadTodos();
return const Success(null);
},
failure: (message, code) => Failure(
message: message,
code: code,
),
);
}
Future<Result<void>> deleteTodo(int id) async {
final result = await _repository.deleteTodoSafe(id);
return result.when(
success: (_) {
_loadTodos();
return const Success(null);
},
failure: (message, code) => Failure(
message: message,
code: code,
),
);
}
Future<void> refresh() => _loadTodos();
}
final errorHandlingTodoProvider =
StateNotifierProvider<ErrorHandlingTodoNotifier, AsyncValue<List<TodoEntity>>>(
(ref) {
final repository = ref.watch(safeTodoRepositoryProvider);
return ErrorHandlingTodoNotifier(repository);
},
);
9.5 UI에서 에러 처리
lib/presentation/widgets/error_handler.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/errors/exceptions.dart';
/// AsyncValue를 위한 에러 핸들러 위젯
class AsyncValueHandler<T> extends StatelessWidget {
final AsyncValue<T> asyncValue;
final Widget Function(T data) data;
final Widget Function()? loading;
final Widget Function(Object error, StackTrace stackTrace)? error;
const AsyncValueHandler({
super.key,
required this.asyncValue,
required this.data,
this.loading,
this.error,
});
@override
Widget build(BuildContext context) {
return asyncValue.when(
data: data,
loading: () => loading?.call() ?? const Center(child: CircularProgressIndicator()),
error: (err, stack) => error?.call(err, stack) ??
ErrorView(error: err, stackTrace: stack),
);
}
}
/// 에러 표시 위젯
class ErrorView extends StatelessWidget {
final Object error;
final StackTrace stackTrace;
final VoidCallback? onRetry;
const ErrorView({
super.key,
required this.error,
required this.stackTrace,
this.onRetry,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final errorInfo = _getErrorInfo(error);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
errorInfo.icon,
size: 64,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
errorInfo.title,
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
errorInfo.message,
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
if (onRetry != null) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('다시 시도'),
),
],
],
),
),
);
}
_ErrorInfo _getErrorInfo(Object error) {
if (error is DatabaseException) {
return _ErrorInfo(
icon: Icons.storage,
title: '데이터베이스 오류',
message: error.message,
);
} else if (error is ValidationException) {
return _ErrorInfo(
icon: Icons.error_outline,
title: '입력 오류',
message: error.message,
);
} else if (error is NetworkException) {
return _ErrorInfo(
icon: Icons.wifi_off,
title: '네트워크 오류',
message: error.message,
);
} else if (error is PermissionException) {
return _ErrorInfo(
icon: Icons.lock,
title: '권한 오류',
message: error.message,
);
} else {
return _ErrorInfo(
icon: Icons.error,
title: '오류 발생',
message: error.toString(),
);
}
}
}
class _ErrorInfo {
final IconData icon;
final String title;
final String message;
_ErrorInfo({
required this.icon,
required this.title,
required this.message,
});
}
/// SnackBar로 에러 표시
void showErrorSnackBar(BuildContext context, Object error) {
String message;
if (error is AppException) {
message = error.message;
} else {
message = error.toString();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(message)),
],
),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
action: SnackBarAction(
label: '확인',
textColor: Colors.white,
onPressed: () {},
),
),
);
}
/// Result 타입을 처리하는 헬퍼
Future<void> handleResult<T>({
required BuildContext context,
required Future<Result<T>> Function() action,
required void Function(T data) onSuccess,
String? successMessage,
}) async {
final result = await action();
result.when(
success: (data) {
onSuccess(data);
if (successMessage != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(successMessage)),
);
}
},
failure: (message, code) {
if (context.mounted) {
showErrorSnackBar(context, AppException(message: message, code: code));
}
},
);
}
9.6 글로벌 에러 핸들러
lib/core/utils/error_logger.dart:
import 'dart:developer' as developer;
import '../errors/exceptions.dart';
/// 에러 로거 싱글톤
class ErrorLogger {
static final ErrorLogger _instance = ErrorLogger._internal();
factory ErrorLogger() => _instance;
ErrorLogger._internal();
/// 에러 로깅
void logError(
Object error, {
StackTrace? stackTrace,
String? context,
Map<String, dynamic>? additionalInfo,
}) {
final errorMessage = _formatError(error, stackTrace, context, additionalInfo);
// 개발 모드에서는 콘솔에 출력
developer.log(
errorMessage,
name: 'ErrorLogger',
error: error,
stackTrace: stackTrace,
level: 1000, // ERROR level
);
// 프로덕션에서는 분석 서비스에 전송
// _sendToAnalytics(error, stackTrace, context, additionalInfo);
}
String _formatError(
Object error,
StackTrace? stackTrace,
String? context,
Map<String, dynamic>? additionalInfo,
) {
final buffer = StringBuffer();
buffer.writeln('=== Error Log ===');
buffer.writeln('Time: ${DateTime.now()}');
if (context != null) {
buffer.writeln('Context: $context');
}
buffer.writeln('Error Type: ${error.runtimeType}');
if (error is AppException) {
buffer.writeln('Message: ${error.message}');
buffer.writeln('Code: ${error.code}');
if (error.originalError != null) {
buffer.writeln('Original Error: ${error.originalError}');
}
} else {
buffer.writeln('Error: $error');
}
if (additionalInfo != null && additionalInfo.isNotEmpty) {
buffer.writeln('Additional Info:');
additionalInfo.forEach((key, value) {
buffer.writeln(' $key: $value');
});
}
if (stackTrace != null) {
buffer.writeln('Stack Trace:');
buffer.writeln(stackTrace.toString());
}
buffer.writeln('===============');
return buffer.toString();
}
/// 프로덕션에서 분석 서비스로 전송 (추후 구현)
void _sendToAnalytics(
Object error,
StackTrace? stackTrace,
String? context,
Map<String, dynamic>? additionalInfo,
) {
// Firebase Crashlytics, Sentry 등에 전송
}
}
/// Provider에서 사용할 에러 로거
final errorLoggerProvider = Provider<ErrorLogger>((ref) {
return ErrorLogger();
});
10. 테스팅
10.1 단위 테스트 (Unit Tests)
test/data/database/daos/todo_dao_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:todo_drift_riverpod/data/database/app_database.dart';
import 'package:todo_drift_riverpod/data/database/daos/todo_dao.dart';
void main() {
late AppDatabase database;
late TodoDao todoDao;
setUp(() {
// 각 테스트마다 새로운 인메모리 데이터베이스 생성
database = AppDatabase.forTesting(NativeDatabase.memory());
todoDao = TodoDao(database);
});
tearDown(() async {
// 테스트 후 데이터베이스 정리
await database.close();
});
group('TodoDao Tests', () {
test('할 일 생성 테스트', () async {
// Given
final companion = TodosCompanion.insert(
title: '테스트 할 일',
description: const Value('테스트 설명'),
);
// When
final id = await todoDao.createTodo(companion);
// Then
expect(id, greaterThan(0));
final todo = await todoDao.getTodoById(id);
expect(todo, isNotNull);
expect(todo!.title, '테스트 할 일');
expect(todo.description, '테스트 설명');
});
test('할 일 조회 테스트', () async {
// Given
await todoDao.createTodo(
TodosCompanion.insert(title: '할 일 1'),
);
await todoDao.createTodo(
TodosCompanion.insert(title: '할 일 2'),
);
// When
final todos = await todoDao.getAllTodos();
// Then
expect(todos.length, 2);
expect(todos[0].title, '할 일 1');
expect(todos[1].title, '할 일 2');
});
test('할 일 업데이트 테스트', () async {
// Given
final id = await todoDao.createTodo(
TodosCompanion.insert(title: '원래 제목'),
);
final originalTodo = await todoDao.getTodoById(id);
// When
final updatedTodo = originalTodo!.copyWith(
title: '수정된 제목',
isCompleted: true,
);
await todoDao.updateTodo(updatedTodo);
// Then
final result = await todoDao.getTodoById(id);
expect(result!.title, '수정된 제목');
expect(result.isCompleted, true);
});
test('할 일 삭제 테스트', () async {
// Given
final id = await todoDao.createTodo(
TodosCompanion.insert(title: '삭제할 할 일'),
);
// When
await todoDao.deleteTodo(id);
// Then
final todo = await todoDao.getTodoById(id);
expect(todo, isNull);
});
test('완료된 할 일 필터링 테스트', () async {
// Given
final id1 = await todoDao.createTodo(
TodosCompanion.insert(title: '할 일 1'),
);
final id2 = await todoDao.createTodo(
TodosCompanion.insert(title: '할 일 2'),
);
final todo1 = await todoDao.getTodoById(id1);
await todoDao.updateTodo(
todo1!.copyWith(isCompleted: true),
);
// When
final completedTodos = await todoDao.watchCompletedTodos().first;
final activeTodos = await todoDao.watchActiveTodos().first;
// Then
expect(completedTodos.length, 1);
expect(completedTodos[0].id, id1);
expect(activeTodos.length, 1);
expect(activeTodos[0].id, id2);
});
test('우선순위별 Todo 조회 테스트', () async {
// Given
await todoDao.createTodo(
TodosCompanion.insert(
title: '낮은 우선순위',
priority: const Value(1),
),
);
await todoDao.createTodo(
TodosCompanion.insert(
title: '높은 우선순위',
priority: const Value(3),
),
);
// When
final highPriorityTodos = await todoDao.watchTodosByPriority(3).first;
final lowPriorityTodos = await todoDao.watchTodosByPriority(1).first;
// Then
expect(highPriorityTodos.length, 1);
expect(highPriorityTodos[0].priority, 3);
expect(lowPriorityTodos.length, 1);
expect(lowPriorityTodos[0].priority, 1);
});
test('Todo 검색 테스트', () async {
// Given
await todoDao.createTodo(
TodosCompanion.insert(
title: 'Flutter 공부하기',
description: const Value('Drift 배우기'),
),
);
await todoDao.createTodo(
TodosCompanion.insert(
title: 'React 공부하기',
),
);
// When
final flutterTodos = await todoDao.searchTodos('Flutter').first;
final driftTodos = await todoDao.searchTodos('Drift').first;
// Then
expect(flutterTodos.length, 1);
expect(flutterTodos[0].title, contains('Flutter'));
expect(driftTodos.length, 1);
expect(driftTodos[0].description, contains('Drift'));
});
test('Todo 통계 테스트', () async {
// Given
await todoDao.createTodo(
TodosCompanion.insert(title: '할 일 1'),
);
final id2 = await todoDao.createTodo(
TodosCompanion.insert(title: '할 일 2'),
);
final todo2 = await todoDao.getTodoById(id2);
await todoDao.updateTodo(
todo2!.copyWith(isCompleted: true),
);
// When
final totalCount = await todoDao.getTotalTodoCount();
final completedCount = await todoDao.getCompletedTodoCount();
// Then
expect(totalCount, 2);
expect(completedCount, 1);
});
});
}
10.2 Repository 테스트
test/data/repositories/todo_repository_impl_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'package:drift/drift.dart';
import 'package:todo_drift_riverpod/data/repositories/todo_repository_impl.dart';
import 'package:todo_drift_riverpod/data/database/daos/todo_dao.dart';
import 'package:todo_drift_riverpod/data/database/app_database.dart';
// Mock 클래스 생성
@GenerateMocks([TodoDao])
import 'todo_repository_impl_test.mocks.dart';
void main() {
late MockTodoDao mockTodoDao;
late TodoRepositoryImpl repository;
setUp(() {
mockTodoDao = MockTodoDao();
repository = TodoRepositoryImpl(mockTodoDao);
});
group('TodoRepositoryImpl Tests', () {
test('getAllTodos는 TodoDao의 getAllTodos를 호출해야 한다', () async {
// Given
final expectedTodos = <TodoEntity>[];
when(mockTodoDao.getAllTodos()).thenAnswer((_) async => expectedTodos);
// When
final result = await repository.getAllTodos();
// Then
expect(result, expectedTodos);
verify(mockTodoDao.getAllTodos()).called(1);
});
test('createTodo는 TodoDao의 createTodo를 호출해야 한다', () async {
// Given
final companion = TodosCompanion.insert(title: '테스트');
when(mockTodoDao.createTodo(companion)).thenAnswer((_) async => 1);
// When
final result = await repository.createTodo(companion);
// Then
expect(result, 1);
verify(mockTodoDao.createTodo(companion)).called(1);
});
test('deleteTodo는 TodoDao의 deleteTodo를 호출해야 한다', () async {
// Given
const todoId = 1;
when(mockTodoDao.deleteTodo(todoId)).thenAnswer((_) async => 1);
// When
final result = await repository.deleteTodo(todoId);
// Then
expect(result, 1);
verify(mockTodoDao.deleteTodo(todoId)).called(1);
});
});
}
10.3 Provider 테스트
test/presentation/providers/todo_provider_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'package:todo_drift_riverpod/presentation/providers/todo_provider.dart';
import 'package:todo_drift_riverpod/domain/repositories/todo_repository.dart';
import 'package:todo_drift_riverpod/data/database/app_database.dart';
@GenerateMocks([TodoRepository])
import 'todo_provider_test.mocks.dart';
void main() {
late MockTodoRepository mockRepository;
late ProviderContainer container;
setUp(() {
mockRepository = MockTodoRepository();
container = ProviderContainer(
overrides: [
todoRepositoryProvider.overrideWithValue(mockRepository),
],
);
});
tearDown(() {
container.dispose();
});
group('TodoListNotifier Tests', () {
test('초기 상태는 loading이어야 한다', () {
// Given & When
final state = container.read(todoListProvider);
// Then
expect(state.isLoading, true);
expect(state.todos, isEmpty);
});
test('createTodo는 새로운 할 일을 생성해야 한다', () async {
// Given
when(mockRepository.watchTodosSorted(
sortBy: anyNamed('sortBy'),
ascending: anyNamed('ascending'),
)).thenAnswer((_) => Stream.value([]));
when(mockRepository.createTodo(any)).thenAnswer((_) async => 1);
final notifier = container.read(todoListProvider.notifier);
// When
await notifier.createTodo(
title: '테스트 할 일',
description: '테스트 설명',
);
// Then
verify(mockRepository.createTodo(any)).called(1);
});
test('toggleTodoCompletion은 할 일의 완료 상태를 토글해야 한다', () async {
// Given
final todo = TodoEntity(
id: 1,
title: '테스트',
description: null,
isCompleted: false,
priority: 2,
categoryId: null,
dueDate: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
completedAt: null,
color: null,
hasReminder: false,
reminderTime: null,
repeatType: 'none',
notes: null,
);
when(mockRepository.watchTodosSorted(
sortBy: anyNamed('sortBy'),
ascending: anyNamed('ascending'),
)).thenAnswer((_) => Stream.value([todo]));
when(mockRepository.updateTodo(any)).thenAnswer((_) async => true);
final notifier = container.read(todoListProvider.notifier);
// When
await notifier.toggleTodoCompletion(todo);
// Then
verify(mockRepository.updateTodo(
argThat(predicate<TodoEntity>((t) => t.isCompleted == true)),
)).called(1);
});
test('setFilter는 필터를 변경해야 한다', () {
// Given
when(mockRepository.watchActiveTodos())
.thenAnswer((_) => Stream.value([]));
final notifier = container.read(todoListProvider.notifier);
// When
notifier.setFilter('active');
// Then
final state = container.read(todoListProvider);
expect(state.filterType, 'active');
});
test('setSortOption은 정렬 옵션을 변경해야 한다', () {
// Given
when(mockRepository.watchTodosSorted(
sortBy: 'title',
ascending: true,
)).thenAnswer((_) => Stream.value([]));
final notifier = container.read(todoListProvider.notifier);
// When
notifier.setSortOption('title', ascending: true);
// Then
final state = container.read(todoListProvider);
expect(state.sortBy, 'title');
expect(state.ascending, true);
});
});
}
10.4 위젯 테스트
test/presentation/widgets/todo_list_item_test.dart:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo_drift_riverpod/presentation/widgets/todo_list_item.dart';
import 'package:todo_drift_riverpod/data/database/app_database.dart';
void main() {
group('TodoListItem Widget Tests', () {
late TodoEntity testTodo;
setUp(() {
testTodo = TodoEntity(
id: 1,
title: '테스트 할 일',
description: '테스트 설명',
isCompleted: false,
priority: 2,
categoryId: null,
dueDate: DateTime(2024, 12, 31),
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
completedAt: null,
color: null,
hasReminder: false,
reminderTime: null,
repeatType: 'none',
notes: null,
);
});
testWidgets('할 일 제목이 표시되어야 한다', (tester) async {
// Given
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TodoListItem(
todo: testTodo,
onTap: () {},
onToggle: () {},
onDelete: () {},
),
),
),
);
// Then
expect(find.text('테스트 할 일'), findsOneWidget);
expect(find.text('테스트 설명'), findsOneWidget);
});
testWidgets('체크박스를 탭하면 onToggle이 호출되어야 한다', (tester) async {
// Given
var toggleCalled = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TodoListItem(
todo: testTodo,
onTap: () {},
onToggle: () => toggleCalled = true,
onDelete: () {},
),
),
),
);
// When
await tester.tap(find.byType(Checkbox));
await tester.pump();
// Then
expect(toggleCalled, true);
});
testWidgets('삭제 버튼을 탭하면 onDelete가 호출되어야 한다', (tester) async {
// Given
var deleteCalled = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TodoListItem(
todo: testTodo,
onTap: () {},
onToggle: () {},
onDelete: () => deleteCalled = true,
),
),
),
);
// When
await tester.tap(find.byIcon(Icons.delete_outline));
await tester.pump();
// Then
expect(deleteCalled, true);
});
testWidgets('완료된 할 일은 취소선이 표시되어야 한다', (tester) async {
// Given
final completedTodo = testTodo.copyWith(isCompleted: true);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TodoListItem(
todo: completedTodo,
onTap: () {},
onToggle: () {},
onDelete: () {},
),
),
),
);
// Then
final textWidget = tester.widget<Text>(
find.text('테스트 할 일'),
);
expect(
textWidget.style?.decoration,
TextDecoration.lineThrough,
);
});
testWidgets('우선순위가 높으면 우선순위 칩이 표시되어야 한다', (tester) async {
// Given
final highPriorityTodo = testTodo.copyWith(priority: 3);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TodoListItem(
todo: highPriorityTodo,
onTap: () {},
onToggle: () {},
onDelete: () {},
),
),
),
);
// Then
expect(find.text('높음'), findsOneWidget);
expect(find.byIcon(Icons.flag), findsOneWidget);
});
testWidgets('마감일이 있으면 날짜가 표시되어야 한다', (tester) async {
// Given
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TodoListItem(
todo: testTodo,
onTap: () {},
onToggle: () {},
onDelete: () {},
),
),
),
);
// Then
expect(find.text('12/31'), findsOneWidget);
expect(find.byIcon(Icons.calendar_today), findsOneWidget);
});
});
}
10.5 통합 테스트
integration_test/app_test.dart:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:integration_test/integration_test.dart';
import 'package:todo_drift_riverpod/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Todo App 통합 테스트', () {
testWidgets('할 일 생성, 완료, 삭제 플로우 테스트', (tester) async {
// 앱 시작
app.main();
await tester.pumpAndSettle();
// 할 일 추가 버튼 찾기
final addButton = find.byType(FloatingActionButton);
expect(addButton, findsOneWidget);
// 할 일 추가 버튼 탭
await tester.tap(addButton);
await tester.pumpAndSettle();
// 다이얼로그가 나타났는지 확인
expect(find.byType(Dialog), findsOneWidget);
// 제목 입력
final titleField = find.byType(TextFormField).first;
await tester.enterText(titleField, '통합 테스트 할 일');
await tester.pumpAndSettle();
// 추가 버튼 탭
final submitButton = find.text('추가');
await tester.tap(submitButton);
await tester.pumpAndSettle();
// 할 일이 목록에 추가되었는지 확인
expect(find.text('통합 테스트 할 일'), findsOneWidget);
// 체크박스 탭하여 완료 처리
final checkbox = find.byType(Checkbox).first;
await tester.tap(checkbox);
await tester.pumpAndSettle();
// 완료 탭으로 이동
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
// 완료된 할 일이 표시되는지 확인
expect(find.text('통합 테스트 할 일'), findsOneWidget);
// 삭제 버튼 탭
final deleteButton = find.byIcon(Icons.delete_outline).first;
await tester.tap(deleteButton);
await tester.pumpAndSettle();
// 확인 다이얼로그에서 삭제 확인
await tester.tap(find.text('삭제'));
await tester.pumpAndSettle();
// 할 일이 삭제되었는지 확인
expect(find.text('통합 테스트 할 일'), findsNothing);
});
testWidgets('검색 기능 테스트', (tester) async {
// 앱 시작
app.main();
await tester.pumpAndSettle();
// 여러 할 일 추가
for (int i = 0; i < 3; i++) {
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
final titleField = find.byType(TextFormField).first;
await tester.enterText(titleField, '할 일 $i');
await tester.pumpAndSettle();
await tester.tap(find.text('추가'));
await tester.pumpAndSettle();
}
// 검색 버튼 탭
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
// 검색어 입력
final searchField = find.byType(TextField);
await tester.enterText(searchField, '할 일 1');
await tester.pumpAndSettle();
// 검색 결과 확인
expect(find.text('할 일 1'), findsOneWidget);
expect(find.text('할 일 0'), findsNothing);
expect(find.text('할 일 2'), findsNothing);
});
testWidgets('필터 및 정렬 테스트', (tester) async {
// 앱 시작
app.main();
await tester.pumpAndSettle();
// 필터 버튼 탭
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pumpAndSettle();
// 정렬 기준 선택
await tester.tap(find.text('제목'));
await tester.pumpAndSettle();
// 오름차순 선택
await tester.tap(find.byType(SwitchListTile));
await tester.pumpAndSettle();
// 적용 버튼 탭
await tester.tap(find.text('적용'));
await tester.pumpAndSettle();
// 정렬이 적용되었는지 확인
// (실제 확인 로직은 할 일 목록에 따라 다름)
});
});
}
11. 성능 최적화
11.1 데이터베이스 최적화
인덱스 추가:
// lib/data/database/tables/todos_table.dart에 추가
@DataClassName('TodoEntity')
class Todos extends Table {
// ... 기존 컬럼들 ...
// 인덱스 정의
@override
List<Set<Column>> get uniqueKeys => [
// 복합 고유 키 정의 (필요시)
];
}
// lib/data/database/app_database.dart에 추가
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
// 인덱스 생성
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_todos_due_date ON todos(due_date)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_todos_category_id ON todos(category_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_todos_is_completed ON todos(is_completed)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_todos_priority ON todos(priority)',
);
await _insertDefaultData();
},
// ...
);
}
쿼리 최적화:
// lib/data/database/daos/optimized_todo_dao.dart
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/todos_table.dart';
part 'optimized_todo_dao.g.dart';
@DriftAccessor(tables: [Todos])
class OptimizedTodoDao extends DatabaseAccessor<AppDatabase>
with _$OptimizedTodoDaoMixin {
OptimizedTodoDao(super.db);
/// 페이지네이션을 사용한 대량 데이터 조회
Future<List<TodoEntity>> getTodosPaginated({
required int page,
required int pageSize,
}) {
final query = select(todos)
..limit(pageSize, offset: page * pageSize)
..orderBy([
(t) => OrderingTerm(expression: t.createdAt, mode: OrderingMode.desc),
]);
return query.get();
}
/// 배치 삽입 (트랜잭션 사용)
Future<void> batchInsertTodos(List<TodosCompanion> todosList) {
return transaction(() async {
await batch((batch) {
batch.insertAll(todos, todosList);
});
});
}
/// 배치 업데이트
Future<void> batchUpdateTodos(List<TodoEntity> todosList) {
return transaction(() async {
await batch((batch) {
batch.replaceAll(todos, todosList);
});
});
}
/// 최적화된 검색 (LIKE 대신 FTS 사용 가능)
Stream<List<TodoEntity>> searchTodosOptimized(String query) {
if (query.isEmpty) {
return Stream.value([]);
}
// 간단한 토큰화
final tokens = query.toLowerCase().split(' ');
return (select(todos)..where((t) {
Expression<bool> condition = const Constant(true);
for (final token in tokens) {
final pattern = '%$token%';
condition = condition & (
t.title.lower().like(pattern) |
t.description.lower().like(pattern)
);
}
return condition;
})).watch();
}
/// 통계 쿼리 최적화 (한 번의 쿼리로 여러 통계 가져오기)
Future<Map<String, int>> getStatistics() async {
final totalCountExpr = countAll();
final completedCountExpr = todos.isCompleted.count(
filter: todos.isCompleted.equals(true),
);
final query = selectOnly(todos)
..addColumns([totalCountExpr, completedCountExpr]);
final result = await query.getSingle();
return {
'total': result.read(totalCountExpr) ?? 0,
'completed': result.read(completedCountExpr) ?? 0,
};
}
}
11.2 Provider 최적화
Provider 분리 및 선택적 구독:
// lib/presentation/providers/optimized_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/database/app_database.dart';
import 'repository_providers.dart';
/// Todo 개수만 구독하는 Provider
final todoCountProvider = StreamProvider<int>((ref) async* {
final repository = ref.watch(todoRepositoryProvider);
// 1초마다 개수만 확인 (부하 감소)
await for (final _ in Stream.periodic(const Duration(seconds: 1))) {
yield await repository.getTotalTodoCount();
}
});
/// 특정 필드만 구독하는 Provider
final todoTitlesProvider = StreamProvider<List<String>>((ref) {
final repository = ref.watch(todoRepositoryProvider);
return repository.watchAllTodos().map((todos) {
// 제목만 추출하여 반환 (메모리 절약)
return todos.map((todo) => todo.title).toList();
});
});
/// Selector를 사용한 최적화
class TodoListState {
final List<TodoEntity> todos;
final bool isLoading;
TodoListState({required this.todos, required this.isLoading});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TodoListState &&
runtimeType == other.runtimeType &&
todos.length == other.todos.length &&
isLoading == other.isLoading;
@override
int get hashCode => todos.length.hashCode ^ isLoading.hashCode;
}
/// 필요한 부분만 재구독
final todoListStateProvider = StateProvider.family<TodoListState, String>(
(ref, filter) {
// filter에 따라 다른 상태 반환
return TodoListState(todos: [], isLoading: false);
},
);
Computed Provider 패턴:
// lib/presentation/providers/computed_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/database/app_database.dart';
import 'todo_provider.dart';
/// 파생된 상태를 계산하는 Provider
final activeTodoCountProvider = Provider<int>((ref) {
final stats = ref.watch(todoStatsProvider).valueOrNull;
return stats?.active ?? 0;
});
final completionRateProvider = Provider<double>((ref) {
final stats = ref.watch(todoStatsProvider).valueOrNull;
return stats?.completionRate ?? 0;
});
/// 메모이제이션된 필터링
final filteredTodosProvider = Provider.family<List<TodoEntity>, TodoFilter>(
(ref, filter) {
final allTodos = ref.watch(todoListProvider).todos;
return allTodos.where((todo) {
if (filter.categoryId != null && todo.categoryId != filter.categoryId) {
return false;
}
if (filter.priority != null && todo.priority != filter.priority) {
return false;
}
if (filter.isCompleted != null && todo.isCompleted != filter.isCompleted) {
return false;
}
return true;
}).toList();
},
);
class TodoFilter {
final int? categoryId;
final int? priority;
final bool? isCompleted;
TodoFilter({
this.categoryId,
this.priority,
this.isCompleted,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TodoFilter &&
runtimeType == other.runtimeType &&
categoryId == other.categoryId &&
priority == other.priority &&
isCompleted == other.isCompleted;
@override
int get hashCode =>
categoryId.hashCode ^ priority.hashCode ^ isCompleted.hashCode;
}
11.3 위젯 최적화
const 생성자 활용:
// lib/presentation/widgets/optimized_widgets.dart
import 'package:flutter/material.dart';
/// const 생성자를 사용한 정적 위젯
class StaticHeader extends StatelessWidget {
const StaticHeader({super.key});
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'할 일 목록',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
);
}
}
/// 불필요한 재빌드 방지
class OptimizedTodoList extends StatelessWidget {
final List<TodoEntity> todos;
const OptimizedTodoList({
super.key,
required this.todos,
});
@override
Widget build(BuildContext context) {
return ListView.builder(
// itemExtent를 지정하여 성능 향상
itemExtent: 80.0,
itemCount: todos.length,
// addAutomaticKeepAlives를 false로 설정
addAutomaticKeepAlives: false,
// addRepaintBoundaries를 false로 설정 (필요시)
addRepaintBoundaries: true,
itemBuilder: (context, index) {
final todo = todos[index];
// Key를 사용하여 위젯 재사용
return TodoListItem(
key: ValueKey(todo.id),
todo: todo,
onTap: () {},
onToggle: () {},
onDelete: () {},
);
},
);
}
}
RepaintBoundary 사용:
// 복잡한 위젯을 RepaintBoundary로 감싸기
class OptimizedComplexWidget extends StatelessWidget {
const OptimizedComplexWidget({super.key});
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: Column(
children: [
// 복잡한 그래픽 연산이 포함된 위젯들
CustomPaint(
painter: ComplexPainter(),
),
// ...
],
),
);
}
}
class ComplexPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// 복잡한 그리기 로직
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
11.4 이미지 및 리소스 최적화
// lib/core/utils/image_cache_manager.dart
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
class ImageCacheManager {
static final ImageCacheManager _instance = ImageCacheManager._internal();
factory ImageCacheManager() => _instance;
ImageCacheManager._internal();
late final CacheManager _cacheManager;
void init() {
_cacheManager = CacheManager(
Config(
'customCacheKey',
stalePeriod: const Duration(days: 7),
maxNrOfCacheObjects: 100,
),
);
}
CacheManager get cacheManager => _cacheManager;
/// 캐시된 이미지 위젯
Widget cachedImage(String url, {BoxFit? fit}) {
return Image(
image: NetworkImage(url),
fit: fit,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded) return child;
return AnimatedOpacity(
opacity: frame == null ? 0 : 1,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
child: child,
);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
);
},
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.error);
},
);
}
/// 캐시 정리
Future<void> clearCache() async {
await _cacheManager.emptyCache();
}
}
11.5 메모리 관리
// lib/core/utils/memory_manager.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
class MemoryManager {
static final MemoryManager _instance = MemoryManager._internal();
factory MemoryManager() => _instance;
MemoryManager._internal();
Timer? _memoryCheckTimer;
void startMonitoring() {
_memoryCheckTimer = Timer.periodic(
const Duration(minutes: 5),
(_) => _checkMemoryUsage(),
);
}
void stopMonitoring() {
_memoryCheckTimer?.cancel();
_memoryCheckTimer = null;
}
void _checkMemoryUsage() {
if (kDebugMode) {
// 메모리 사용량 체크 (디버그 모드에서만)
print('Memory check performed');
// 필요시 캐시 정리
// _clearCaches();
}
}
void _clearCaches() {
// 이미지 캐시 정리
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
// 커스텀 캐시 정리
// ...
}
/// 메모리 경고 시 호출
void onMemoryWarning() {
_clearCaches();
}
}
void _loadMore() { setState(() { _currentPage++; }); // 다음 페이지 로드 }
@override void dispose() { _scrollController.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return ListView.builder( controller: _scrollController, itemCount: (_currentPage + 1) * _pageSize, itemBuilder: (context, index) { // 아이템 빌드 return TodoListItem(/* ... */); }, ); } }
### 12.6 보안 베스트 프랙티스
**민감한 데이터 보호**:
```dart
// lib/core/security/secure_storage.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecureStorage {
static final SecureStorage _instance = SecureStorage._internal();
factory SecureStorage() => _instance;
SecureStorage._internal();
final _storage = const FlutterSecureStorage();
// ✅ 민감한 데이터는 암호화하여 저장
Future<void> saveSecureData(String key, String value) async {
await _storage.write(key: key, value: value);
}
Future<String?> readSecureData(String key) async {
return await _storage.read(key: key);
}
Future<void> deleteSecureData(String key) async {
await _storage.delete(key: key);
}
Future<void> deleteAll() async {
await _storage.deleteAll();
}
}
// 사용 예
class AuthRepository {
final SecureStorage _secureStorage = SecureStorage();
Future<void> saveAuthToken(String token) async {
// ✅ 토큰은 암호화된 저장소에 보관
await _secureStorage.saveSecureData('auth_token', token);
}
Future<String?> getAuthToken() async {
return await _secureStorage.readSecureData('auth_token');
}
}
SQL Injection 방지:
// ✅ Drift는 자동으로 SQL Injection 방지
// 파라미터화된 쿼리 사용
class SafeTodoDao extends DatabaseAccessor<AppDatabase> {
SafeTodoDao(super.db);
// ✅ 좋은 예: Drift의 타입 안전 쿼리 사용
Future<List<TodoEntity>> searchTodos(String query) {
return (select(todos)
..where((t) => t.title.like('%$query%')))
.get();
}
// ❌ 나쁜 예: Raw SQL with string interpolation (사용하지 말 것)
// Future<List<TodoEntity>> unsafeSearch(String query) async {
// final result = await customSelect(
// "SELECT * FROM todos WHERE title LIKE '%$query%'",
// ).get();
// return result.map((row) => /* ... */).toList();
// }
// ✅ Raw SQL이 필요한 경우 파라미터 바인딩 사용
Future<List<TodoEntity>> safeRawSearch(String query) async {
final result = await customSelect(
'SELECT * FROM todos WHERE title LIKE ?',
variables: [Variable.withString('%$query%')],
).get();
return result.map((row) => /* ... */).toList();
}
}
12.7 테스팅 베스트 프랙티스
테스트 가능한 코드 작성:
// ✅ 의존성 주입을 통한 테스트 가능한 코드
// 인터페이스 정의
abstract class TodoRepository {
Future<List<TodoEntity>> getAllTodos();
Future<void> createTodo(TodosCompanion todo);
}
// 실제 구현
class TodoRepositoryImpl implements TodoRepository {
final TodoDao _dao;
TodoRepositoryImpl(this._dao);
@override
Future<List<TodoEntity>> getAllTodos() => _dao.getAllTodos();
@override
Future<void> createTodo(TodosCompanion todo) async {
await _dao.createTodo(todo);
}
}
// 테스트용 Mock 구현
class MockTodoRepository implements TodoRepository {
List<TodoEntity> _todos = [];
@override
Future<List<TodoEntity>> getAllTodos() async => _todos;
@override
Future<void> createTodo(TodosCompanion todo) async {
// Mock 동작
_todos.add(/* ... */);
}
}
// Provider에서 쉽게 교체 가능
final todoRepositoryProvider = Provider<TodoRepository>((ref) {
// 프로덕션
return TodoRepositoryImpl(ref.watch(todoDaoProvider));
});
// 테스트에서
void main() {
test('Todo 목록 가져오기', () async {
final container = ProviderContainer(
overrides: [
todoRepositoryProvider.overrideWithValue(MockTodoRepository()),
],
);
// 테스트 진행
});
}
Golden Test 활용:
// test/widget/golden_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
void main() {
group('Golden Tests', () {
testGoldens('TodoListItem UI 테스트', (tester) async {
final todo = TodoEntity(
id: 1,
title: '테스트 할 일',
description: '상세 설명',
isCompleted: false,
priority: 2,
categoryId: null,
dueDate: DateTime(2024, 12, 31),
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
completedAt: null,
color: null,
hasReminder: false,
reminderTime: null,
repeatType: 'none',
notes: null,
);
await tester.pumpWidgetBuilder(
TodoListItem(
todo: todo,
onTap: () {},
onToggle: () {},
onDelete: () {},
),
);
await screenMatchesGolden(tester, 'todo_list_item');
});
testGoldens('다양한 상태의 TodoListItem', (tester) async {
await tester.pumpWidgetBuilder(
Column(
children: [
TodoListItem(todo: activeTodo, /* ... */),
TodoListItem(todo: completedTodo, /* ... */),
TodoListItem(todo: overdueTodo, /* ... */),
],
),
);
await multiScreenGolden(
tester,
'todo_list_item_states',
devices: [Device.phone, Device.tablet],
);
});
});
}
12.8 문서화 베스트 프랙티스
코드 주석:
/// 할 일 Repository 인터페이스
///
/// 할 일 데이터에 접근하기 위한 추상 인터페이스를 정의합니다.
/// 실제 구현은 [TodoRepositoryImpl]을 참조하세요.
///
/// Example:
/// ```dart
/// final repository = ref.watch(todoRepositoryProvider);
/// final todos = await repository.getAllTodos();
/// ```
abstract class TodoRepository {
/// 모든 할 일 목록을 반환합니다.
///
/// 삭제되지 않은 모든 할 일을 생성일 기준 내림차순으로 정렬하여 반환합니다.
///
/// Returns: 할 일 엔티티 목록
/// Throws: [DatabaseException] 데이터베이스 오류 발생 시
Future<List<TodoEntity>> getAllTodos();
/// 새로운 할 일을 생성합니다.
///
/// [todo]에 제공된 정보로 새로운 할 일을 데이터베이스에 저장합니다.
///
/// Parameters:
/// - [todo]: 생성할 할 일 정보
///
/// Returns: 생성된 할 일의 ID
/// Throws:
/// - [ValidationException] 유효하지 않은 데이터인 경우
/// - [DatabaseException] 데이터베이스 오류 발생 시
Future<int> createTodo(TodosCompanion todo);
/// 할 일을 업데이트합니다.
///
/// 기존 할 일의 정보를 [todo]에 제공된 정보로 업데이트합니다.
/// [TodoEntity.updatedAt]은 자동으로 현재 시각으로 설정됩니다.
///
/// Parameters:
/// - [todo]: 업데이트할 할 일 엔티티
///
/// Returns: 업데이트 성공 여부
/// Throws:
/// - [NotFoundException] 해당 ID의 할 일이 없는 경우
/// - [DatabaseException] 데이터베이스 오류 발생 시
Future<bool> updateTodo(TodoEntity todo);
/// 할 일을 삭제합니다.
///
/// [id]에 해당하는 할 일을 데이터베이스에서 영구적으로 삭제합니다.
/// 관련된 태그 관계도 함께 삭제됩니다.
///
/// Parameters:
/// - [id]: 삭제할 할 일의 ID
///
/// Returns: 삭제된 행의 수 (0 또는 1)
Future<int> deleteTodo(int id);
}
README 작성:
# Todo App with Drift & Riverpod
Flutter, Drift, Riverpod를 사용한 할 일 관리 애플리케이션입니다.
## 주요 기능
- ✅ 할 일 생성, 수정, 삭제
- 📁 카테고리별 관리
- 🏷️ 태그 시스템
- 🔍 검색 및 필터링
- 📊 통계 및 대시보드
- 🔔 알림 (예정)
- 💾 오프라인 지원
## 기술 스택
- **Flutter** 3.x
- **Drift** 2.x - 로컬 데이터베이스
- **Riverpod** 2.x - 상태 관리
- **Material 3** - UI 디자인
## 프로젝트 구조
lib/ ├── core/ # 핵심 유틸리티 ├── data/ # 데이터 레이어 │ ├── database/ # Drift 데이터베이스 │ └── repositories/ # Repository 구현 ├── domain/ # 도메인 레이어 │ ├── models/ # 도메인 모델 │ └── repositories/ # Repository 인터페이스 └── presentation/ # 프레젠테이션 레이어 ├── providers/ # Riverpod Providers ├── screens/ # 화면 └── widgets/ # 위젯
## 시작하기
### 요구사항
- Flutter SDK 3.0.0 이상
- Dart SDK 3.0.0 이상
### 설치
1. 저장소 클론:
```bash
git clone https://github.com/yourusername/todo_drift_riverpod.git
cd todo_drift_riverpod
- 의존성 설치:
flutter pub get
- 코드 생성:
flutter pub run build_runner build --delete-conflicting-outputs
- 앱 실행:
flutter run
테스팅
# 단위 테스트
flutter test
# 통합 테스트
flutter test integration_test/
# 커버리지 리포트
flutter test --coverage
라이센스
MIT License
기여
Pull Request는 언제나 환영합니다!
### 12.9 CI/CD 베스트 프랙티스
**GitHub Actions 예시**:
```yaml
# .github/workflows/flutter.yml
name: Flutter CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.16.0'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
- name: Generate code
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Analyze
run: flutter analyze
- name: Run tests
run: flutter test --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: coverage/lcov.info
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.16.0'
- name: Install dependencies
run: flutter pub get
- name: Generate code
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Build APK
run: flutter build apk --release
- name: Upload APK
uses: actions/upload-artifact@v3
with:
name: app-release
path: build/app/outputs/flutter-apk/app-release.apk
12.10 배포 체크리스트
프로덕션 배포 전 체크리스트:
// lib/core/config/app_config.dart
class AppConfig {
// ✅ 환경별 설정 분리
static const bool isProduction = bool.fromEnvironment('dart.vm.product');
static const String apiUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'https://api.example.com',
);
// ✅ 디버그 모드 확인
static bool get isDebugMode => !isProduction;
// ✅ 로깅 레벨 설정
static LogLevel get logLevel =>
isProduction ? LogLevel.error : LogLevel.debug;
// ✅ 분석 활성화
static bool get enableAnalytics => isProduction;
// ✅ 크래시 리포팅
static bool get enableCrashReporting => isProduction;
}
// main.dart에서 사용
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// ✅ 에러 핸들러 설정
FlutterError.onError = (details) {
if (AppConfig.isProduction) {
// 크래시 리포팅 서비스로 전송
// FirebaseCrashlytics.instance.recordFlutterError(details);
} else {
FlutterError.dumpErrorToConsole(details);
}
};
// ✅ 플랫폼 에러 핸들러
PlatformDispatcher.instance.onError = (error, stack) {
if (AppConfig.isProduction) {
// 크래시 리포팅
}
return true;
};
runApp(const ProviderScope(child: MyApp()));
}
배포 전 체크리스트:
## 배포 전 체크리스트
### 코드 품질
- [ ] 모든 테스트 통과
- [ ] 코드 커버리지 80% 이상
- [ ] Lint 경고 없음
- [ ] 사용하지 않는 import 제거
- [ ] 주석 처리된 코드 제거
### 성능
- [ ] 메모리 누수 확인
- [ ] 60 FPS 유지 확인
- [ ] 앱 시작 시간 확인 (3초 이내)
- [ ] 데이터베이스 쿼리 최적화
- [ ] 이미지 최적화
### 보안
- [ ] 민감한 데이터 암호화
- [ ] API 키 환경 변수로 분리
- [ ] ProGuard/R8 설정 (Android)
- [ ] 코드 난독화 활성화
### UI/UX
- [ ] 다양한 화면 크기 테스트
- [ ] 다크 모드 지원
- [ ] 접근성 확인
- [ ] 에러 메시지 사용자 친화적으로 작성
- [ ] 로딩 상태 표시
### 문서화
- [ ] README 업데이트
- [ ] CHANGELOG 작성
- [ ] API 문서 최신화
- [ ] 주요 기능 스크린샷 추가
### 스토어 준비 (모바일)
- [ ] 앱 아이콘 설정
- [ ] 스플래시 스크린
- [ ] 앱 설명 작성
- [ ] 스크린샷 준비 (최소 5개)
- [ ] 개인정보 처리방침
- [ ] 이용약관
### 기타
- [ ] 버전 번호 업데이트
- [ ] 빌드 번호 증가
- [ ] 릴리스 노트 작성
- [ ] 백업 계획 수립
13. 마무리 및 추가 리소스
13.1 프로젝트 완성도 체크
이제 여러분은 Flutter, Drift, Riverpod를 사용한 완전한 Todo 애플리케이션을 구축했습니다. 다음 사항들을 확인해보세요:
✅ 완료된 기능들:
- 데이터 영속성
- Drift를 사용한 SQLite 데이터베이스
- 테이블 설계 (Todos, Categories, Tags)
- DAO 패턴 구현
- 복잡한 쿼리 및 관계 처리
- 상태 관리
- Riverpod Provider 설계
- StateNotifier 구현
- 의존성 주입
- 리액티브 데이터 흐름
- 아키텍처
- Clean Architecture 적용
- Repository 패턴
- 관심사 분리
- 확장 가능한 구조
- UI/UX
- Material 3 디자인
- 반응형 레이아웃
- 로딩 및 에러 상태 처리
- 사용자 친화적 인터페이스
- 고급 기능
- 검색 및 필터링
- 정렬 옵션
- 통계 대시보드
- 낙관적 업데이트
- 품질 보증
- 단위 테스트
- 위젯 테스트
- 통합 테스트
- 에러 핸들링
13.2 다음 단계
프로젝트를 더욱 발전시키고 싶다면 다음 기능들을 추가해보세요:
추천 추가 기능:
- 동기화 기능
- // Firebase 또는 커스텀 백엔드와 동기화 class SyncService { final TodoRepository _localRepository; final RemoteApiClient _remoteClient; Future<void> sync() async { // 로컬 변경사항을 서버로 푸시 final localChanges = await _localRepository.getChangedSince(lastSyncTime); await _remoteClient.uploadChanges(localChanges); // 서버 변경사항을 로컬로 풀 final remoteChanges = await _remoteClient.fetchChanges(lastSyncTime); await _localRepository.applyChanges(remoteChanges); } }
- 반복 일정
- // 반복되는 할 일 처리 class RecurringTodoService { Future<void> createRecurringInstances(TodoEntity template) async { switch (template.repeatType) { case 'daily': // 매일 반복 break; case 'weekly': // 매주 반복 break; case 'monthly': // 매월 반복 break; } } }
- 알림 시스템
- // flutter_local_notifications 사용 class NotificationService { Future<void> scheduleNotification(TodoEntity todo) async { if (todo.hasReminder && todo.reminderTime != null) { await notificationsPlugin.zonedSchedule( todo.id, '할 일 알림', todo.title, // 알림 시간 설정 ); } } }
- 첨부파일 지원
- // 이미지, 파일 첨부 class AttachmentTable extends Table { IntColumn get id => integer().autoIncrement()(); IntColumn get todoId => integer().references(Todos, #id)(); TextColumn get filePath => text()(); TextColumn get fileType => text()(); }
- 협업 기능
- // 할 일 공유 및 협업 class SharedTodo extends Table { IntColumn get id => integer().autoIncrement()(); IntColumn get todoId => integer().references(Todos, #id)(); TextColumn get sharedWithUserId => text()(); TextColumn get permission => text()(); // read, write, owner }
- 데이터 백업 및 복원
- class BackupService { Future<void> backupToFile() async { final database = AppDatabase(); final todos = await database.select(database.todos).get(); final json = jsonEncode(todos.map((t) => t.toJson()).toList()); // 파일로 저장 } Future<void> restoreFromFile(String filePath) async { // 파일에서 복원 } }
13.3 학습 리소스
공식 문서:
- Flutter
- 공식 문서: https://docs.flutter.dev
- API 레퍼런스: https://api.flutter.dev
- Cookbook: https://docs.flutter.dev/cookbook
- Drift
- 공식 문서: https://drift.simonbinder.eu
- Getting Started: https://drift.simonbinder.eu/docs/getting-started
- 예제: https://drift.simonbinder.eu/docs/examples
- Riverpod
- 공식 문서: https://riverpod.dev
- 마이그레이션 가이드: https://riverpod.dev/docs/migration
- 베스트 프랙티스: https://riverpod.dev/docs/essentials/first_request
추천 강좌 및 튜토리얼:
- Flutter & Dart - The Complete Guide [Udemy]
- Reso Coder - Flutter TDD Clean Architecture
- Flutter Official YouTube Channel
- Riverpod Official Documentation & Examples
커뮤니티 및 지원:
- Flutter Discord: https://discord.gg/flutter
- Flutter Reddit: r/FlutterDev
- Stack Overflow: [flutter] 태그
- GitHub Discussions
13.4 자주 묻는 질문 (FAQ)
Q: Drift vs Hive vs Isar, 어떤 것을 선택해야 하나요?
A: 각각의 장단점이 있습니다:
- Drift: SQL 기반, 복잡한 쿼리, 관계형 데이터에 적합
- Hive: 간단한 key-value 저장소, 빠른 성능
- Isar: NoSQL, 빠른 성능, 간단한 API
복잡한 관계와 쿼리가 필요하면 Drift, 단순한 데이터 저장이면 Hive나 Isar를 추천합니다.
Q: Riverpod vs Provider vs Bloc, 어떤 것이 좋나요?
A:
- Riverpod: 컴파일 타임 안정성, 테스트 용이, 최신 권장사항
- Provider: 간단하지만 런타임 에러 가능성
- Bloc: 복잡한 상태 관리, 많은 보일러플레이트
대부분의 경우 Riverpod를 추천합니다.
Q: 데이터베이스 마이그레이션 중 데이터 손실을 어떻게 방지하나요?
A:
@override
MigrationStrategy get migration {
return MigrationStrategy(
onUpgrade: (m, from, to) async {
// 1. 백업 테이블 생성
await m.createTable(todosBackup);
// 2. 데이터 복사
await customStatement(
'INSERT INTO todos_backup SELECT * FROM todos',
);
// 3. 마이그레이션 수행
await m.addColumn(todos, todos.newColumn);
// 4. 데이터 복원
await customStatement(
'UPDATE todos SET new_column = (SELECT value FROM todos_backup WHERE id = todos.id)',
);
// 5. 백업 테이블 삭제
await m.deleteTable(todosBackup.tableName);
},
);
}
Q: 프로덕션에서 성능 이슈가 발생하면 어떻게 디버깅하나요?
A: Flutter DevTools를 사용하세요:
- Performance 탭에서 프레임 드롭 확인
- Memory 탭에서 메모리 누수 확인
- Network 탭에서 API 호출 모니터링
- Timeline에서 빌드/레이아웃 시간 확인
13.5 결론
이 튜토리얼을 통해 다음을 학습했습니다:
- Drift 데이터베이스
- 테이블 설계 및 관계 정의
- DAO 패턴으로 데이터 접근 캡슐화
- 복잡한 쿼리 및 조인 처리
- 마이그레이션 전략
- Riverpod 상태 관리
- Provider 설계 및 조합
- StateNotifier를 통한 상태 관리
- 의존성 주입 패턴
- 테스트 가능한 구조
- Clean Architecture
- 계층 분리 (Data, Domain, Presentation)
- Repository 패턴
- 관심사 분리
- 확장 가능한 코드 구조
- 실전 앱 개발
- CRUD 기능 구현
- 검색 및 필터링
- 에러 핸들링
- 성능 최적화
- 품질 보증
- 단위 테스트, 위젯 테스트, 통합 테스트
- 코드 품질 관리
- CI/CD 파이프라인
여러분은 이제 프로덕션 레벨의 Flutter 앱을 개발할 수 있는 역량을 갖추었습니다!
13.6 마지막 조언
- 꾸준히 연습하세요: 작은 프로젝트부터 시작해 점진적으로 복잡도를 높이세요.
- 커뮤니티에 참여하세요: 다른 개발자들의 코드를 보고 피드백을 주고받으세요.
- 최신 트렌드를 따라가세요: Flutter와 Dart는 빠르게 발전하고 있습니다.
- 테스트를 작성하세요: 테스트는 리팩토링과 유지보수를 쉽게 만듭니다.
- 문서화하세요: 미래의 자신과 팀원을 위해 명확한 문서를 작성하세요.
Happy Coding! 🎉
부록 A: 전체 프로젝트 구조
todo_drift_riverpod/
├── lib/
│ ├── main.dart
│ ├── app.dart
│ ├── core/
│ │ ├── constants/
│ │ │ ├── app_constants.dart
│ │ │ └── database_constants.dart
│ │ ├── errors/
│ │ │ └── exceptions.dart
│ │ ├── utils/
│ │ │ ├── result.dart
│ │ │ ├── date_utils.dart
│ │ │ └── error_logger.dart
│ │ └── theme/
│ │ └── app_theme.dart
│ ├── data/
│ │ ├── database/
│ │ │ ├── app_database.dart
│ │ │ ├── app_database.g.dart
│ │ │ ├── tables/
│ │ │ │ ├── todos_table.dart
│ │ │ │ ├── categories_table.dart
│ │ │ │ ├── tags_table.dart
│ │ │ │ └── todo_tags_table.dart
│ │ │ └── daos/
│ │ │ ├── todo_dao.dart
│ │ │ ├── category_dao.dart
│ │ │ └── tag_dao.dart
│ │ └── repositories/
│ │ ├── todo_repository_impl.dart
│ │ ├── category_repository_impl.dart
│ │ └── tag_repository_impl.dart
│ ├── domain/
│ │ └── repositories/
│ │ ├── todo_repository.dart
│ │ ├── category_repository.dart
│ │ └── tag_repository.dart
│ └── presentation/
│ ├── providers/
│ │ ├── database_provider.dart
│ │ ├── repository_providers.dart
│ │ ├── todo_provider.dart
│ │ ├── category_provider.dart
│ │ ├── tag_provider.dart
│ │ └── search_provider.dart
│ ├── screens/
│ │ ├── home_screen.dart
│ │ ├── todo_detail_screen.dart
│ │ └── settings_screen.dart
│ └── widgets/
│ ├── todo_list_item.dart
│ ├── stats_card.dart
│ ├── add_todo_dialog.dart
│ ├── edit_todo_dialog.dart
│ ├── filter_bottom_sheet.dart
│ └── error_handler.dart
├── test/
│ ├── data/
│ │ ├── database/
│ │ │ └── daos/
│ │ │ └── todo_dao_test.dart
│ │ └── repositories/
│ │ └── todo_repository_impl_test.dart
│ ├── presentation/
│ │ ├── providers/
│ │ │ └── todo_provider_test.dart
│ │ └── widgets/
│ │ └── todo_list_item_test.dart
│ └── widget_test.dart
├── integration_test/
│ └── app_test.dart
├── pubspec.yaml
├── analysis_options.yaml
└── README.md
부록 B: 주요 명령어 모음
# 프로젝트 생성
flutter create todo_drift_riverpod
# 의존성 설치
flutter pub get
# 코드 생성
flutter pub run build_runner build --delete-conflicting-outputs
# Watch 모드로 코드 생성
flutter pub run build_runner watch --delete-conflicting-outputs
# 앱 실행
flutter run
# 테스트 실행
flutter test
# 커버리지와 함께 테스트
flutter test --coverage
# 통합 테스트
flutter test integration_test/
# 분석
flutter analyze
# 빌드 (Android)
flutter build apk --release
flutter build appbundle --release
# 빌드 (iOS)
flutter build ios --release
# 빌드 (Web)
flutter build web --release
# 클린
flutter clean
# Pub 캐시 정리
flutter pub cache repair
이 튜토리얼이 여러분의 Flutter 개발 여정에 도움이 되길 바랍니다!
'스터디 > Flutter' 카테고리의 다른 글
| Flutter프로젝트가 안드로이드 프로젝트로 인식 되는 오류 (0) | 2025.09.15 |
|---|---|
| Flutter에서 실무·학습에서 자주 쓰이는 디자인 패턴 (1) | 2025.08.19 |
| flutter analyze 설명 (3) | 2025.07.30 |
| WWDC25 이후, Flutter 체크 사항 (3) | 2025.06.16 |
| Flutter 3.32 업데이트 내용 (2) | 2025.05.21 |
