# 설계서: 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.dart` — `Protocols.category` CHECK 새 7개로 > - `app/lib/data/db/app_database.dart` — `schemaVersion` 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](./fn-catalog_repository.md) — 통합 CatalogItem 생성 + DisplayCategory 매핑 > - [fn-migration_v1_to_v2.md](./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 → v2** — `Protocols.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.json` 의 `category` 필드 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 의 `onUpgrade` 는 `assert(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 (34) │ ├─► db.select(breakProtocols) → List (8) │ └─► db.select(dietPatterns) → List (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] (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 노출용) ```dart // 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) ```dart TextColumn get category => text().check(const CustomExpression( "category IN (" "'light_circadian','sleep','movement','nutrition'," "'focus_cognition','recovery_stress','emotion_relationship'" ")"))(); ``` > 7개 — `break_habit` 는 별도 BreakProtocols 테이블이라 미포함. ### CatalogItem (unified model) ```dart // 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 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_circadian` → `lightCircadian` 등) | `static DisplayCategory? fromProtocolCategory(String)` | DB 문자열 | enum or null | 미매칭 시 null (호출자가 throw) | 단순 | | `CatalogRepository.all()` | 3 source 읽고 CatalogItem 리스트 반환 | `Future> all()` | none | List 47 | DB 에러 throw | **복잡** → [fn-catalog_repository.md](./fn-catalog_repository.md) | | `CatalogRepository.byId(String)` | 단건 조회 (Preview 화면 진입 시) | `Future byId(String)` | id | CatalogItem or null | DB 에러 throw | 단순 (lookup) | | `CatalogRepository.referencesByIds(List)` | reference id 들 → ReferenceRow 리스트 | `Future>` | ids | rows | DB 에러 throw | 단순 | | `groupByCategory(items)` (pure) | List → Map> | `Map> groupByCategory(List)` | items | grouped map (빈 카테고리 키 미포함) | — | 단순 | | `_summary(Protocol)` (pure) | Protocol.what 의 1줄 요약 추출 | `String _summary(Protocol p)` | Protocol | string ≤ 60자 | what 빈 문자열이면 title 반환 | 단순 | | `migrateV1ToV2(Migrator)` | onUpgrade 1→2 실행 | `Future migrateV1ToV2(Migrator m)` | Migrator | void | SQL 실패 throw | **복잡** → [fn-migration_v1_to_v2.md](./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. `CategoryChipRow` 는 `grouped.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 종료 시 검증) - [x] §1~§12 모든 섹션 채워짐 - [x] Planner 10 AC 모두 §3 + §10 1:1 매핑 - [x] 모든 함수 §7 표에 등재. 복잡 2개 (`CatalogRepository.all`, `migrateV1ToV2`) → fn-*.md 작성 - [x] I/O ↔ 순수 로직 경계 §5 명시 - [x] 데이터 모델 §6 (DisplayCategory enum + Protocols.category v2 CHECK + CatalogItem sealed) 정의 - [x] 마이그레이션 시나리오 (신규/업그레이드 둘 다) §8 명시 - [x] 에러 / 빈 카테고리 / ref 매칭 실패 §9 처리 - [x] ADR-0004 발행 항목 §11 - [x] out-of-scope §2 명확 (#FF1~#FF4 라벨) - [x] AC-8 (DB write 0건) 보장 매커니즘 §5 + §8