Recent Posts
Recent Comments
반응형
«   2025/11   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30
Archives
Today
Total
관리 메뉴

오늘도 공부

Flutter Riverpod + Drift 완벽 가이드 본문

스터디/Flutter

Flutter Riverpod + Drift 완벽 가이드

행복한 수지아빠 2025. 10. 22. 17:26
반응형

실전 프로젝트로 배우는 종합 튜토리얼


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 ArchitectureRepository 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 관리 시스템을 구축하며 다음 기능들을 구현합니다:

  1. 기본 CRUD 기능
    • Todo 생성, 읽기, 수정, 삭제
    • 카테고리 관리
    • 태그 시스템
  2. 고급 기능
    • 검색 및 필터링
    • 정렬 (날짜, 우선순위, 완료 여부)
    • 통계 및 대시보드
    • 데이터 동기화
  3. 사용자 경험
    • 오프라인 지원
    • 실시간 업데이트
    • 낙관적 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
  1. 의존성 설치:
flutter pub get
  1. 코드 생성:
flutter pub run build_runner build --delete-conflicting-outputs
  1. 앱 실행:
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 애플리케이션을 구축했습니다. 다음 사항들을 확인해보세요:

✅ 완료된 기능들:

  1. 데이터 영속성
    • Drift를 사용한 SQLite 데이터베이스
    • 테이블 설계 (Todos, Categories, Tags)
    • DAO 패턴 구현
    • 복잡한 쿼리 및 관계 처리
  2. 상태 관리
    • Riverpod Provider 설계
    • StateNotifier 구현
    • 의존성 주입
    • 리액티브 데이터 흐름
  3. 아키텍처
    • Clean Architecture 적용
    • Repository 패턴
    • 관심사 분리
    • 확장 가능한 구조
  4. UI/UX
    • Material 3 디자인
    • 반응형 레이아웃
    • 로딩 및 에러 상태 처리
    • 사용자 친화적 인터페이스
  5. 고급 기능
    • 검색 및 필터링
    • 정렬 옵션
    • 통계 대시보드
    • 낙관적 업데이트
  6. 품질 보증
    • 단위 테스트
    • 위젯 테스트
    • 통합 테스트
    • 에러 핸들링

13.2 다음 단계

프로젝트를 더욱 발전시키고 싶다면 다음 기능들을 추가해보세요:

추천 추가 기능:

  1. 동기화 기능
  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); } }
  3. 반복 일정
  4. // 반복되는 할 일 처리 class RecurringTodoService { Future<void> createRecurringInstances(TodoEntity template) async { switch (template.repeatType) { case 'daily': // 매일 반복 break; case 'weekly': // 매주 반복 break; case 'monthly': // 매월 반복 break; } } }
  5. 알림 시스템
  6. // flutter_local_notifications 사용 class NotificationService { Future<void> scheduleNotification(TodoEntity todo) async { if (todo.hasReminder && todo.reminderTime != null) { await notificationsPlugin.zonedSchedule( todo.id, '할 일 알림', todo.title, // 알림 시간 설정 ); } } }
  7. 첨부파일 지원
  8. // 이미지, 파일 첨부 class AttachmentTable extends Table { IntColumn get id => integer().autoIncrement()(); IntColumn get todoId => integer().references(Todos, #id)(); TextColumn get filePath => text()(); TextColumn get fileType => text()(); }
  9. 협업 기능
  10. // 할 일 공유 및 협업 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 }
  11. 데이터 백업 및 복원
  12. 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 학습 리소스

공식 문서:

  1. Flutter
  2. Drift
  3. Riverpod

추천 강좌 및 튜토리얼:

  • Flutter & Dart - The Complete Guide [Udemy]
  • Reso Coder - Flutter TDD Clean Architecture
  • Flutter Official YouTube Channel
  • Riverpod Official Documentation & Examples

커뮤니티 및 지원:

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를 사용하세요:

  1. Performance 탭에서 프레임 드롭 확인
  2. Memory 탭에서 메모리 누수 확인
  3. Network 탭에서 API 호출 모니터링
  4. Timeline에서 빌드/레이아웃 시간 확인

13.5 결론

이 튜토리얼을 통해 다음을 학습했습니다:

  1. Drift 데이터베이스
    • 테이블 설계 및 관계 정의
    • DAO 패턴으로 데이터 접근 캡슐화
    • 복잡한 쿼리 및 조인 처리
    • 마이그레이션 전략
  2. Riverpod 상태 관리
    • Provider 설계 및 조합
    • StateNotifier를 통한 상태 관리
    • 의존성 주입 패턴
    • 테스트 가능한 구조
  3. Clean Architecture
    • 계층 분리 (Data, Domain, Presentation)
    • Repository 패턴
    • 관심사 분리
    • 확장 가능한 코드 구조
  4. 실전 앱 개발
    • CRUD 기능 구현
    • 검색 및 필터링
    • 에러 핸들링
    • 성능 최적화
  5. 품질 보증
    • 단위 테스트, 위젯 테스트, 통합 테스트
    • 코드 품질 관리
    • CI/CD 파이프라인

여러분은 이제 프로덕션 레벨의 Flutter 앱을 개발할 수 있는 역량을 갖추었습니다!

13.6 마지막 조언

  1. 꾸준히 연습하세요: 작은 프로젝트부터 시작해 점진적으로 복잡도를 높이세요.
  2. 커뮤니티에 참여하세요: 다른 개발자들의 코드를 보고 피드백을 주고받으세요.
  3. 최신 트렌드를 따라가세요: Flutter와 Dart는 빠르게 발전하고 있습니다.
  4. 테스트를 작성하세요: 테스트는 리팩토링과 유지보수를 쉽게 만듭니다.
  5. 문서화하세요: 미래의 자신과 팀원을 위해 명확한 문서를 작성하세요.

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 개발 여정에 도움이 되길 바랍니다!

반응형