Files
joungmin 4665f06a94 [02-Architect] #226 design spec + ADR-0004
- docs/design/226-catalog-gallery/README.md — 12 changed files, DisplayCategory enum, sealed CatalogItem, 5 scenarios
- docs/design/226-catalog-gallery/fn-catalog_repository.md — 3-source unification algorithm + helpers
- docs/design/226-catalog-gallery/fn-migration_v1_to_v2.md — first schema migration (DROP + reseed pattern)
- docs/adr/0004-catalog-recategorization-and-first-migration.md — Catalog 재분류 + 첫 마이그레이션 정책

Refs #226
2026-06-12 16:59:31 +09:00

28 KiB

설계서: Catalog Gallery + 8 카테고리 재분류 (#226)

상태: Draft 작성: [AI] Architect · 최종수정: 2026-06-12 추적성 — Redmine: #226 · 관련 ADR: ADR-0004 (본 이슈에서 신규 — Catalog 우선 onboarding 정책 + 첫 schema 마이그레이션) · 상위/이전: #218 (v0.3.0) · 후속: #FF1 (import) / #FF2 (LLM tweak) / #FF3 (FTS5) / #FF4 (LLM retriever) · 변경 대상 파일:

  • app/lib/data/db/tables/catalog_tables.dartProtocols.category CHECK 새 7개로
  • app/lib/data/db/app_database.dartschemaVersion 1 → 2 + onUpgrade 진짜 구현
  • app/assets/seed/protocols.json — 34 항목 category 재분류
  • app/lib/domain/catalog/display_category.dart (★ 신규) — 8 DisplayCategory enum + label/icon
  • app/lib/domain/catalog/catalog_item.dart (★ 신규) — 통합 CatalogItem 모델
  • app/lib/data/catalog/catalog_repository.dart (★ 신규) — Protocols + Break + Diet → CatalogItem 변환
  • app/lib/state/catalog_providers.dart (★ 신규) — catalogItemsProvider / catalogByCategoryProvider
  • app/lib/ui/screens/protocol_gallery_screen.dart (★ 신규)
  • app/lib/ui/screens/protocol_preview_screen.dart (★ 신규)
  • app/lib/ui/widgets/catalog_card.dart (★ 신규)
  • app/lib/ui/widgets/category_chip_row.dart (★ 신규)
  • app/lib/ui/widgets/reference_expand_card.dart (★ 신규)
  • app/lib/ui/screens/habit_list_screen.dart — 빈 상태 CTA + AppBar 액션 · 신규 테스트:
  • app/test/data/db/migration_v1_to_v2_test.dart
  • app/test/domain/catalog/display_category_test.dart
  • app/test/data/catalog/catalog_repository_test.dart
  • app/test/ui/protocol_gallery_screen_test.dart
  • app/test/ui/protocol_preview_screen_test.dart · 하위 문서:
  • fn-catalog_repository.md — 통합 CatalogItem 생성 + DisplayCategory 매핑
  • fn-migration_v1_to_v2.md — 첫 schema 마이그레이션

1. 목적 (Why)

#218 (v0.3.0) 종료 후 사용자가 "화면이 너무 횡하다" 지적. 진단:

  • 빌드 자산: 시드 카탈로그 107 항목 (protocols 34 + frame 30 + reward 30 + break 8 + diet 5) — 모두 DB seed 완료
  • UI 노출: 0 경로
  • 첫 사용자: 빈 HabitListScreen + 자유 입력 단일 경로만 봄

Tiny Habits 의 "선택 마비" 함정 — 어떤 습관을 만들지 막막함이 채택률 최대 적. 풍부한 자산을 0% 노출 중인 게 핵심 문제.

본 이슈는 47 항목 (protocols 34 + break 8 + diet 5) 을 8 카테고리로 묶어 갤러리/프리뷰 화면으로 노출만 — "내 습관으로" import 는 #FF1 후속.

Planner 목표 1줄: "사용자가 첫 진입에서 빈 화면 대신 8 카테고리로 분류된 107 개 Huberman 카탈로그를 탐색·미리보기 할 수 있게 한다 — 노출만, 자동 import 없음."

2. 범위 (Scope)

포함

  • DB 마이그레이션 v1 → v2Protocols.category CHECK 제약 6 → 7 신 카테고리. 본 앱의 첫 schema 마이그레이션.
  • protocols.json 재분류 — 34 항목 모두 7 카테고리 중 하나로 매핑 (1차 효과 기준).
  • DisplayCategory enum — UI 노출용 8 카테고리 (Protocols 의 7 + 항상 break = breakHabit). break/diet 는 Protocols.category 와 직교한 별도 source 이므로 domain/catalog/ 에서 통합.
  • CatalogRepository — 3 source (Protocols / BreakProtocols / DietPatterns) → 단일 CatalogItem 리스트 변환.
  • 3 신규 화면 + 3 신규 위젯.
  • HabitListScreen 진입점 — 빈 상태 CTA + 채워진 상태에서 AppBar 액션.
  • 신규 테스트 ≥ 15건 — 마이그레이션 검증 + DisplayCategory 매핑 + Repository 통합 + 위젯 2종.

제외 (out of scope)

  • "내 습관으로" import → #FF1. 본 이슈에선 disabled placeholder 버튼만.
  • LLM tweak / FTS5 / LLM retriever / vector → #FF2~#FF4.
  • methodologies / frame_patterns / reward_menu_items / common_frames / references 의 별도 갤러리 — 본 이슈는 47 항목만. references 는 Preview 의 펼치기 카드로만 노출.
  • 카탈로그 자유 검색 → #FF3.
  • 다국어 (한국어 단일 유지).
  • 본 이슈는 ADR-0004 발행. ADR-0005 (catalog import policy) 는 #FF1 시점.

3. 인수조건 (Acceptance Criteria)

Planner §3 10 AC 그대로 수용. QA 판정.

  • AC-1: HabitListScreen 빈 상태에 "🔍 카탈로그 탐색" CTA + 채워진 상태에서 AppBar 액션으로 상시 진입.
  • AC-2: ProtocolGalleryScreen 진입 시 가로 카테고리 칩 (전체 + 8 DisplayCategory). 칩 선택 시 해당 카테고리 카드만 표시.
  • AC-3: 카드 = (title + 1줄 요약 + evidence_strength 배지 + 카테고리 아이콘). 카드 탭 → Preview 진입.
  • AC-4: protocols.jsoncategory 필드 7개로 재분류 — 34 항목 모두 매핑. + break 8 + diet 5 가 갤러리 unified view 에서 8 카테고리 중 하나에 노출.
  • AC-5: ProtocolPreviewScreen 에 title / title_en / what / when / dose / why / how (번호 매김) / check / caution / default_anchor / min_dose_for_start / source_doc + evidence_strength 배지 모두 표시.
  • AC-6: Preview 하단 "내 습관으로" 버튼 disabled + 툴팁 "다음 업데이트 예정". 본 이슈에선 동작 X.
  • AC-7: reference_ids 가 있으면 References 테이블에서 매칭 → 펼치기 카드로 title + url + kind 표시. ref 0 일 때 섹션 숨김.
  • AC-8: 갤러리/프리뷰 진입/탐색 중 R규칙 검사 0건 호출 + habits 테이블 write 0건. 사용자가 "내 습관으로" 누르지 않으면 어떤 DB 변경도 없음.
  • AC-9: 기존 88 테스트 회귀 0 + 신규 ≥ 15 (마이그레이션 + DisplayCategory + Repository + widget 2). 카테고리 매핑 완전성 unit test (모든 ID 가 정확히 1 DisplayCategory 에 매핑).
  • AC-10: flutter analyze 0 issues + APK release 빌드 성공.

4. 컨텍스트 & 제약

의존성

  • Drift 2.x 기존 + Riverpod 2.5 기존. 신규 패키지 0.
  • schemaVersion 1 → 2 가 본 앱 첫 마이그레이션 — 기존 Phase 1 의 onUpgradeassert(false). 이걸 진짜 구현으로 교체. 정책은 ADR-0004 에 묶음.
  • 신규 DB 테이블 0. 신규 컬럼 0. CHECK 제약 한 줄 변경 + reseed 만.
  • kSeededV1Flag 키 명 유지 — 본 이슈에서 seeded_v2 로 추가하지 않고, 마이그레이션이 기존 flag 를 클리어해서 SeedImporter 가 재시드.

제약

  • AC-8 (DB write 0건) — 사용자 작업 동안 user 테이블 무변화. 단, 마이그레이션 자체는 catalog 테이블만 영향 (read-only seed), user 데이터 0 영향.
  • first run vs upgrade — 신규 설치는 onCreate (v2 schema 그대로), 기존 설치는 onUpgrade (Protocols 재생성 + reseed). 둘 다 동일 결과 보장.
  • 빈 카테고리 UX — 일부 DisplayCategory (예: emotionRelationship) 는 47 항목 중 매핑 0 가능. 빈 카테고리는 카테고리 칩 자체를 숨김 (사용자 혼란 최소화). 모든 칩이 빈 경우는 없음 (47 항목 ≥ 7).
  • i18n — 한국어 단일. title_en 은 카드/Preview 에 회색 보조 텍스트.
  • 접근성 — evidence_strength 배지는 색 + 텍스트 둘 다. Semantics label 모든 카드.

가정

  • joungmin 1인 사용자 + Android 단말.
  • 기존 v1 설치된 dev 단말이 있을 수 있어 upgrade path 필수 (assert false 제거).
  • seed JSON 의 모든 항목에 한국어 title/what 존재 (확인됨).
  • BreakProtocols / DietPatterns 의 스키마는 변경 없음 — 본 이슈에서 손대지 않음.

5. 아키텍처 개요

모듈 구조

app/
├── lib/
│   ├── data/
│   │   ├── db/
│   │   │   ├── app_database.dart                 △ schemaVersion 1→2 + onUpgrade
│   │   │   └── tables/catalog_tables.dart        △ Protocols.category CHECK 새 7개
│   │   └── catalog/
│   │       └── catalog_repository.dart           ★ 신규 — 3 source 통합
│   ├── domain/
│   │   └── catalog/
│   │       ├── display_category.dart             ★ enum + label + icon + protocol_id 매핑
│   │       └── catalog_item.dart                 ★ unified model
│   ├── state/
│   │   └── catalog_providers.dart                ★ catalogItemsProvider + groupedByCategoryProvider
│   └── ui/
│       ├── screens/
│       │   ├── habit_list_screen.dart            △ 빈 CTA + AppBar 액션
│       │   ├── protocol_gallery_screen.dart      ★
│       │   └── protocol_preview_screen.dart      ★
│       └── widgets/
│           ├── catalog_card.dart                 ★
│           ├── category_chip_row.dart            ★
│           └── reference_expand_card.dart        ★
├── assets/seed/
│   └── protocols.json                            △ 34 항목 category 재분류
├── seed-staging/
│   └── protocols.json                            △ (mirror)
└── test/
    ├── data/db/migration_v1_to_v2_test.dart      ★
    ├── data/catalog/catalog_repository_test.dart ★
    ├── domain/catalog/display_category_test.dart ★
    └── ui/
        ├── protocol_gallery_screen_test.dart     ★
        └── protocol_preview_screen_test.dart     ★

데이터 흐름

[HabitListScreen]
   │ 빈 상태 → "🔍 카탈로그 탐색" CTA
   │ 채워진 상태 → AppBar IconButton(search)
   ▼
[ProtocolGalleryScreen]
   │ ref.watch(catalogItemsProvider)
   ▼
[CatalogRepository.all()] ─────────► [AppDatabase]
   │   ├─► db.select(protocols)        → List<Protocol> (34)
   │   ├─► db.select(breakProtocols)   → List<BreakProtocol> (8)
   │   └─► db.select(dietPatterns)     → List<DietPattern> (5)
   │ 변환:
   │   for p in protocols:
   │     resolve DisplayCategory by p.category enum
   │     → CatalogItem(source: protocol, displayCategory, ...)
   │   for b in breakProtocols:
   │     → CatalogItem(source: break, displayCategory: breakHabit, ...)
   │   for d in dietPatterns:
   │     → CatalogItem(source: diet, displayCategory: nutrition, ...)
   ▼
[List<CatalogItem>] (47)
   │ groupBy DisplayCategory
   ▼
[CategoryChipRow] — 비어있지 않은 카테고리만
   │ user 선택 → state
   ▼
[GridView of CatalogCard]
   │ tap →
   ▼
[ProtocolPreviewScreen(item: CatalogItem)]
   │ what/when/dose/why/how/check/caution/anchor/min_dose 표시
   │ if item.referenceIds.isNotEmpty:
   │   ref.watch(referencesByIdsProvider(item.referenceIds))
   │   → ReferenceExpandCard
   │ 하단 "내 습관으로" disabled (#FF1 진입점)
   ▼
[user back] — DB write 0

I/O ↔ 순수 로직 경계

  • I/O 경계:
    • CatalogRepository (DB 읽기) = data/catalog/
    • assets/seed/*.json 읽기는 SeedImporter 만 (마이그레이션 시점)
    • DB onUpgrade 안의 SQL = drift API 안 raw SQL 최소
  • 순수 로직:
    • DisplayCategory.resolve(protocolId) — protocol id → DisplayCategory 정적 lookup (pure function)
    • CatalogItem.fromProtocol/fromBreak/fromDiet factory (pure)
    • groupByCategory(items) (pure)
  • UI 경계:
    • Riverpod providers 가 데이터 ↔ UI bridge. 화면은 ConsumerWidget only.

6. 데이터 모델

DisplayCategory enum (UI 노출용)

// lib/domain/catalog/display_category.dart
enum DisplayCategory {
  lightCircadian('빛/일주기', Icons.wb_sunny),
  sleep('수면', Icons.bedtime),
  movement('운동/신체', Icons.fitness_center),
  nutrition('영양', Icons.restaurant),
  focusCognition('집중/인지', Icons.psychology),
  recoveryStress('회복/스트레스', Icons.spa),
  emotionRelationship('감정/관계', Icons.favorite),
  breakHabit('없애기', Icons.block);

  const DisplayCategory(this.label, this.icon);
  final String label;
  final IconData icon;
}

Protocols.category CHECK (v2)

TextColumn get category => text().check(const CustomExpression<bool>(
    "category IN ("
    "'light_circadian','sleep','movement','nutrition',"
    "'focus_cognition','recovery_stress','emotion_relationship'"
    ")"))();

7개 — break_habit 는 별도 BreakProtocols 테이블이라 미포함.

CatalogItem (unified model)

// lib/domain/catalog/catalog_item.dart
sealed class CatalogItem {
  String get id;
  String get title;
  String? get titleEn;
  String get summary;      // 1줄 요약 (카드용)
  DisplayCategory get displayCategory;
  String? get evidenceStrength;
  List<String> get referenceIds;
}

final class ProtocolCatalogItem extends CatalogItem {
  // 원본 Protocol 필드 보존 (what/when/dose/why/how/check/caution/anchor/minDose)
  // summary = what 의 첫 문장 또는 1줄 요약
  // displayCategory = DisplayCategory.values.byName(protocol.category) 의 camelCase 변환
  //   light_circadian → lightCircadian
}

final class BreakCatalogItem extends CatalogItem {
  // 원본 BreakProtocol 필드 (hubermanSummary, phases, defaultCommonFrames)
  // summary = hubermanSummary
  // displayCategory = breakHabit (항상)
  // category(원본 alcohol/nicotine/..) 는 sub-tag 로 보존
}

final class DietCatalogItem extends CatalogItem {
  // 원본 DietPattern (name, core, strengths, weaknesses, koreanContextFit, ...)
  // summary = core
  // displayCategory = nutrition (항상)
  // koreanContextFit 은 sub-tag
}

protocols.json 카테고리 매핑 (34 항목)

분류 가이드: 1차 효과 기준. 모호 시 what/why 첫 문장의 주효과를 따른다. 본 매핑은 Architect 가 1차 결정, QA 가 비논리적 매핑 발견 시 reject 가능.

protocol id v1 category v2 category 근거
morning_sunlight health light_circadian 일주기 리셋
evening_sunlight health light_circadian 일주기
night_light_avoidance health light_circadian 일주기 보호
sleep_stack health sleep 수면 직접
caffeine_protocol health sleep 수면 영향이 1차
zone2 health movement 운동
strength health movement 운동
nsdr health recovery_stress 회복/이완
cold_exposure health recovery_stress 스트레스 적응
breathwork health recovery_stress 스트레스 조절
focus_block health focus_cognition 집중
dopamine_baseline health focus_cognition 동기/인지
meditation_focus meditation focus_cognition 집중
omega3 health nutrition 영양
creatine health nutrition 영양
protein_target health nutrition 영양
... (총 34, Developer 가 staging 의 모든 id 확인 후 1차 효과 분류)

Developer 작업 지침: 위 표는 샘플. 실제 staging/protocols.json 의 모든 34 id 를 읽고 1차 효과 기준으로 v2 category 할당. 모호하면 Architect 와 협의 (저널 노트). emotion_relationship 매핑 0 이어도 OK (빈 카테고리는 칩 자체 숨김).

7. 함수 명세 (Function Specs)

함수 책임 (1줄) 시그니처 입력 출력 에러/실패 복잡?
DisplayCategory.values (enum) 8 enum 자체 enum none enum 단순
DisplayCategory.fromProtocolCategory(String) DB category 문자열 → enum (light_circadianlightCircadian 등) static DisplayCategory? fromProtocolCategory(String) DB 문자열 enum or null 미매칭 시 null (호출자가 throw) 단순
CatalogRepository.all() 3 source 읽고 CatalogItem 리스트 반환 Future<List<CatalogItem>> all() none List 47 DB 에러 throw 복잡fn-catalog_repository.md
CatalogRepository.byId(String) 단건 조회 (Preview 화면 진입 시) Future<CatalogItem?> byId(String) id CatalogItem or null DB 에러 throw 단순 (lookup)
CatalogRepository.referencesByIds(List<String>) reference id 들 → ReferenceRow 리스트 Future<List<ReferenceRow>> ids rows DB 에러 throw 단순
groupByCategory(items) (pure) List → Map<DisplayCategory, List> Map<DisplayCategory, List<CatalogItem>> groupByCategory(List<CatalogItem>) items grouped map (빈 카테고리 키 미포함) 단순
_summary(Protocol) (pure) Protocol.what 의 1줄 요약 추출 String _summary(Protocol p) Protocol string ≤ 60자 what 빈 문자열이면 title 반환 단순
migrateV1ToV2(Migrator) onUpgrade 1→2 실행 Future<void> migrateV1ToV2(Migrator m) Migrator void SQL 실패 throw 복잡fn-migration_v1_to_v2.md
HabitListScreen._onCatalogPressed 갤러리 진입 콜백 void _onCatalogPressed(BuildContext) context navigation 없음 단순
ProtocolGalleryScreen.build 칩 + 그리드 build Widget build(BuildContext, WidgetRef) context, ref widget provider 에러 → SnackBar 단순
ProtocolPreviewScreen.build 상세 build + 펼치기 카드 Widget build(BuildContext, WidgetRef) context, ref widget references provider 에러 → 섹션 숨김 단순
CatalogCard (widget) 카드 1개 class CatalogCard extends StatelessWidget item widget 단순
CategoryChipRow (widget) 가로 칩 줄 class CategoryChipRow extends StatelessWidget categories, selected, onSelect widget 단순
ReferenceExpandCard (widget) ref 펼치기 카드 class ReferenceExpandCard extends StatelessWidget reference widget url 누르기 = url_launcher (선택, 본 이슈에선 텍스트 표시만) 단순

복잡 함수 = 2개 (CatalogRepository.all, migrateV1ToV2). 각각 fn-*.md 작성.

8. 흐름 / 알고리즘

시나리오 A: 신규 설치 (onCreate)

  1. v2 schema 그대로 적용 (createAll).
  2. SeedImporter 가 protocols.json (v2 category) 을 import.
  3. CHECK 제약 통과 — 정상.
  4. 사용자 첫 진입 → 빈 HabitListScreen + 카탈로그 CTA.

시나리오 B: v1 → v2 업그레이드 (onUpgrade)

  1. migration.onUpgrade(m, 1, 2) 호출.
  2. migrateV1ToV2(m):
    • a. await m.deleteTable(db.protocols) — Drift API 로 안전한 DROP.
    • b. await m.createTable(db.protocols) — 새 CHECK 로 재생성.
    • c. await m.createIndex(IDX_protocols_category) — 인덱스 복구.
    • d. await (m.database.delete(db.metaKv)..where((t) => t.key.equals(kSeededV1Flag))).go() — 시드 flag 클리어.
  3. 다음 부팅 시 SeedImporter.importIfNeeded() 가 flag 가 없음을 보고 reseed.
  4. 사용자 user-* 테이블 (Habits / Phases / TrackerEntries / ...) 0 영향.

시나리오 C: 카탈로그 갤러리 진입

  1. 사용자 HabitListScreen 의 "🔍 카탈로그 탐색" 또는 AppBar IconButton 탭.
  2. Navigator.push(MaterialPageRoute(ProtocolGalleryScreen)).
  3. ProtocolGalleryScreen.build:
    • final items = ref.watch(catalogItemsProvider) — 47 item.
    • final grouped = ref.watch(groupedByCategoryProvider) — 7~8 카테고리 키.
    • CategoryChipRow(categories: grouped.keys.toList(), selected: _selected)
    • GridView.builder(items: grouped[_selected] ?? items).
  4. 사용자 카드 탭 → Navigator.push(ProtocolPreviewScreen(item: item)).
  5. Preview 화면:
    • 본문 필드 전부 표시.
    • if (item.referenceIds.isNotEmpty)ref.watch(referencesByIdsProvider(item.referenceIds)) 펼치기 카드 N개.
    • 하단 "내 습관으로" FilledButton(onPressed: null, ...) + Tooltip.

시나리오 D: 빈 카테고리

  1. groupByCategory(items) 가 47 item 을 그룹핑.
  2. 매핑 0 인 DisplayCategory 키 (예: emotionRelationship) 는 map 에 미포함.
  3. CategoryChipRowgrouped.keys 만 그림 — 빈 카테고리 칩 자체 미표시.
  4. 사용자는 빈 카테고리 존재 자체를 모르고, "전체" + N 카테고리 칩만 봄.

시나리오 E: reference 매칭 실패

  1. Protocol.referenceIds = ["ref_xxx", "ref_yyy"] 이지만 References 테이블에 ref_yyy 없음.
  2. referencesByIdsProvider 가 2개 중 1개만 반환 (DB 에서 매칭된 것만).
  3. Preview 의 ref 섹션이 발견된 1개만 카드로 표시.
  4. ref 0건 매칭이면 섹션 자체 숨김.

9. 엣지케이스 & 에러 처리

상황 처리
Protocols.category 가 v2 7개 외 값 (이론상 불가, JSON 손상 시) SeedImporter 가 CHECK 위배로 throw → 부팅 실패. 명시적이 graceful 보다 낫다 (잘못 시드한 빌드를 출시하지 않음)
onUpgrade 가 실패 (SQL 에러) drift 가 transaction 롤백 → DB 상태 보전. 사용자에겐 부팅 실패. dev 단말 1대 영향이라 수용.
Protocol 의 v1 category=meditation/motivation/habit/learning/diet 가 v2 매핑 없음 Developer 가 protocols.json 의 모든 34 id 를 1차 효과로 재분류 — staging 검증 step 에서 단위 테스트로 100% 매핑 보장.
BreakProtocol 의 한국어 title 누락 (이론상 없음) 시드 검증 단위 테스트에서 적발.
카드 그리드가 화면을 넘침 (작은 단말) GridView.builder + SliverGridDelegateWithMaxCrossAxisExtent (240px) — 단말 폭에 맞게 1~3 컬럼 자동.
Preview 의 how 가 빈 배열 섹션 자체 숨김.
References URL 누르기 본 이슈에선 텍스트 표시만url_launcher 패키지 도입은 #FF1 또는 별도.
사용자가 갤러리에서 즉시 뒤로 가기 DB write 0건, R규칙 호출 0건 — AC-8 자명 만족.
groupedByCategoryProvider 가 loading 상태 갤러리 화면 중앙 CircularProgressIndicator.
references.json 매칭 시 url 이 null url 라인 자체 숨김 + 다른 필드 (kind, title) 만 표시.

안전한 기본값

  • onUpgrade 미정의 분기 (v3+) → assert(false, 'Unknown upgrade from $from to $to') — Phase 1 패턴 유지.
  • Repository 에러 → Riverpod AsyncValue.error 로 전파, UI 가 SnackBar 표시.
  • _summary 가 60자 초과 시 ... 절단.

10. 테스트 계획

단위 테스트 (신규)

AC 테스트 파일 내용
AC-4, AC-9 test/domain/catalog/display_category_test.dart 모든 protocols.json id 가 fromProtocolCategory 로 정확히 1 DisplayCategory 에 매핑됨 (full coverage) + BreakProtocol → breakHabit + DietPattern → nutrition
AC-4 test/data/db/migration_v1_to_v2_test.dart v1 schema 로 시작 → migrate → Protocols 테이블 v2 CHECK 적용 + 인덱스 복구 + kSeededV1Flag 클리어 + user 테이블 (Habits 등) 무변화
AC-2, AC-3, AC-7 test/data/catalog/catalog_repository_test.dart in-memory DB + seed → all() 가 47 item 반환 + groupByCategory 가 빈 카테고리 키 미포함 + byId / referencesByIds 동작
AC-2, AC-3 test/ui/protocol_gallery_screen_test.dart ProviderScope override 로 catalogItemsProvider 페이크 → 칩 N개 표시 + 카드 그리드 + 칩 선택 시 필터링
AC-5, AC-6, AC-7 test/ui/protocol_preview_screen_test.dart 각 필드 표시 + "내 습관으로" 버튼 disabled + tooltip 확인 + references 펼치기 카드

회귀 보호

  • 기존 88 테스트 0 회귀 — Protocols.category 변경이 영향 가능한 테스트는 seed_importer_test.dart 의 1차. 시드 JSON 갱신 + 단위 테스트 같이 갱신.
  • flutter analyze 0 issues.
  • APK release 빌드 성공.

Mock 전략

  • DB = AppDatabase.memory() (기존 패턴).
  • ProviderScope override 로 widget test.
  • Migration test 는 raw SQLite 로 v1 schema 수동 생성 → migrate 호출 → CHECK 위배 확인 (negative test).

11. 리스크 & 대안 검토

본 설계서 결정

결정 채택 대안 근거
Protocols.category CHECK 6 → 7 마이그레이션 (A) virtual mapping (DB 무변경, Dart Map) / (B) display_category 신규 컬럼 Planner AC-4 가 "category 필드 재분류" 명시. DB 가 SoT 인 본 앱 철학상 컬럼이 정답. 가상 매핑은 시드 파일과 DB 가 분리되어 일관성 깨짐.
schemaVersion 1→2 + 진짜 onUpgrade onUpgrade 무시 + 신규 설치만 지원 기존 dev 단말 (joungmin 본인) 의 v1 DB 가 있음. assert false 깨지면 부팅 실패. 한 번 제대로 만들면 후속 마이그레이션 패턴 재사용.
마이그레이션 = DROP + CREATE + reseed INSERT...SELECT 로 row-level 재맵핑 Protocols 는 read-only catalog — drop 해도 데이터 손실 0 (시드에서 복원). row-level 재맵핑은 라벨 매핑 알고리즘 분리 필요해서 과한 복잡도.
빈 카테고리 칩 자체 숨김 칩 회색 + "준비 중" 사용자가 "왜 비어있지" 묻는 친화성 ↓. 빈 카테고리 존재를 모르게 하는 게 더 깔끔. emotion_relationship 추가는 향후 시드 갱신 시 자동 노출.
break + diet 통합 view (CatalogItem sealed) 3개 화면 분리 (Protocol Gallery / Break Gallery / Diet Gallery) 사용자는 "수면 관련 뭐 있나?" 같은 카테고리 중심 탐색 — source 별 분리는 사용자 멘탈모델과 직교. unified view 가 정답.
"내 습관으로" 버튼 disabled placeholder 버튼 자체 없음 (다음 PR 에서 추가) Preview 화면을 다음 이슈에서 또 수정하는 비용 방지 + 사용자에게 "다음 업데이트 예정" 시그널. UX 비용 0.
ADR-0004 발행 ADR 없음 첫 schema 마이그레이션 = 향후 정책 (DROP+reseed for catalog vs row-migrate for user) 의 기준점. ADR 가치 충분.
sealed class CatalogItem abstract class + downcast Dart 3+ sealed pattern matching 안전 + 신규 source 추가 시 컴파일 에러로 강제.

핵심 리스크

  1. 카테고리 분류 의견 차 (예: cold_exposure = recovery? movement?) — Architect 1차 결정 (recovery_stress), Developer 가 staging 검증 시 모호 케이스를 저널 노트로 보고. QA 가 비논리 매핑 적발 권한.
  2. emotion_relationship 빈 카테고리 — 현 시드 0 매핑 가능. 본 이슈 후 시드 보강은 별도 (#FF5+ 콘텐츠 추가).
  3. 첫 마이그레이션 버그 — onUpgrade 미작동 시 dev 단말 부팅 실패. 마이그레이션 unit test 가 1차 방어선.

되돌리기 어려운 결정 → ADR

  • ADR-0004 "Catalog re-categorization + first schema migration policy" 본 이슈에서 발행:
    • 결정 1: Catalog (read-only seed) 마이그레이션은 DROP + reseed
    • 결정 2: User (mutable) 테이블 마이그레이션은 row-preserving (해당 안 됨, 본 이슈)
    • 결정 3: DisplayCategory 8개 vs Storage category 7개 (Protocols) — Storage = 단일 source 7, breakHabit/nutrition 은 별도 source 라 enum 만 8.

12. 미해결 질문 (Open Questions)

OQ 질문 상태 권고
OQ-1 methodologies / frame_patterns / reward_menu_items 도 갤러리에 노출? OPEN → Planner OOS 명시. 본 이슈에서 미노출 확정. Phase 2-C 시점에 재검토
OQ-2 url_launcher 패키지 도입? OPEN → 본 이슈에서 미도입 (텍스트 표시만). #FF1 또는 별도
OQ-3 DietPattern 의 koreanContextFit 을 카드 배지로? OPEN → 본 이슈에서 미표시 (Preview 만). UX 피드백 후 결정
OQ-4 "내 습관으로" 버튼이 disabled 상태에서 사용자가 누르면 toast? OPEN → tooltip 만 (Material 표준). #FF1 에서 활성화
OQ-5 갤러리 카드 정렬 순서 (id / evidence_strength / 가나다) OPEN → id 알파벳 순 (안정적 + 예측 가능). Developer 결정 권한
OQ-6 references URL 없을 때 카드 표시 vs 숨김 OPEN → 표시 (title + kind 만, url 라인 숨김). Developer 결정

부록: 자가 점검 (Architect 종료 시 검증)

  • §1~§12 모든 섹션 채워짐
  • Planner 10 AC 모두 §3 + §10 1:1 매핑
  • 모든 함수 §7 표에 등재. 복잡 2개 (CatalogRepository.all, migrateV1ToV2) → fn-*.md 작성
  • I/O ↔ 순수 로직 경계 §5 명시
  • 데이터 모델 §6 (DisplayCategory enum + Protocols.category v2 CHECK + CatalogItem sealed) 정의
  • 마이그레이션 시나리오 (신규/업그레이드 둘 다) §8 명시
  • 에러 / 빈 카테고리 / ref 매칭 실패 §9 처리
  • ADR-0004 발행 항목 §11
  • out-of-scope §2 명확 (#FF1~#FF4 라벨)
  • AC-8 (DB write 0건) 보장 매커니즘 §5 + §8