- 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
설계서: 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.categoryCHECK 새 7개로app/lib/data/db/app_database.dart—schemaVersion1 → 2 +onUpgrade진짜 구현app/assets/seed/protocols.json— 34 항목 category 재분류app/lib/domain/catalog/display_category.dart(★ 신규) — 8 DisplayCategory enum + label/iconapp/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/catalogByCategoryProviderapp/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.dartapp/test/domain/catalog/display_category_test.dartapp/test/data/catalog/catalog_repository_test.dartapp/test/ui/protocol_gallery_screen_test.dartapp/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 → v2 —
Protocols.categoryCHECK 제약 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 analyze0 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<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/fromDietfactory (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_circadian → lightCircadian 등) |
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)
- v2 schema 그대로 적용 (
createAll). - SeedImporter 가
protocols.json(v2 category) 을 import. - CHECK 제약 통과 — 정상.
- 사용자 첫 진입 → 빈 HabitListScreen + 카탈로그 CTA.
시나리오 B: v1 → v2 업그레이드 (onUpgrade)
migration.onUpgrade(m, 1, 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 클리어.
- a.
- 다음 부팅 시
SeedImporter.importIfNeeded()가 flag 가 없음을 보고 reseed. - 사용자 user-* 테이블 (Habits / Phases / TrackerEntries / ...) 0 영향.
시나리오 C: 카탈로그 갤러리 진입
- 사용자 HabitListScreen 의 "🔍 카탈로그 탐색" 또는 AppBar IconButton 탭.
Navigator.push(MaterialPageRoute(ProtocolGalleryScreen)).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).
- 사용자 카드 탭 →
Navigator.push(ProtocolPreviewScreen(item: item)). - Preview 화면:
- 본문 필드 전부 표시.
if (item.referenceIds.isNotEmpty)→ref.watch(referencesByIdsProvider(item.referenceIds))펼치기 카드 N개.- 하단 "내 습관으로"
FilledButton(onPressed: null, ...)+ Tooltip.
시나리오 D: 빈 카테고리
groupByCategory(items)가 47 item 을 그룹핑.- 매핑 0 인 DisplayCategory 키 (예: emotionRelationship) 는 map 에 미포함.
CategoryChipRow는grouped.keys만 그림 — 빈 카테고리 칩 자체 미표시.- 사용자는 빈 카테고리 존재 자체를 모르고, "전체" + N 카테고리 칩만 봄.
시나리오 E: reference 매칭 실패
- Protocol.referenceIds =
["ref_xxx", "ref_yyy"]이지만 References 테이블에ref_yyy없음. referencesByIdsProvider가 2개 중 1개만 반환 (DB 에서 매칭된 것만).- Preview 의 ref 섹션이 발견된 1개만 카드로 표시.
- 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 analyze0 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 추가 시 컴파일 에러로 강제. |
핵심 리스크
- 카테고리 분류 의견 차 (예: cold_exposure = recovery? movement?) — Architect 1차 결정 (recovery_stress), Developer 가 staging 검증 시 모호 케이스를 저널 노트로 보고. QA 가 비논리 매핑 적발 권한.
- emotion_relationship 빈 카테고리 — 현 시드 0 매핑 가능. 본 이슈 후 시드 보강은 별도 (#FF5+ 콘텐츠 추가).
- 첫 마이그레이션 버그 — 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