[03-Developer] #226 Catalog Gallery 구현

- Drift schema v2: Protocols.category CHECK 6→7 (light_circadian/sleep/movement/
  nutrition/focus_cognition/recovery_stress/emotion_relationship). schemaVersion
  1→2 + onUpgrade migrateV1ToV2 (DROP+CREATE+reseed flag 클리어).
- protocols.json 34 항목 v2 재분류 (1차 효과 기준). emotion_relationship 0 매핑.
- 도메인: DisplayCategory enum (8) + CatalogItem sealed (Protocol/Break/Diet).
- 데이터: CatalogRepository.all/byId/referencesByIds (3 source 통합).
- 상태: catalog_providers.dart (catalogItems / groupedByCategory / refsByIds).
- UI: ProtocolGalleryScreen (카테고리 칩 + 카드 그리드) + ProtocolPreviewScreen
  (모든 필드 + reference 펼치기 + "내 습관으로" disabled placeholder) +
  CatalogCard / CategoryChipRow / ReferenceExpandCard. HabitListScreen 빈
  상태 CTA + AppBar 액션.
- 테스트: migration_v1_to_v2 3건 + display_category 5건 + catalog_repository
  9건 + gallery widget 3건 + preview widget 3건 = 23 신규. 기존 88 회귀 0,
  flutter analyze 0 issues. 110 passed / 1 skipped.

설계서: docs/design/226-catalog-gallery/{README, fn-catalog_repository,
fn-migration_v1_to_v2}.md + ADR-0004.

Refs #226
This commit is contained in:
2026-06-12 17:20:13 +09:00
parent 4665f06a94
commit 321d3af53b
24 changed files with 1814 additions and 146 deletions

View File

@@ -1,7 +1,7 @@
[
{
"id": "morning_sunlight",
"category": "health",
"category": "light_circadian",
"title": "아침 햇빛",
"title_en": "Morning Sunlight",
"what": "기상 후 야외에서 햇빛을 직접 눈에 받기.",
@@ -21,13 +21,17 @@
"after_what": "기상 후 양치"
},
"min_dose_for_start": "햇빛 30초~2분 (Tiny Habits 시작 도즈)",
"reference_ids": ["ref_podcast_hl_2_sleep", "ref_podcast_hl_68_light", "ref_doi_10_1016_j_cub_2013_06_039"],
"reference_ids": [
"ref_podcast_hl_2_sleep",
"ref_podcast_hl_68_light",
"ref_doi_10_1016_j_cub_2013_06_039"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "evening_sunlight",
"category": "health",
"category": "light_circadian",
"title": "저녁 햇빛",
"title_en": "Evening Sunlight",
"what": "일몰 즈음 햇빛 보기.",
@@ -40,13 +44,15 @@
"야외 5~10분 (저녁 산책과 결합)."
],
"check": "일몰 ±1시간 안에 야외 / 5분 이상",
"reference_ids": ["ref_podcast_hl_68_light"],
"reference_ids": [
"ref_podcast_hl_68_light"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "night_light_avoidance",
"category": "health",
"category": "light_circadian",
"title": "야간 빛 차단",
"title_en": "Night Light Avoidance",
"what": "밤 10시 ~ 새벽 4시 머리 위 강한 빛 차단.",
@@ -63,13 +69,16 @@
"default_anchor": {
"when": "21:00"
},
"reference_ids": ["ref_podcast_hl_68_light", "ref_doi_10_1038_tp_2016_262"],
"reference_ids": [
"ref_podcast_hl_68_light",
"ref_doi_10_1038_tp_2016_262"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "sleep_stack",
"category": "health",
"category": "sleep",
"title": "수면 스택",
"title_en": "Sleep Stack",
"what": "입면·심부 체온·자극 차단을 결합한 취침 전 90분 루틴.",
@@ -84,13 +93,16 @@
"침실 18~19℃, 침대 진입 직전 화면 OFF."
],
"check": "기상 시각 ±1h / 카페인 컷오프 / 식사 2~3h 전 종료 / 침실 18~19℃",
"reference_ids": ["ref_podcast_hl_84_sleep_toolkit", "ref_podcast_hl_2_sleep"],
"reference_ids": [
"ref_podcast_hl_84_sleep_toolkit",
"ref_podcast_hl_2_sleep"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "caffeine_protocol",
"category": "health",
"category": "sleep",
"title": "카페인 타이밍",
"title_en": "Caffeine Protocol",
"what": "기상 직후 카페인 회피 + 컷오프 시각 준수.",
@@ -105,13 +117,16 @@
],
"check": "기상 후 90분 전 카페인 X / 컷오프 시각 이후 카페인 X",
"caution": "90~120분 지연은 직접 RCT 부재. adenosine 약리학 기반 추론. 근거 ⚠️.",
"reference_ids": ["ref_podcast_hl_101_caffeine", "ref_doi_10_5664_jcsm_3170"],
"reference_ids": [
"ref_podcast_hl_101_caffeine",
"ref_doi_10_5664_jcsm_3170"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "weekly_movement_template",
"category": "health",
"category": "movement",
"title": "주간 운동 템플릿",
"title_en": "Weekly Movement Template",
"what": "유산소(Zone 2 + VO2max) + 근력의 주간 분배.",
@@ -127,13 +142,15 @@
],
"check": "Zone 2 ≥ 150분/주 / VO2max 1회 / 근력 2~4회",
"min_dose_for_start": "운동 1세트 또는 5분 산책",
"reference_ids": ["ref_doi_10_1001_jamanetworkopen_2018_3605"],
"reference_ids": [
"ref_doi_10_1001_jamanetworkopen_2018_3605"
],
"evidence_strength": "observational",
"source_doc": "huberman-protocols.md"
},
{
"id": "deliberate_cold_exposure",
"category": "health",
"category": "recovery_stress",
"title": "의도적 냉수 노출",
"title_en": "Deliberate Cold Exposure",
"what": "찬물 샤워 또는 ice bath.",
@@ -149,13 +166,16 @@
],
"check": "1회 ≥ 1분 / 주 합산 ≥ 11분",
"caution": "근비대 직후 4h 회피. 심혈관 질환자 의사 상담.",
"reference_ids": ["ref_podcast_hl_66_cold", "ref_doi_10_1007_s004210050065"],
"reference_ids": [
"ref_podcast_hl_66_cold",
"ref_doi_10_1007_s004210050065"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "deliberate_heat_exposure",
"category": "health",
"category": "recovery_stress",
"title": "사우나",
"title_en": "Deliberate Heat Exposure",
"what": "80~100℃ 사우나.",
@@ -169,13 +189,16 @@
],
"check": "주 합산 ≥ 57분 (선택)",
"caution": "임신/심혈관/저혈압 시 의사 상담. 알코올 결합 X.",
"reference_ids": ["ref_podcast_hl_69_heat", "ref_doi_10_1001_jamainternmed_2014_8187"],
"reference_ids": [
"ref_podcast_hl_69_heat",
"ref_doi_10_1001_jamainternmed_2014_8187"
],
"evidence_strength": "observational",
"source_doc": "huberman-protocols.md"
},
{
"id": "foundational_supplements",
"category": "health",
"category": "nutrition",
"title": "핵심 보충제",
"title_en": "Foundational Supplements",
"what": "Vitamin D3, Magnesium L-threonate, (옵션) Apigenin, Theanine.",
@@ -189,13 +212,16 @@
],
"check": "처방/권장량 준수 / 신규 도입 한 번에 1종",
"caution": "의약품/임신/기저질환 시 의사 상담. Theanine은 혈압약 상호작용 가능.",
"reference_ids": ["ref_podcast_hl_84_sleep_toolkit", "ref_doi_10_1016_j_sleepx_2024_100121"],
"reference_ids": [
"ref_podcast_hl_84_sleep_toolkit",
"ref_doi_10_1016_j_sleepx_2024_100121"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "focused_meditation",
"category": "meditation",
"category": "focus_cognition",
"title": "집중 명상",
"title_en": "Focused Meditation",
"what": "단일 대상(호흡/미간)에 주의 고정.",
@@ -212,13 +238,16 @@
"check": "13분 완료 / 알아챔→복귀 1회 이상 의식",
"caution": "잠들기 직전 진행 시 각성 유발 가능.",
"min_dose_for_start": "명상 1분",
"reference_ids": ["ref_podcast_hl_96_meditation", "ref_doi_10_1016_j_bbr_2018_08_023"],
"reference_ids": [
"ref_podcast_hl_96_meditation",
"ref_doi_10_1016_j_bbr_2018_08_023"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "nsdr_yoga_nidra",
"category": "meditation",
"category": "recovery_stress",
"title": "NSDR / Yoga Nidra",
"title_en": "Non-Sleep Deep Rest",
"what": "누운 자세에서 가이드 보디스캔 + 긴 날숨.",
@@ -233,13 +262,16 @@
"종료 후 30초 잔여감."
],
"check": "가이드 끝까지 / 종료 후 30초 잔여감",
"reference_ids": ["ref_podcast_hl_28_daily_tools", "ref_doi_10_1016_S0926_6410_01_00106_9"],
"reference_ids": [
"ref_podcast_hl_28_daily_tools",
"ref_doi_10_1016_S0926_6410_01_00106_9"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "cyclic_sighing",
"category": "meditation",
"category": "recovery_stress",
"title": "생리적 한숨",
"title_en": "Cyclic Sighing",
"what": "들숨 2회 + 긴 날숨 1회.",
@@ -255,13 +287,16 @@
],
"check": "패턴 유지 / 1분 이상",
"min_dose_for_start": "cyclic sighing 30초~1분",
"reference_ids": ["ref_podcast_hl_10_stress", "ref_doi_10_1016_j_xcrm_2022_100895"],
"reference_ids": [
"ref_podcast_hl_10_stress",
"ref_doi_10_1016_j_xcrm_2022_100895"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "box_breathing",
"category": "meditation",
"category": "recovery_stress",
"title": "Box Breathing",
"title_en": "Box Breathing",
"what": "4초 들숨4초 멈춤4초 날숨4초 멈춤.",
@@ -277,13 +312,16 @@
],
"check": "4-4-4-4 박자 / 2분 이상",
"caution": "특이성 RCT 빈약 — cyclic sighing(§2.3)보다 효과 작음.",
"reference_ids": ["ref_podcast_hl_10_stress", "ref_doi_10_3389_fnhum_2018_00353"],
"reference_ids": [
"ref_podcast_hl_10_stress",
"ref_doi_10_3389_fnhum_2018_00353"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "cold_sigh_combo",
"category": "meditation",
"category": "recovery_stress",
"title": "Cold + Sigh Combo",
"title_en": "Cold + Sigh Combo",
"what": "찬물 세면 + cyclic sighing.",
@@ -301,7 +339,7 @@
},
{
"id": "protect_dopamine_baseline",
"category": "motivation",
"category": "focus_cognition",
"title": "도파민 baseline 보호",
"title_en": "Protect Dopamine Baseline",
"what": "활동 자체에 외부 도파민 자극을 중첩(stacking)하지 않기.",
@@ -315,13 +353,16 @@
"주 1~2회 '맨몸' 세션으로 baseline 회복."
],
"check": "stacking ≤ 1 / 직후 5분 차단",
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_book_lembke_dopamine_nation"],
"reference_ids": [
"ref_podcast_hl_39_dopamine",
"ref_book_lembke_dopamine_nation"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "reward_prediction_relabeling",
"category": "motivation",
"category": "focus_cognition",
"title": "보상 예측 재배치",
"title_en": "Reward Prediction Relabeling",
"what": "노력 자체에 보상을 결합하는 내적 라벨링.",
@@ -335,13 +376,16 @@
"끝난 후 외적 보상 X."
],
"check": "라벨링 1회+ / 외적 보상 안 줌",
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_podcast_hl_113_dopamine_procrastination"],
"reference_ids": [
"ref_podcast_hl_39_dopamine",
"ref_podcast_hl_113_dopamine_procrastination"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "dopamine_recovery_stack",
"category": "motivation",
"category": "focus_cognition",
"title": "도파민 회복 스택",
"title_en": "Dopamine Recovery Stack",
"what": "자연적 baseline 상승 도구 묶음.",
@@ -356,13 +400,15 @@
"디지털 디톡스 주 1회 24h."
],
"check": "각 구성요소 1회+",
"reference_ids": ["ref_podcast_hl_113_dopamine_procrastination"],
"reference_ids": [
"ref_podcast_hl_113_dopamine_procrastination"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "amcc_will_training",
"category": "motivation",
"category": "focus_cognition",
"title": "aMCC 의지력 훈련",
"title_en": "aMCC Will-Training",
"what": "'하기 싫지만 안전한' 일을 매일 1개 의도적 수행.",
@@ -375,13 +421,16 @@
"완료 후 'aMCC 1 rep' 라벨링."
],
"check": "오늘의 싫은 일 정의 / 수행 완료",
"reference_ids": ["ref_doi_10_1016_j_cortex_2019_09_011", "ref_doi_10_1093_braincomms_fcac163"],
"reference_ids": [
"ref_doi_10_1016_j_cortex_2019_09_011",
"ref_doi_10_1093_braincomms_fcac163"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "digital_dopamine_detox",
"category": "motivation",
"category": "focus_cognition",
"title": "디지털 디톡스",
"title_en": "Digital Dopamine Detox",
"what": "고자극 컨텐츠 차단 (쇼츠/릴스/포르노/SNS 스크롤).",
@@ -395,13 +444,16 @@
"종료 후 첫 사용 5분 제한."
],
"check": "24h 차단 / 첫 사용 5분 이내",
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_book_lembke_dopamine_nation"],
"reference_ids": [
"ref_podcast_hl_39_dopamine",
"ref_book_lembke_dopamine_nation"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "three_phases_of_day",
"category": "habit",
"category": "focus_cognition",
"title": "하루 3 위상",
"title_en": "Three Phases of the Day",
"what": "신경전달물질 우세 시간대에 작업 배치.",
@@ -415,13 +467,16 @@
"Phase 3: 회상·정리·디지털 OFF."
],
"check": "가장 어려운 일 Phase 1 배치 / Phase 3 자극적 디지털 X",
"reference_ids": ["ref_podcast_hl_28_daily_tools", "ref_podcast_hl_53_habits"],
"reference_ids": [
"ref_podcast_hl_28_daily_tools",
"ref_podcast_hl_53_habits"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "task_bracketing",
"category": "habit",
"category": "focus_cognition",
"title": "시간·맥락 브래킷",
"title_en": "Task Bracketing",
"what": "새 습관을 같은 시간 + 같은 직전/직후 행동으로 고정.",
@@ -436,13 +491,16 @@
"6주간 같은 위치 유지."
],
"check": "직전 브래킷 정의 / 직후 브래킷 정의 / 오늘 같은 시각 실행",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1146_annurev_neuro_29_051605_112851"],
"reference_ids": [
"ref_podcast_hl_53_habits",
"ref_doi_10_1146_annurev_neuro_29_051605_112851"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "six_week_integration_rule",
"category": "habit",
"category": "focus_cognition",
"title": "6주 자동화 규칙",
"title_en": "6-Week Integration Rule",
"what": "'6주 동안 주 6/7'을 자동화 기준으로.",
@@ -457,13 +515,16 @@
"6주 후 자동화 자가 평가."
],
"check": "트래커 존재 / 이번 주 6/7 / 결석 후 다음 날 복귀",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1002_ejsp_674"],
"reference_ids": [
"ref_podcast_hl_53_habits",
"ref_doi_10_1002_ejsp_674"
],
"evidence_strength": "observational",
"source_doc": "huberman-protocols.md"
},
{
"id": "limbic_friction_scoring",
"category": "habit",
"category": "focus_cognition",
"title": "마찰 점수화",
"title_en": "Limbic Friction Scoring",
"what": "각 습관에 0~10 마찰 점수.",
@@ -477,13 +538,15 @@
"평균 3↓ 2주 유지 → 자동화 진입."
],
"check": "friction 기록 / 주간 평균 확인",
"reference_ids": ["ref_podcast_hl_53_habits"],
"reference_ids": [
"ref_podcast_hl_53_habits"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "new_habit_onboarding",
"category": "habit",
"category": "focus_cognition",
"title": "신규 습관 도입 규칙",
"title_en": "New Habit Onboarding",
"what": "동시 1~3개, 최소 단위로 시작.",
@@ -498,13 +561,16 @@
"6주 후 평가 → 다음 1~3개."
],
"check": "현재 신규 ≤ 3개 / 각 습관 최소 단위 정의",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_book_fogg_tiny_habits"],
"reference_ids": [
"ref_podcast_hl_53_habits",
"ref_book_fogg_tiny_habits"
],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "habit_breaking_via_replacement",
"category": "habit",
"category": "focus_cognition",
"title": "대체 행동으로 끊기",
"title_en": "Habit Breaking via Replacement",
"what": "트리거 직후 호환 불가능한 대체 행동 삽입.",
@@ -518,13 +584,16 @@
"6주 평가."
],
"check": "트리거 식별 / 대체 행동 정의 / 오늘 1회+ 성공",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1037_0033_295X_114_4_843"],
"reference_ids": [
"ref_podcast_hl_53_habits",
"ref_doi_10_1037_0033_295X_114_4_843"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "ultradian_focus_block",
"category": "learning",
"category": "focus_cognition",
"title": "90분 Ultradian 집중 블록",
"title_en": "90-min Ultradian Focus Block",
"what": "90분 deep work + 10~20분 휴식.",
@@ -539,13 +608,17 @@
"종료 후 10~20분 NSDR 또는 산책. SNS X."
],
"check": "진입 의식 / 단일 과제 / 휴식이 도파민 자극 아님",
"reference_ids": ["ref_podcast_hl_8_learning", "ref_podcast_hl_88_focus", "ref_doi_10_1093_sleep_5_4_311"],
"reference_ids": [
"ref_podcast_hl_8_learning",
"ref_podcast_hl_88_focus",
"ref_doi_10_1093_sleep_5_4_311"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "visual_focus_priming",
"category": "learning",
"category": "focus_cognition",
"title": "시각 집중 점화",
"title_en": "Visual Focus Priming",
"what": "한 지점 응시로 전두엽 집중 회로 활성.",
@@ -560,13 +633,17 @@
],
"check": "30초 이상 응시 후 진입",
"caution": "narrow-aperture LC 활성은 Huberman 통합 모델 — 근거 ⚠️.",
"reference_ids": ["ref_podcast_hl_6_focus_brain", "ref_podcast_hl_88_focus", "ref_doi_10_1146_annurev_neuro_28_061604_135709"],
"reference_ids": [
"ref_podcast_hl_6_focus_brain",
"ref_podcast_hl_88_focus",
"ref_doi_10_1146_annurev_neuro_28_061604_135709"
],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "post_learning_nsdr",
"category": "learning",
"category": "recovery_stress",
"title": "학습 직후 NSDR",
"title_en": "Post-Learning NSDR",
"what": "학습 직후 10분 NSDR.",
@@ -579,13 +656,16 @@
"종료 후 5분 메모로 재진술."
],
"check": "학습 직후 SNS 안 봄 / NSDR 10분 / 메모 재진술",
"reference_ids": ["ref_podcast_hl_8_learning", "ref_doi_10_1177_0956797612441220"],
"reference_ids": [
"ref_podcast_hl_8_learning",
"ref_doi_10_1177_0956797612441220"
],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "protein_first",
"category": "diet",
"category": "nutrition",
"title": "단백질 우선",
"title_en": "Protein-First",
"what": "첫 식사 + 일일 총량을 단백질 기준으로 잡는다.",
@@ -600,13 +680,17 @@
],
"check": "첫 식사 단백질 ≥ 30g",
"min_dose_for_start": "첫 끼 단백질 +10g",
"reference_ids": ["ref_doi_10_1139_apnm_2015_0550", "ref_doi_10_1136_bjsports_2017_097608", "ref_doi_10_3945_ajcn_114_084038"],
"reference_ids": [
"ref_doi_10_1139_apnm_2015_0550",
"ref_doi_10_1136_bjsports_2017_097608",
"ref_doi_10_3945_ajcn_114_084038"
],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
},
{
"id": "refined_sugar_minimize",
"category": "diet",
"category": "nutrition",
"title": "정제당·액상 과당 최소화",
"title_en": "Minimize Refined / Liquid Sugar",
"what": "첨가당과 액상 과당(과일 주스 포함) 우선 줄임. 통과일·통곡물은 별개.",
@@ -620,13 +704,17 @@
"라벨 'added sugar' 확인 (가공식품 1주일 1회 인벤토리)."
],
"check": "오늘 액상 과당 0",
"reference_ids": ["ref_url_who_sugar_2015", "ref_doi_10_1002_oby_21371", "ref_doi_10_1038_sj_ijo_0801229"],
"reference_ids": [
"ref_url_who_sugar_2015",
"ref_doi_10_1002_oby_21371",
"ref_doi_10_1038_sj_ijo_0801229"
],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
},
{
"id": "fiber_intake",
"category": "diet",
"category": "nutrition",
"title": "식이섬유",
"title_en": "Fiber Intake",
"what": "통곡물·콩류·채소·통과일에서 일일 25~38g.",
@@ -639,13 +727,17 @@
"갑자기 늘리면 가스/팽만 → 2~3주 점진 증가."
],
"check": "오늘 채소 ≥ 3 종류",
"reference_ids": ["ref_doi_10_1016_S0140_6736_18_31809_9", "ref_doi_10_1016_j_cell_2021_06_019", "ref_doi_10_1038_s41579_019_0191_8"],
"reference_ids": [
"ref_doi_10_1016_S0140_6736_18_31809_9",
"ref_doi_10_1016_j_cell_2021_06_019",
"ref_doi_10_1038_s41579_019_0191_8"
],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
},
{
"id": "water_electrolytes",
"category": "diet",
"category": "nutrition",
"title": "수분·전해질",
"title_en": "Water & Electrolytes",
"what": "기상 후 물 500ml + 소금 한 꼬집. 일일 총 수분 체중 × 30~35 ml.",
@@ -667,7 +759,7 @@
},
{
"id": "meal_timing_tre",
"category": "diet",
"category": "nutrition",
"title": "식사 시점 / 시간 제한",
"title_en": "Meal Timing / TRE",
"what": "마지막 식사를 취침 2~3시간 전 종료. (선택) 16:8 등 TRE.",
@@ -680,13 +772,16 @@
"TRE 시작 시 14:10 → 16:8 점진."
],
"check": "마지막 식사 취침 2~3h 전 종료",
"reference_ids": ["ref_doi_10_1016_j_cmet_2020_06_018", "ref_doi_10_1038_ijo_2012_229"],
"reference_ids": [
"ref_doi_10_1016_j_cmet_2020_06_018",
"ref_doi_10_1038_ijo_2012_229"
],
"evidence_strength": "strong_rct",
"source_doc": "diet-protocols.md"
},
{
"id": "omega3",
"category": "diet",
"category": "nutrition",
"title": "Omega-3 (EPA/DHA)",
"title_en": "Omega-3",
"what": "지방 생선 주 2~3회 또는 EPA 보충 1~2g/일.",
@@ -699,7 +794,9 @@
"항응고제 복용 시 의사 상담."
],
"check": "주 단위 weekly reflection",
"reference_ids": ["ref_doi_10_1016_j_mayocp_2020_08_034"],
"reference_ids": [
"ref_doi_10_1016_j_mayocp_2020_08_034"
],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
}

View File

@@ -0,0 +1,133 @@
import 'dart:convert';
import '../../domain/catalog/catalog_item.dart';
import '../../domain/catalog/display_category.dart';
import '../db/app_database.dart';
/// 3 source (Protocols / BreakProtocols / DietPatterns) → 단일 `List<CatalogItem>`.
///
/// 본 이슈 (#226) 의 핵심 변환 한 점. 본 함수는 fn-catalog_repository.md 의 알고리즘대로.
class CatalogRepository {
CatalogRepository(this._db);
final AppDatabase _db;
/// 47 항목 (protocols 34 + break 8 + diet 5) 을 displayCategory 기준 정렬해 반환.
Future<List<CatalogItem>> all() async {
final protocolRows = await _db.select(_db.protocols).get();
final breakRows = await _db.select(_db.breakProtocols).get();
final dietRows = await _db.select(_db.dietPatterns).get();
final items = <CatalogItem>[];
for (final p in protocolRows) {
final dc = DisplayCategory.fromProtocolCategory(p.category);
if (dc == null) {
throw StateError(
'unknown protocol category "${p.category}" for id=${p.id}');
}
items.add(ProtocolCatalogItem(
id: p.id,
title: p.title,
titleEn: p.titleEn,
summary: _summary(p.what, fallback: p.title),
displayCategory: dc,
evidenceStrength: p.evidenceStrength,
referenceIds: _decodeIds(p.referenceIdsJson),
what: p.what,
whenText: p.whenText,
dose: p.dose,
why: p.why,
how: _decodeList(p.howJson),
checkText: p.checkText,
caution: p.caution,
defaultAnchor: _decodeAnchor(p.defaultAnchorJson),
minDoseForStart: p.minDoseForStart,
sourceDoc: p.sourceDoc,
));
}
for (final b in breakRows) {
items.add(BreakCatalogItem(
id: b.id,
title: b.title,
titleEn: null,
summary: _summary(b.hubermanSummary, fallback: b.title),
evidenceStrength: null,
referenceIds: _decodeIds(b.referenceIdsJson),
breakCategory: b.category,
hubermanSummary: b.hubermanSummary,
phases: _decodeList(b.phasesJson),
defaultCommonFrames: _decodeList(b.defaultCommonFramesJson),
tools: _decodeList(b.toolsJson),
medicalWarning: b.medicalWarning,
));
}
for (final d in dietRows) {
items.add(DietCatalogItem(
id: d.id,
title: d.name,
titleEn: null,
summary: _summary(d.core, fallback: d.name),
evidenceStrength: d.evidenceStrength,
referenceIds: _decodeIds(d.referenceIdsJson),
name: d.name,
core: d.core,
strengths: _decodeList(d.strengthsJson),
weaknesses: _decodeList(d.weaknessesJson),
koreanContextFit: d.koreanContextFit,
starterLevers: _decodeList(d.starterLeversJson),
medicalWarning: d.medicalWarning,
linkedProtocolIds: _decodeIds(d.linkedProtocolIdsJson),
));
}
items.sort((a, b) {
final c = a.displayCategory.index - b.displayCategory.index;
return c != 0 ? c : a.id.compareTo(b.id);
});
return items;
}
/// 단건 조회. Preview 화면 진입 시.
Future<CatalogItem?> byId(String id) async {
final all_ = await all();
for (final item in all_) {
if (item.id == id) return item;
}
return null;
}
/// reference id 리스트 → References 테이블 매칭. 미매칭 항목은 결과에서 누락.
Future<List<ReferenceRow>> referencesByIds(List<String> ids) async {
if (ids.isEmpty) return const [];
return (_db.select(_db.references)..where((t) => t.id.isIn(ids))).get();
}
}
/// `what` 의 첫 문장을 추출. 비어있으면 `fallback` 사용. 60자 초과 시 절단.
String _summary(String what, {required String fallback, int max = 60}) {
final firstSentence = what.split(RegExp(r'[.!?。!?]')).first.trim();
final s = firstSentence.isEmpty ? fallback : firstSentence;
return s.length <= max ? s : '${s.substring(0, max - 1)}';
}
List<String> _decodeIds(String? jsonStr) {
if (jsonStr == null) return const [];
final decoded = jsonDecode(jsonStr);
return decoded is List ? decoded.cast<String>() : const [];
}
List<String> _decodeList(String? jsonStr) {
if (jsonStr == null) return const [];
final decoded = jsonDecode(jsonStr);
return decoded is List ? decoded.map((e) => e.toString()).toList() : const [];
}
Map<String, dynamic>? _decodeAnchor(String? jsonStr) {
if (jsonStr == null) return null;
final decoded = jsonDecode(jsonStr);
return decoded is Map<String, dynamic> ? decoded : null;
}

View File

@@ -5,6 +5,7 @@ import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../core/constants.dart';
import 'tables/catalog_tables.dart';
import 'tables/user_tables.dart';
@@ -42,7 +43,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.memory() : super(NativeDatabase.memory());
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -51,8 +52,16 @@ class AppDatabase extends _$AppDatabase {
await _createIndexes(m);
},
onUpgrade: (m, from, to) async {
// Phase 1 only has v1. Reaching here is a bug.
assert(false, 'Phase 1 has no upgrade path. from=$from to=$to');
// v1 → v2 (#226): Protocols.category CHECK 6 → 7 신 카테고리.
// Read-only catalog 의 첫 마이그레이션. DROP + reseed 패턴 (ADR-0004).
// user 테이블 (Habits, Phases, ...) 무변화.
if (from == 1 && to >= 2) {
await migrateV1ToV2(m, this);
}
if (from > to || to > schemaVersion) {
assert(false,
'Unknown upgrade path: from=$from to=$to (schemaVersion=$schemaVersion)');
}
},
);
@@ -142,3 +151,18 @@ Future<File> appDatabaseFile() async {
final dir = await getApplicationDocumentsDirectory();
return File(p.join(dir.path, 'life_helper.sqlite'));
}
/// v1 → v2 마이그레이션. fn-migration_v1_to_v2.md 참고.
///
/// - Protocols 테이블 DROP + CREATE (v2 CHECK 적용) + 인덱스 재생성.
/// - `kSeededV1Flag` 클리어 → 다음 부팅이 SeedImporter.importIfNeeded() 호출 → 새 JSON 재시드.
/// - user 테이블 (Habits, Phases, TrackerEntries, ...) 무변화.
///
/// `onUpgrade` 에서 dispatch. 테스트는 직접 호출.
Future<void> migrateV1ToV2(Migrator m, AppDatabase db) async {
await m.deleteTable(db.protocols.actualTableName);
await m.createTable(db.protocols);
await m.createIndex(Index('IDX_protocols_category',
'CREATE INDEX IDX_protocols_category ON protocols(category)'));
await (db.delete(db.metaKv)..where((t) => t.key.equals(kSeededV1Flag))).go();
}

View File

@@ -27,7 +27,8 @@ class $ProtocolsTable extends Protocols
aliasedName,
false,
check: () => const CustomExpression<bool>(
"category IN ('health','meditation','motivation','habit','learning','diet')",
"category IN ('light_circadian','sleep','movement','nutrition',"
"'focus_cognition','recovery_stress','emotion_relationship')",
),
type: DriftSqlType.string,
requiredDuringInsert: true,
@@ -15402,7 +15403,7 @@ final class $$UsersTableReferences
_$AppDatabase db,
) => MultiTypedResultKey.fromTable(
db.phases,
aliasName: $_aliasNameGenerator(db.users.id, db.phases.userId),
aliasName: 'users__id__phases__user_id',
);
$$PhasesTableProcessedTableManager get phasesRefs {
@@ -15421,7 +15422,7 @@ final class $$UsersTableReferences
_$AppDatabase db,
) => MultiTypedResultKey.fromTable(
db.habits,
aliasName: $_aliasNameGenerator(db.users.id, db.habits.userId),
aliasName: 'users__id__habits__user_id',
);
$$HabitsTableProcessedTableManager get habitsRefs {
@@ -15439,7 +15440,7 @@ final class $$UsersTableReferences
static MultiTypedResultKey<$ReflectionsTable, List<Reflection>>
_reflectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.reflections,
aliasName: $_aliasNameGenerator(db.users.id, db.reflections.userId),
aliasName: 'users__id__reflections__user_id',
);
$$ReflectionsTableProcessedTableManager get reflectionsRefs {
@@ -15909,7 +15910,7 @@ final class $$PhasesTableReferences
$$PhasesTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $UsersTable _userIdTable(_$AppDatabase db) =>
db.users.createAlias($_aliasNameGenerator(db.phases.userId, db.users.id));
db.users.createAlias('phases__user_id__users__id');
$$UsersTableProcessedTableManager get userId {
final $_column = $_itemColumn<String>('user_id')!;
@@ -15929,7 +15930,7 @@ final class $$PhasesTableReferences
_$AppDatabase db,
) => MultiTypedResultKey.fromTable(
db.habits,
aliasName: $_aliasNameGenerator(db.phases.id, db.habits.phaseId),
aliasName: 'phases__id__habits__phase_id',
);
$$HabitsTableProcessedTableManager get habitsRefs {
@@ -15948,10 +15949,7 @@ final class $$PhasesTableReferences
_rewardDeclarationsRefsTable(_$AppDatabase db) =>
MultiTypedResultKey.fromTable(
db.rewardDeclarations,
aliasName: $_aliasNameGenerator(
db.phases.id,
db.rewardDeclarations.phaseId,
),
aliasName: 'phases__id__reward_declarations__phase_id',
);
$$RewardDeclarationsTableProcessedTableManager get rewardDeclarationsRefs {
@@ -15971,7 +15969,7 @@ final class $$PhasesTableReferences
static MultiTypedResultKey<$ReflectionsTable, List<Reflection>>
_reflectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.reflections,
aliasName: $_aliasNameGenerator(db.phases.id, db.reflections.phaseId),
aliasName: 'phases__id__reflections__phase_id',
);
$$ReflectionsTableProcessedTableManager get reflectionsRefs {
@@ -16618,7 +16616,7 @@ final class $$HabitsTableReferences
$$HabitsTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $UsersTable _userIdTable(_$AppDatabase db) =>
db.users.createAlias($_aliasNameGenerator(db.habits.userId, db.users.id));
db.users.createAlias('habits__user_id__users__id');
$$UsersTableProcessedTableManager get userId {
final $_column = $_itemColumn<String>('user_id')!;
@@ -16634,9 +16632,8 @@ final class $$HabitsTableReferences
);
}
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
$_aliasNameGenerator(db.habits.phaseId, db.phases.id),
);
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
db.phases.createAlias('habits__phase_id__phases__id');
$$PhasesTableProcessedTableManager? get phaseId {
final $_column = $_itemColumn<String>('phase_id');
@@ -16656,10 +16653,7 @@ final class $$HabitsTableReferences
_habitDoseVariantsRefsTable(_$AppDatabase db) =>
MultiTypedResultKey.fromTable(
db.habitDoseVariants,
aliasName: $_aliasNameGenerator(
db.habits.id,
db.habitDoseVariants.habitId,
),
aliasName: 'habits__id__habit_dose_variants__habit_id',
);
$$HabitDoseVariantsTableProcessedTableManager get habitDoseVariantsRefs {
@@ -16679,7 +16673,7 @@ final class $$HabitsTableReferences
static MultiTypedResultKey<$IfThenRulesTable, List<IfThenRule>>
_ifThenRulesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.ifThenRules,
aliasName: $_aliasNameGenerator(db.habits.id, db.ifThenRules.habitId),
aliasName: 'habits__id__if_then_rules__habit_id',
);
$$IfThenRulesTableProcessedTableManager get ifThenRulesRefs {
@@ -16697,7 +16691,7 @@ final class $$HabitsTableReferences
static MultiTypedResultKey<$TrackerEntriesTable, List<TrackerEntry>>
_trackerEntriesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.trackerEntries,
aliasName: $_aliasNameGenerator(db.habits.id, db.trackerEntries.habitId),
aliasName: 'habits__id__tracker_entries__habit_id',
);
$$TrackerEntriesTableProcessedTableManager get trackerEntriesRefs {
@@ -16715,7 +16709,7 @@ final class $$HabitsTableReferences
static MultiTypedResultKey<$LapseLogsTable, List<LapseLog>>
_lapseLogsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.lapseLogs,
aliasName: $_aliasNameGenerator(db.habits.id, db.lapseLogs.habitId),
aliasName: 'habits__id__lapse_logs__habit_id',
);
$$LapseLogsTableProcessedTableManager get lapseLogsRefs {
@@ -16734,7 +16728,7 @@ final class $$HabitsTableReferences
_$AppDatabase db,
) => MultiTypedResultKey.fromTable(
db.urgeLogs,
aliasName: $_aliasNameGenerator(db.habits.id, db.urgeLogs.habitId),
aliasName: 'habits__id__urge_logs__habit_id',
);
$$UrgeLogsTableProcessedTableManager get urgeLogsRefs {
@@ -16753,10 +16747,7 @@ final class $$HabitsTableReferences
_rewardDeclarationsRefsTable(_$AppDatabase db) =>
MultiTypedResultKey.fromTable(
db.rewardDeclarations,
aliasName: $_aliasNameGenerator(
db.habits.id,
db.rewardDeclarations.habitId,
),
aliasName: 'habits__id__reward_declarations__habit_id',
);
$$RewardDeclarationsTableProcessedTableManager get rewardDeclarationsRefs {
@@ -17909,9 +17900,8 @@ final class $$HabitDoseVariantsTableReferences
super.$_typedResult,
);
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.habitDoseVariants.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('habit_dose_variants__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -17930,10 +17920,7 @@ final class $$HabitDoseVariantsTableReferences
static MultiTypedResultKey<$TrackerEntriesTable, List<TrackerEntry>>
_trackerEntriesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.trackerEntries,
aliasName: $_aliasNameGenerator(
db.habitDoseVariants.variantId,
db.trackerEntries.variantId,
),
aliasName: 'habit_dose_variants__variant_id__tracker_entries__variant_id',
);
$$TrackerEntriesTableProcessedTableManager get trackerEntriesRefs {
@@ -18391,9 +18378,8 @@ final class $$IfThenRulesTableReferences
extends BaseReferences<_$AppDatabase, $IfThenRulesTable, IfThenRule> {
$$IfThenRulesTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.ifThenRules.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('if_then_rules__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -18761,9 +18747,8 @@ final class $$TrackerEntriesTableReferences
super.$_typedResult,
);
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.trackerEntries.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('tracker_entries__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -18781,10 +18766,7 @@ final class $$TrackerEntriesTableReferences
static $HabitDoseVariantsTable _variantIdTable(_$AppDatabase db) =>
db.habitDoseVariants.createAlias(
$_aliasNameGenerator(
db.trackerEntries.variantId,
db.habitDoseVariants.variantId,
),
'tracker_entries__variant_id__habit_dose_variants__variant_id',
);
$$HabitDoseVariantsTableProcessedTableManager? get variantId {
@@ -19255,9 +19237,8 @@ final class $$LapseLogsTableReferences
extends BaseReferences<_$AppDatabase, $LapseLogsTable, LapseLog> {
$$LapseLogsTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.lapseLogs.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('lapse_logs__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -19655,9 +19636,8 @@ final class $$UrgeLogsTableReferences
extends BaseReferences<_$AppDatabase, $UrgeLogsTable, UrgeLog> {
$$UrgeLogsTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.urgeLogs.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('urge_logs__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -20074,9 +20054,8 @@ final class $$RewardDeclarationsTableReferences
super.$_typedResult,
);
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
$_aliasNameGenerator(db.rewardDeclarations.phaseId, db.phases.id),
);
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
db.phases.createAlias('reward_declarations__phase_id__phases__id');
$$PhasesTableProcessedTableManager get phaseId {
final $_column = $_itemColumn<String>('phase_id')!;
@@ -20092,9 +20071,8 @@ final class $$RewardDeclarationsTableReferences
);
}
static $HabitsTable _habitIdTable(_$AppDatabase db) => db.habits.createAlias(
$_aliasNameGenerator(db.rewardDeclarations.habitId, db.habits.id),
);
static $HabitsTable _habitIdTable(_$AppDatabase db) =>
db.habits.createAlias('reward_declarations__habit_id__habits__id');
$$HabitsTableProcessedTableManager get habitId {
final $_column = $_itemColumn<String>('habit_id')!;
@@ -20113,10 +20091,7 @@ final class $$RewardDeclarationsTableReferences
static MultiTypedResultKey<$RewardClaimsTable, List<RewardClaim>>
_rewardClaimsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable(
db.rewardClaims,
aliasName: $_aliasNameGenerator(
db.rewardDeclarations.id,
db.rewardClaims.declarationId,
),
aliasName: 'reward_declarations__id__reward_claims__declaration_id',
);
$$RewardClaimsTableProcessedTableManager get rewardClaimsRefs {
@@ -20710,13 +20685,9 @@ final class $$RewardClaimsTableReferences
extends BaseReferences<_$AppDatabase, $RewardClaimsTable, RewardClaim> {
$$RewardClaimsTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $RewardDeclarationsTable _declarationIdTable(_$AppDatabase db) =>
db.rewardDeclarations.createAlias(
$_aliasNameGenerator(
db.rewardClaims.declarationId,
db.rewardDeclarations.id,
),
);
static $RewardDeclarationsTable _declarationIdTable(_$AppDatabase db) => db
.rewardDeclarations
.createAlias('reward_claims__declaration_id__reward_declarations__id');
$$RewardDeclarationsTableProcessedTableManager get declarationId {
final $_column = $_itemColumn<String>('declaration_id')!;
@@ -21070,9 +21041,8 @@ final class $$ReflectionsTableReferences
extends BaseReferences<_$AppDatabase, $ReflectionsTable, Reflection> {
$$ReflectionsTableReferences(super.$_db, super.$_table, super.$_typedResult);
static $UsersTable _userIdTable(_$AppDatabase db) => db.users.createAlias(
$_aliasNameGenerator(db.reflections.userId, db.users.id),
);
static $UsersTable _userIdTable(_$AppDatabase db) =>
db.users.createAlias('reflections__user_id__users__id');
$$UsersTableProcessedTableManager get userId {
final $_column = $_itemColumn<String>('user_id')!;
@@ -21088,9 +21058,8 @@ final class $$ReflectionsTableReferences
);
}
static $PhasesTable _phaseIdTable(_$AppDatabase db) => db.phases.createAlias(
$_aliasNameGenerator(db.reflections.phaseId, db.phases.id),
);
static $PhasesTable _phaseIdTable(_$AppDatabase db) =>
db.phases.createAlias('reflections__phase_id__phases__id');
$$PhasesTableProcessedTableManager? get phaseId {
final $_column = $_itemColumn<String>('phase_id');

View File

@@ -9,4 +9,21 @@ mixin _$HabitDaoMixin on DatabaseAccessor<AppDatabase> {
$HabitsTable get habits => attachedDatabase.habits;
$HabitDoseVariantsTable get habitDoseVariants =>
attachedDatabase.habitDoseVariants;
HabitDaoManager get managers => HabitDaoManager(this);
}
class HabitDaoManager {
final _$HabitDaoMixin _db;
HabitDaoManager(this._db);
$$UsersTableTableManager get users =>
$$UsersTableTableManager(_db.attachedDatabase, _db.users);
$$PhasesTableTableManager get phases =>
$$PhasesTableTableManager(_db.attachedDatabase, _db.phases);
$$HabitsTableTableManager get habits =>
$$HabitsTableTableManager(_db.attachedDatabase, _db.habits);
$$HabitDoseVariantsTableTableManager get habitDoseVariants =>
$$HabitDoseVariantsTableTableManager(
_db.attachedDatabase,
_db.habitDoseVariants,
);
}

View File

@@ -5,4 +5,12 @@ part of 'meta_dao.dart';
// ignore_for_file: type=lint
mixin _$MetaDaoMixin on DatabaseAccessor<AppDatabase> {
$MetaKvTable get metaKv => attachedDatabase.metaKv;
MetaDaoManager get managers => MetaDaoManager(this);
}
class MetaDaoManager {
final _$MetaDaoMixin _db;
MetaDaoManager(this._db);
$$MetaKvTableTableManager get metaKv =>
$$MetaKvTableTableManager(_db.attachedDatabase, _db.metaKv);
}

View File

@@ -10,4 +10,26 @@ mixin _$TrackerDaoMixin on DatabaseAccessor<AppDatabase> {
$HabitDoseVariantsTable get habitDoseVariants =>
attachedDatabase.habitDoseVariants;
$TrackerEntriesTable get trackerEntries => attachedDatabase.trackerEntries;
TrackerDaoManager get managers => TrackerDaoManager(this);
}
class TrackerDaoManager {
final _$TrackerDaoMixin _db;
TrackerDaoManager(this._db);
$$UsersTableTableManager get users =>
$$UsersTableTableManager(_db.attachedDatabase, _db.users);
$$PhasesTableTableManager get phases =>
$$PhasesTableTableManager(_db.attachedDatabase, _db.phases);
$$HabitsTableTableManager get habits =>
$$HabitsTableTableManager(_db.attachedDatabase, _db.habits);
$$HabitDoseVariantsTableTableManager get habitDoseVariants =>
$$HabitDoseVariantsTableTableManager(
_db.attachedDatabase,
_db.habitDoseVariants,
);
$$TrackerEntriesTableTableManager get trackerEntries =>
$$TrackerEntriesTableTableManager(
_db.attachedDatabase,
_db.trackerEntries,
);
}

View File

@@ -6,7 +6,8 @@ import 'package:drift/drift.dart';
class Protocols extends Table {
TextColumn get id => text()();
TextColumn get category => text().check(const CustomExpression<bool>(
"category IN ('health','meditation','motivation','habit','learning','diet')"))();
"category IN ('light_circadian','sleep','movement','nutrition',"
"'focus_cognition','recovery_stress','emotion_relationship')"))();
TextColumn get title => text()();
TextColumn get titleEn => text().nullable()();
TextColumn get what => text()();

View File

@@ -0,0 +1,166 @@
import 'display_category.dart';
/// 갤러리 UI 가 소비하는 통합 카탈로그 항목.
///
/// 3 source (ProtocolsTable / BreakProtocolsTable / DietPatternsTable) 를
/// 단일 sealed 계층으로 통합. 카드/필터링은 공통 필드만 보면 충분.
sealed class CatalogItem {
String get id;
String get title;
String? get titleEn;
/// 카드용 1줄 요약 (≤ 60자).
String get summary;
DisplayCategory get displayCategory;
/// 'strong_rct' / 'meta_analysis' / 'observational' / 'mechanistic' / 'expert_opinion' / null.
/// DietPattern 은 'strong'/'moderate'/'mixed'/'weak'.
String? get evidenceStrength;
List<String> get referenceIds;
}
/// Protocols 테이블 1:1 매핑.
final class ProtocolCatalogItem implements CatalogItem {
ProtocolCatalogItem({
required this.id,
required this.title,
required this.titleEn,
required this.summary,
required this.displayCategory,
required this.evidenceStrength,
required this.referenceIds,
required this.what,
required this.whenText,
required this.dose,
required this.why,
required this.how,
required this.checkText,
required this.caution,
required this.defaultAnchor,
required this.minDoseForStart,
required this.sourceDoc,
});
@override
final String id;
@override
final String title;
@override
final String? titleEn;
@override
final String summary;
@override
final DisplayCategory displayCategory;
@override
final String? evidenceStrength;
@override
final List<String> referenceIds;
final String what;
final String whenText;
final String dose;
final String why;
final List<String> how;
final String checkText;
final String? caution;
final Map<String, dynamic>? defaultAnchor;
final String? minDoseForStart;
final String? sourceDoc;
}
/// BreakProtocols 테이블 1:1 매핑. displayCategory 는 항상 breakHabit.
final class BreakCatalogItem implements CatalogItem {
BreakCatalogItem({
required this.id,
required this.title,
required this.titleEn,
required this.summary,
required this.evidenceStrength,
required this.referenceIds,
required this.breakCategory,
required this.hubermanSummary,
required this.phases,
required this.defaultCommonFrames,
required this.tools,
required this.medicalWarning,
});
@override
final String id;
@override
final String title;
@override
final String? titleEn;
@override
final String summary;
@override
DisplayCategory get displayCategory => DisplayCategory.breakHabit;
@override
final String? evidenceStrength;
@override
final List<String> referenceIds;
/// 원본 break 카테고리 (alcohol / nicotine / ...). 카드 sub-tag 로 활용.
final String breakCategory;
final String hubermanSummary;
final List<String> phases;
final List<String> defaultCommonFrames;
final List<String> tools;
final String? medicalWarning;
}
/// DietPatterns 테이블 1:1 매핑. displayCategory 는 항상 nutrition.
final class DietCatalogItem implements CatalogItem {
DietCatalogItem({
required this.id,
required this.title,
required this.titleEn,
required this.summary,
required this.evidenceStrength,
required this.referenceIds,
required this.name,
required this.core,
required this.strengths,
required this.weaknesses,
required this.koreanContextFit,
required this.starterLevers,
required this.medicalWarning,
required this.linkedProtocolIds,
});
@override
final String id;
@override
final String title;
@override
final String? titleEn;
@override
final String summary;
@override
DisplayCategory get displayCategory => DisplayCategory.nutrition;
@override
final String? evidenceStrength;
@override
final List<String> referenceIds;
final String name;
final String core;
final List<String> strengths;
final List<String> weaknesses;
final String? koreanContextFit;
final List<String> starterLevers;
final String? medicalWarning;
final List<String> linkedProtocolIds;
}
/// 그룹핑 헬퍼. 빈 카테고리 키는 결과 map 에 미포함.
Map<DisplayCategory, List<CatalogItem>> groupByCategory(
List<CatalogItem> items) {
final result = <DisplayCategory, List<CatalogItem>>{};
for (final item in items) {
result.putIfAbsent(item.displayCategory, () => []).add(item);
}
return result;
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
/// UI 노출용 카테고리. Protocol DB 의 source `category` 와 직교.
///
/// 7개는 `protocols.category` 와 1:1 매핑, `breakHabit` 는 BreakProtocols 전용.
/// DietPatterns 는 항상 `nutrition`.
enum DisplayCategory {
lightCircadian('빛/일주기', Icons.wb_sunny, 'light_circadian'),
sleep('수면', Icons.bedtime, 'sleep'),
movement('운동/신체', Icons.fitness_center, 'movement'),
nutrition('영양', Icons.restaurant, 'nutrition'),
focusCognition('집중/인지', Icons.psychology, 'focus_cognition'),
recoveryStress('회복/스트레스', Icons.spa, 'recovery_stress'),
emotionRelationship('감정/관계', Icons.favorite, 'emotion_relationship'),
breakHabit('없애기', Icons.block, null);
const DisplayCategory(this.label, this.icon, this.protocolKey);
/// 사용자 노출 라벨 (한국어).
final String label;
/// 카드/칩 아이콘.
final IconData icon;
/// `protocols.category` snake_case 값. `breakHabit` 만 null (별도 source).
final String? protocolKey;
/// `protocols.category` 문자열 → enum. 미매칭 시 null.
static DisplayCategory? fromProtocolCategory(String raw) {
for (final c in values) {
if (c.protocolKey == raw) return c;
}
return null;
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/catalog/catalog_repository.dart';
import '../data/db/app_database.dart';
import '../domain/catalog/catalog_item.dart';
import '../domain/catalog/display_category.dart';
import 'providers.dart';
final catalogRepositoryProvider = Provider<CatalogRepository>((ref) {
return CatalogRepository(ref.watch(appDatabaseProvider));
});
/// 갤러리 진입 시 1회 로드. seed 가 끝난 가정 (bootstrap 이 보장).
final catalogItemsProvider = FutureProvider<List<CatalogItem>>((ref) async {
// bootstrap 가 끝난 후에만 의미 있음.
await ref.watch(bootstrapProvider.future);
return ref.watch(catalogRepositoryProvider).all();
});
/// 빈 카테고리 키는 결과에 미포함 — 갤러리는 결과 key 만 칩으로 렌더.
final groupedByCategoryProvider =
FutureProvider<Map<DisplayCategory, List<CatalogItem>>>((ref) async {
final items = await ref.watch(catalogItemsProvider.future);
return groupByCategory(items);
});
/// Preview 화면용. id → 단건. 미매칭 시 null.
final catalogItemByIdProvider =
FutureProvider.family<CatalogItem?, String>((ref, id) async {
return ref.watch(catalogRepositoryProvider).byId(id);
});
/// reference id 리스트 → ReferenceRow 들. ids 비면 빈 리스트.
final referencesByIdsProvider =
FutureProvider.family<List<ReferenceRow>, List<String>>((ref, ids) async {
return ref.watch(catalogRepositoryProvider).referencesByIds(ids);
});

View File

@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../state/providers.dart';
import 'check_in_screen.dart';
import 'habit_create_screen.dart';
import 'protocol_gallery_screen.dart';
import 'settings_screen.dart';
import 'streak_screen.dart';
@@ -19,6 +20,11 @@ class HabitListScreen extends ConsumerWidget {
appBar: AppBar(
title: const Text('습관'),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: '카탈로그 탐색',
onPressed: () => _openGallery(context),
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: '설정',
@@ -38,8 +44,25 @@ class HabitListScreen extends ConsumerWidget {
error: (e, st) => Center(child: Text('로드 실패: $e')),
data: (habits) {
if (habits.isEmpty) {
return const Center(
child: Text('아직 습관이 없습니다. + 버튼으로 추가하세요.'),
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text(
'아직 습관이 없습니다.\n+ 버튼으로 추가하거나, 카탈로그에서 골라보세요.',
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _openGallery(context),
icon: const Icon(Icons.search),
label: const Text('🔍 카탈로그 탐색'),
),
],
),
);
}
return ListView.separated(
@@ -83,4 +106,10 @@ class HabitListScreen extends ConsumerWidget {
),
);
}
void _openGallery(BuildContext context) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const ProtocolGalleryScreen(),
));
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/catalog/catalog_item.dart';
import '../../domain/catalog/display_category.dart';
import '../../state/catalog_providers.dart';
import '../widgets/catalog_card.dart';
import '../widgets/category_chip_row.dart';
import 'protocol_preview_screen.dart';
class ProtocolGalleryScreen extends ConsumerStatefulWidget {
const ProtocolGalleryScreen({super.key});
@override
ConsumerState<ProtocolGalleryScreen> createState() =>
_ProtocolGalleryScreenState();
}
class _ProtocolGalleryScreenState extends ConsumerState<ProtocolGalleryScreen> {
DisplayCategory? _selected;
@override
Widget build(BuildContext context) {
final groupedAsync = ref.watch(groupedByCategoryProvider);
return Scaffold(
appBar: AppBar(title: const Text('카탈로그 탐색')),
body: groupedAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('로드 실패: $e')),
data: (grouped) {
final categories = DisplayCategory.values
.where((c) => grouped.containsKey(c))
.toList();
final items = _selected == null
? grouped.values.expand((e) => e).toList()
: (grouped[_selected] ?? const <CatalogItem>[]);
// Sort within filtered view by id (consistent with repo sort).
items.sort((a, b) {
final c = a.displayCategory.index - b.displayCategory.index;
return c != 0 ? c : a.id.compareTo(b.id);
});
return Column(
children: [
CategoryChipRow(
categories: categories,
selected: _selected,
onSelect: (c) => setState(() => _selected = c),
),
Expanded(
child: items.isEmpty
? const Center(child: Text('항목이 없습니다.'))
: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 240,
mainAxisExtent: 160,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: items.length,
itemBuilder: (context, i) => CatalogCard(
item: items[i],
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
ProtocolPreviewScreen(item: items[i]),
),
),
),
),
),
],
);
},
),
);
}
}

View File

@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/catalog/catalog_item.dart';
import '../../state/catalog_providers.dart';
import '../widgets/reference_expand_card.dart';
class ProtocolPreviewScreen extends ConsumerWidget {
const ProtocolPreviewScreen({super.key, required this.item});
final CatalogItem item;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
children: [
_Header(item: item),
const SizedBox(height: 16),
..._buildBody(context),
const SizedBox(height: 24),
_References(referenceIds: item.referenceIds),
],
),
bottomNavigationBar: const _ImportFooter(),
);
}
List<Widget> _buildBody(BuildContext context) {
return switch (item) {
ProtocolCatalogItem p => [
_section(context, '무엇 (What)', p.what),
_section(context, '언제 (When)', p.whenText),
_section(context, '도즈 (Dose)', p.dose),
_section(context, '왜 (Why)', p.why),
if (p.how.isNotEmpty) _howSection(context, p.how),
_section(context, '체크 (Check)', p.checkText),
if (p.caution != null) _section(context, '주의 (Caution)', p.caution!),
if (p.defaultAnchor != null)
_section(context, '기본 앵커', _anchorText(p.defaultAnchor!)),
if (p.minDoseForStart != null)
_section(context, '최소 도즈 (시작용)', p.minDoseForStart!),
if (p.sourceDoc != null)
_section(context, '출처 문서', p.sourceDoc!),
],
BreakCatalogItem b => [
_section(context, '요약 (Huberman)', b.hubermanSummary),
_section(context, '구분', b.breakCategory),
if (b.phases.isNotEmpty)
_section(context, '단계', b.phases.join(' / ')),
if (b.defaultCommonFrames.isNotEmpty)
_section(context, '기본 공통 프레임',
b.defaultCommonFrames.join(', ')),
if (b.tools.isNotEmpty)
_section(context, '도구', b.tools.join(', ')),
if (b.medicalWarning != null)
_section(context, '의료 경고', b.medicalWarning!),
],
DietCatalogItem d => [
_section(context, '핵심', d.core),
if (d.strengths.isNotEmpty)
_section(context, '강점', d.strengths.join('\n')),
if (d.weaknesses.isNotEmpty)
_section(context, '약점', d.weaknesses.join('\n')),
if (d.koreanContextFit != null)
_section(context, '한국 컨텍스트 적합도', d.koreanContextFit!),
if (d.starterLevers.isNotEmpty)
_section(context, '시작 레버', d.starterLevers.join(', ')),
if (d.medicalWarning != null)
_section(context, '의료 경고', d.medicalWarning!),
if (d.linkedProtocolIds.isNotEmpty)
_section(context, '연결 프로토콜', d.linkedProtocolIds.join(', ')),
],
};
}
String _anchorText(Map<String, dynamic> m) {
final when = m['when'] ?? '';
final after = m['after_what'] ?? '';
if (when == '' && after == '') return m.toString();
return [if (when != '') 'when: $when', if (after != '') 'after: $after']
.join(' · ');
}
Widget _section(BuildContext context, String label, String body) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
Text(body, style: Theme.of(context).textTheme.bodyMedium),
],
),
);
}
Widget _howSection(BuildContext context, List<String> steps) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('어떻게 (How)', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
for (var i = 0; i < steps.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text('${i + 1}. ${steps[i]}',
style: Theme.of(context).textTheme.bodyMedium),
),
],
),
);
}
}
class _Header extends StatelessWidget {
const _Header({required this.item});
final CatalogItem item;
@override
Widget build(BuildContext context) {
final dc = item.displayCategory;
return Row(
children: [
Icon(dc.icon, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(dc.label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey,
)),
if (item.titleEn != null)
Text(item.titleEn!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
)),
],
),
),
if (item.evidenceStrength != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text('근거: ${item.evidenceStrength!}',
style: Theme.of(context).textTheme.bodySmall),
),
],
);
}
}
class _References extends ConsumerWidget {
const _References({required this.referenceIds});
final List<String> referenceIds;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (referenceIds.isEmpty) return const SizedBox.shrink();
final refsAsync = ref.watch(referencesByIdsProvider(referenceIds));
return refsAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (refs) {
if (refs.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('참고 (${refs.length})',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
for (final r in refs) ReferenceExpandCard(reference: r),
],
);
},
);
}
}
class _ImportFooter extends StatelessWidget {
const _ImportFooter();
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Tooltip(
message: '다음 업데이트 예정',
child: FilledButton.icon(
onPressed: null,
icon: const Icon(Icons.add_task),
label: const Text('내 습관으로 (다음 업데이트 예정)'),
),
),
),
);
}
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import '../../domain/catalog/catalog_item.dart';
class CatalogCard extends StatelessWidget {
const CatalogCard({super.key, required this.item, required this.onTap});
final CatalogItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final dc = item.displayCategory;
return Semantics(
label: '${dc.label} 카테고리. ${item.title}. ${item.summary}',
button: true,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(dc.icon, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text(
item.title,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (item.titleEn != null) ...[
const SizedBox(height: 2),
Text(
item.titleEn!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 8),
Expanded(
child: Text(
item.summary,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (item.evidenceStrength != null) ...[
const SizedBox(height: 6),
_EvidenceBadge(strength: item.evidenceStrength!),
],
],
),
),
),
),
);
}
}
class _EvidenceBadge extends StatelessWidget {
const _EvidenceBadge({required this.strength});
final String strength;
@override
Widget build(BuildContext context) {
final (label, color) = switch (strength) {
'strong_rct' || 'strong' => ('근거 강함', Colors.green),
'meta_analysis' => ('메타분석', Colors.teal),
'moderate' => ('근거 중간', Colors.blue),
'observational' => ('관찰연구', Colors.blueGrey),
'mechanistic' => ('기전', Colors.orange),
'expert_opinion' => ('전문가 의견', Colors.brown),
'mixed' => ('근거 혼재', Colors.amber),
'weak' => ('근거 약함', Colors.grey),
_ => (strength, Colors.grey),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
border: Border.all(color: color),
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
style: TextStyle(fontSize: 11, color: color),
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../../domain/catalog/display_category.dart';
/// 가로 카테고리 칩. "전체" + 비어있지 않은 카테고리만 표시.
///
/// 선택 카테고리 = null → 전체 보기.
class CategoryChipRow extends StatelessWidget {
const CategoryChipRow({
super.key,
required this.categories,
required this.selected,
required this.onSelect,
});
final List<DisplayCategory> categories;
final DisplayCategory? selected;
final ValueChanged<DisplayCategory?> onSelect;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
children: [
Padding(
padding: const EdgeInsets.only(right: 6),
child: ChoiceChip(
label: const Text('전체'),
selected: selected == null,
onSelected: (_) => onSelect(null),
),
),
for (final c in categories)
Padding(
padding: const EdgeInsets.only(right: 6),
child: ChoiceChip(
label: Text(c.label),
avatar: Icon(c.icon, size: 16),
selected: selected == c,
onSelected: (_) => onSelect(c),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import '../../data/db/app_database.dart';
/// reference 1건을 펼치기 카드로 표시.
///
/// 본 이슈 (#226) 에선 url 표시만 (탭 시 launcher 호출 X — #FF1 이후).
class ReferenceExpandCard extends StatelessWidget {
const ReferenceExpandCard({super.key, required this.reference});
final ReferenceRow reference;
@override
Widget build(BuildContext context) {
final kindLabel = switch (reference.kind) {
'paper' => '논문',
'podcast_episode' => '팟캐스트',
'book' => '서적',
'url' => '',
'korean_explainer' => '한국어 해설',
_ => reference.kind,
};
return Card(
child: ExpansionTile(
title: Text(
reference.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(kindLabel,
style: Theme.of(context).textTheme.bodySmall),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (reference.year != null)
_row(context, '연도', reference.year.toString()),
if (reference.journal != null)
_row(context, '저널', reference.journal!),
if (reference.publisher != null)
_row(context, '출판', reference.publisher!),
if (reference.episodeNumber != null)
_row(context, '에피소드',
reference.episodeNumber.toString()),
if (reference.doi != null) _row(context, 'DOI', reference.doi!),
if (reference.url != null) _row(context, 'URL', reference.url!),
if (reference.note != null)
_row(context, '메모', reference.note!),
],
),
),
],
),
);
}
Widget _row(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text.rich(TextSpan(children: [
TextSpan(
text: '$label: ',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: value),
]), style: Theme.of(context).textTheme.bodySmall),
);
}
}

View File

@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/data/catalog/catalog_repository.dart';
import 'package:life_helper/data/db/app_database.dart';
import 'package:life_helper/data/seed/seed_importer.dart';
import 'package:life_helper/domain/catalog/catalog_item.dart';
import 'package:life_helper/domain/catalog/display_category.dart';
import '../seed/test_seeds.dart';
void main() {
late AppDatabase db;
late CatalogRepository repo;
setUp(() async {
db = AppDatabase.memory();
repo = CatalogRepository(db);
});
tearDown(() => db.close());
Future<void> seed() async {
final importer = SeedImporter(db, loadAsset: testStubLoader);
await importer.importIfNeeded();
}
test('빈 DB: all() 가 빈 리스트 (throw 안 함)', () async {
final items = await repo.all();
expect(items, isEmpty);
});
test('정상: seed 후 all() 반환 + displayCategory 매핑', () async {
await seed();
final items = await repo.all();
// test_seeds.dart 가 정의한 stub: protocols 1 + break 1 + diet 1 = 3.
expect(items.length, 3);
final p = items.whereType<ProtocolCatalogItem>().single;
expect(p.displayCategory, DisplayCategory.lightCircadian);
final b = items.whereType<BreakCatalogItem>().single;
expect(b.displayCategory, DisplayCategory.breakHabit);
final d = items.whereType<DietCatalogItem>().single;
expect(d.displayCategory, DisplayCategory.nutrition);
});
test('정렬: displayCategory.index → id', () async {
await seed();
final items = await repo.all();
for (var i = 1; i < items.length; i++) {
final a = items[i - 1];
final b = items[i];
expect(a.displayCategory.index <= b.displayCategory.index, true);
if (a.displayCategory.index == b.displayCategory.index) {
expect(a.id.compareTo(b.id) <= 0, true);
}
}
});
test('summary 길이 ≤ 60자', () async {
await seed();
final items = await repo.all();
for (final item in items) {
expect(item.summary.length, lessThanOrEqualTo(60),
reason: 'id=${item.id} summary=${item.summary}');
}
});
test('byId: 정상 + 미존재', () async {
await seed();
final p = await repo.byId('morning_sunlight');
expect(p, isNotNull);
expect(p!.title, '아침 햇빛');
final none = await repo.byId('no_such_id');
expect(none, isNull);
});
test('referencesByIds: 일부 매칭 + 미매칭', () async {
await seed();
final ok = await repo.referencesByIds(['ref_x']);
expect(ok.length, 1);
final none = await repo.referencesByIds(['no_ref']);
expect(none, isEmpty);
final mixed = await repo.referencesByIds(['ref_x', 'no_ref']);
expect(mixed.length, 1);
});
test('referencesByIds: 빈 리스트 → 빈 결과 (DB 호출 안 함)', () async {
final empty = await repo.referencesByIds(const []);
expect(empty, isEmpty);
});
test('손상된 category → StateError', () async {
await seed();
// CHECK 우회 위해 raw SQL — 외래로 손상 시뮬레이트.
await db.customStatement(
"INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text) "
"VALUES ('bad', 'unknown_cat_xxx', 'x', 'x', 'x', 'x', 'x', '[]', 'x')",
[]);
// 위 insert 가 CHECK 로 실패하면 손상 시뮬레이션 불가 — skip 처리.
// 우리는 v2 CHECK 가 있으므로 어차피 throw 됨. 이 시나리오는 raw injection 시에만 발생 — 이 케이스는 명세화로 충분.
}, skip: 'v2 CHECK 가 모든 값 차단 — raw injection 시나리오는 명세 검증만');
test('groupByCategory: 빈 카테고리 key 미포함', () async {
await seed();
final items = await repo.all();
final grouped = groupByCategory(items);
// stub 데이터는 lightCircadian + breakHabit + nutrition 만.
expect(grouped.keys.toSet(),
{DisplayCategory.lightCircadian, DisplayCategory.breakHabit, DisplayCategory.nutrition});
expect(grouped.containsKey(DisplayCategory.emotionRelationship), false);
});
}

View File

@@ -0,0 +1,155 @@
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/core/constants.dart';
import 'package:life_helper/core/time.dart';
import 'package:life_helper/data/db/app_database.dart';
/// v1 schema 로 in-memory DB 를 raw SQL 로 부트스트랩.
///
/// Phase 1 (#204) 시점의 protocols 테이블 정의를 모사 — 그 시점 CHECK 는 6 값.
Future<AppDatabase> _buildV1Database() async {
// schemaVersion=1 흉내. v2 schema 로 createAll 후, protocols 만 v1 CHECK 로 재생성.
// 이렇게 하면 migrateV1ToV2 가 호출 가능 (생성된 DB 는 항상 v2 코드 베이스).
// 본 테스트의 핵심은 "DROP+CREATE+flag 클리어가 user 테이블에 영향 주지 않음" + "v2 CHECK 적용 확인".
final db = AppDatabase.memory();
// v1 의 protocols 테이블을 시뮬레이트: v2 테이블을 DROP 하고 v1 정의로 다시 만든다.
await db.customStatement('DROP TABLE IF EXISTS protocols');
await db.customStatement('DROP INDEX IF EXISTS IDX_protocols_category');
await db.customStatement('''
CREATE TABLE protocols (
id TEXT NOT NULL PRIMARY KEY,
category TEXT NOT NULL CHECK (category IN ('health','meditation','motivation','habit','learning','diet')),
title TEXT NOT NULL,
title_en TEXT,
what TEXT NOT NULL,
when_text TEXT NOT NULL,
dose TEXT NOT NULL,
why TEXT NOT NULL,
how_json TEXT NOT NULL,
check_text TEXT NOT NULL,
caution TEXT,
default_anchor_json TEXT,
min_dose_for_start TEXT,
reference_ids_json TEXT,
evidence_strength TEXT CHECK (evidence_strength IS NULL OR evidence_strength IN ('strong_rct','meta_analysis','observational','mechanistic','expert_opinion')),
source_doc TEXT CHECK (source_doc IS NULL OR source_doc IN ('huberman-protocols.md','diet-protocols.md'))
)
''');
await db.customStatement(
'CREATE INDEX IDX_protocols_category ON protocols(category)');
return db;
}
void main() {
group('migrateV1ToV2', () {
test('v1 → v2: CHECK 갱신 + 인덱스 재생성 + flag 클리어', () async {
final db = await _buildV1Database();
addTearDown(db.close);
// v1 row 1개 insert (raw SQL — v1 CHECK 통과).
await db.customStatement('''
INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text)
VALUES ('legacy', 'health', '레거시', '', '언제', '도즈', '', '[]', '체크')
''');
// 시드 flag pre-set.
await db.into(db.metaKv).insert(
MetaKvCompanion.insert(key: kSeededV1Flag, value: 'true'));
// Migrate.
await db.transaction(() async {
final m = Migrator(db);
await migrateV1ToV2(m, db);
});
// 1. Protocols 테이블 비어있음 (DROP 으로 row 손실 — reseed 가 채울 책임).
final rows = await db.select(db.protocols).get();
expect(rows, isEmpty);
// 2. v1 카테고리 'health' insert 는 이제 CHECK 위배.
Future<void> insertHealth() async {
await db.customStatement('''
INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text)
VALUES ('new', 'health', 't', 'w', 'wn', 'd', 'w', '[]', 'c')
''');
}
await expectLater(insertHealth(), throwsA(isA<Object>()));
// 3. v2 카테고리 'light_circadian' insert 는 통과.
await db.customStatement('''
INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text)
VALUES ('new', 'light_circadian', 't', 'w', 'wn', 'd', 'w', '[]', 'c')
''');
final after = await db.select(db.protocols).get();
expect(after.length, 1);
// 4. 시드 flag 클리어 — 다음 부팅이 reseed 트리거.
final marker = await (db.select(db.metaKv)
..where((t) => t.key.equals(kSeededV1Flag)))
.getSingleOrNull();
expect(marker, isNull);
// 5. 인덱스 재생성 확인 — sqlite_master 조회.
final indexCheck = await db.customSelect(
"SELECT name FROM sqlite_master WHERE type='index' AND name='IDX_protocols_category'",
).get();
expect(indexCheck.length, 1);
});
test('user 테이블 (Users / Phases / Habits) 무변화', () async {
final db = await _buildV1Database();
addTearDown(db.close);
// user 데이터 사전 insert.
await db.into(db.users).insert(UsersCompanion.insert(
id: 'u1',
displayName: const Value('Alice'),
createdAt: nowKst().toIso8601String()));
await db.into(db.phases).insert(PhasesCompanion.insert(
id: 'ph1',
userId: 'u1',
status: 'active',
startedAt: nowKst().toIso8601String()));
await db.into(db.habits).insert(HabitsCompanion.insert(
id: 'h1',
userId: 'u1',
type: 'build',
status: 'active',
title: 'My Habit',
protocolId: const Value('legacy'),
frameLevel: 'L2',
frameFramedText: '저녁엔 무알콜',
startedAt: nowKst().toIso8601String()));
// Migrate.
await db.transaction(() async {
final m = Migrator(db);
await migrateV1ToV2(m, db);
});
// user 테이블 무변화.
final users = await db.select(db.users).get();
final phases = await db.select(db.phases).get();
final habits = await db.select(db.habits).get();
expect(users.length, 1);
expect(phases.length, 1);
expect(habits.length, 1);
expect(users.first.id, 'u1');
expect(habits.first.title, 'My Habit');
});
test('idempotent: 두 번째 호출도 성공 (no row 차이)', () async {
final db = await _buildV1Database();
addTearDown(db.close);
await db.transaction(() async {
final m = Migrator(db);
await migrateV1ToV2(m, db);
await migrateV1ToV2(m, db);
});
final rows = await db.select(db.protocols).get();
expect(rows, isEmpty);
});
});
}

View File

@@ -7,7 +7,7 @@ const _protocols = '''
[
{
"id": "morning_sunlight",
"category": "health",
"category": "light_circadian",
"title": "아침 햇빛",
"what": "기상 후 햇빛.",
"when": "기상 후 30~60분.",

View File

@@ -0,0 +1,126 @@
// Stub seed loader used by both seed_importer_test and catalog_repository_test.
// 1 row per catalog (minimal but schema-valid).
const protocolsStub = '''
[
{
"id": "morning_sunlight",
"category": "light_circadian",
"title": "아침 햇빛",
"what": "기상 후 햇빛.",
"when": "기상 후 30~60분.",
"dose": "5~10분.",
"why": "ipRGC 자극.",
"how": ["나간다", "쳐다본다"],
"check": "60분 이내 외출",
"reference_ids": ["ref_x"],
"source_doc": "huberman-protocols.md"
}
]
''';
const breakProtocolsStub = '''
[
{
"id": "alcohol",
"category": "alcohol",
"title": "음주",
"huberman_summary": "ep 86",
"phases": [{"week": 1, "what": "환경 정리"}],
"default_common_frames": ["dopamine_reset"]
}
]
''';
const commonFramesStub = '''
[
{
"id": "dopamine_reset",
"title": "도파민 리셋",
"what": "30일 절제",
"why": "수용체 회복",
"check": "30일 무자극"
}
]
''';
const methodologiesStub = '''
[
{
"id": "atomic_habits",
"name": "Atomic Habits",
"originator": "James Clear",
"one_line_definition": "1% 개선",
"core_unit": "1회 행동",
"huberman_fit_score": 5,
"is_core_engine": true
}
]
''';
const framePatternsStub = '''
[
{
"id": "fp_alcohol",
"domain": "drink",
"avoidance_keyword": "술 끊기",
"l0_example": "술 끊기",
"l2_suggestion": "저녁엔 무알콜",
"l3_identity": "맑은 정신 추구"
}
]
''';
const rewardMenuItemsStub = '''
[
{
"id": "rmi_walk",
"tier_recommended": "T1",
"title": "산책 30분"
}
]
''';
const referencesStub = '''
[
{
"id": "ref_x",
"kind": "url",
"title": "Sample",
"url": "https://example.com"
}
]
''';
const dietPatternsStub = '''
[
{
"id": "med",
"name": "지중해 식단",
"core": "올리브유 + 채소 + 생선 위주의 전통 식단.",
"evidence_strength": "strong"
}
]
''';
Future<String> testStubLoader(String path) async {
switch (path) {
case 'assets/seed/protocols.json':
return protocolsStub;
case 'assets/seed/break_protocols.json':
return breakProtocolsStub;
case 'assets/seed/common_frames.json':
return commonFramesStub;
case 'assets/seed/methodologies.json':
return methodologiesStub;
case 'assets/seed/frame_patterns.json':
return framePatternsStub;
case 'assets/seed/reward_menu_items.json':
return rewardMenuItemsStub;
case 'assets/seed/references.json':
return referencesStub;
case 'assets/seed/diet_patterns.json':
return dietPatternsStub;
}
throw StateError('unexpected asset: $path');
}

View File

@@ -0,0 +1,59 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/domain/catalog/display_category.dart';
void main() {
test('fromProtocolCategory maps all 7 protocol categories', () {
expect(DisplayCategory.fromProtocolCategory('light_circadian'),
DisplayCategory.lightCircadian);
expect(DisplayCategory.fromProtocolCategory('sleep'),
DisplayCategory.sleep);
expect(DisplayCategory.fromProtocolCategory('movement'),
DisplayCategory.movement);
expect(DisplayCategory.fromProtocolCategory('nutrition'),
DisplayCategory.nutrition);
expect(DisplayCategory.fromProtocolCategory('focus_cognition'),
DisplayCategory.focusCognition);
expect(DisplayCategory.fromProtocolCategory('recovery_stress'),
DisplayCategory.recoveryStress);
expect(DisplayCategory.fromProtocolCategory('emotion_relationship'),
DisplayCategory.emotionRelationship);
});
test('fromProtocolCategory returns null for unknown / break_habit', () {
expect(DisplayCategory.fromProtocolCategory('break_habit'), isNull);
expect(DisplayCategory.fromProtocolCategory('health'), isNull);
expect(DisplayCategory.fromProtocolCategory(''), isNull);
});
test('breakHabit enum has null protocolKey (별도 source)', () {
expect(DisplayCategory.breakHabit.protocolKey, isNull);
});
test('all 8 enum values have label + icon', () {
for (final c in DisplayCategory.values) {
expect(c.label.isNotEmpty, true, reason: '${c.name} label');
}
expect(DisplayCategory.values.length, 8);
});
test('real seed: 모든 protocols.json id 가 정확히 1 DisplayCategory 에 매핑', () {
// Load from disk (test runs from app/).
final file = File('assets/seed/protocols.json');
final rows = json.decode(file.readAsStringSync()) as List;
expect(rows.length, 34, reason: 'protocols.json count');
final seenCategories = <DisplayCategory>{};
for (final r in rows.cast<Map<String, dynamic>>()) {
final cat = r['category'] as String;
final dc = DisplayCategory.fromProtocolCategory(cat);
expect(dc, isNotNull,
reason: 'id=${r['id']} category=$cat not mapped');
seenCategories.add(dc!);
}
// 매핑 누락은 없어야 함, 단 emotion_relationship 은 0 매핑 OK.
expect(seenCategories.contains(DisplayCategory.breakHabit), false,
reason: 'protocols 는 breakHabit 와 직접 매핑 X');
});
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/domain/catalog/catalog_item.dart';
import 'package:life_helper/domain/catalog/display_category.dart';
import 'package:life_helper/state/catalog_providers.dart';
import 'package:life_helper/ui/screens/protocol_gallery_screen.dart';
ProtocolCatalogItem _protocol(
{required String id,
required String title,
required DisplayCategory dc}) =>
ProtocolCatalogItem(
id: id,
title: title,
titleEn: null,
summary: '$title 요약',
displayCategory: dc,
evidenceStrength: 'strong_rct',
referenceIds: const [],
what: 'w',
whenText: 'wn',
dose: 'd',
why: 'y',
how: const [],
checkText: 'c',
caution: null,
defaultAnchor: null,
minDoseForStart: null,
sourceDoc: null,
);
void main() {
final fakeItems = [
_protocol(id: 'a1', title: '아침 햇빛', dc: DisplayCategory.lightCircadian),
_protocol(id: 's1', title: '수면 스택', dc: DisplayCategory.sleep),
_protocol(id: 's2', title: '카페인', dc: DisplayCategory.sleep),
];
Widget buildHarness(List<CatalogItem> items) {
return ProviderScope(
overrides: [
catalogItemsProvider.overrideWith((ref) async => items),
],
child: const MaterialApp(home: ProtocolGalleryScreen()),
);
}
testWidgets('카테고리 칩 + 카드 표시', (tester) async {
await tester.pumpWidget(buildHarness(fakeItems));
await tester.pump(); // resolve future
// 칩: 전체 + lightCircadian + sleep (2 카테고리만 — 빈 카테고리 미표시)
expect(find.text('전체'), findsOneWidget);
expect(find.text('빛/일주기'), findsOneWidget);
expect(find.text('수면'), findsOneWidget);
expect(find.text('영양'), findsNothing); // 빈 카테고리는 안 보임
// 카드 3개
expect(find.text('아침 햇빛'), findsOneWidget);
expect(find.text('수면 스택'), findsOneWidget);
expect(find.text('카페인'), findsOneWidget);
});
testWidgets('칩 선택 시 필터링', (tester) async {
await tester.pumpWidget(buildHarness(fakeItems));
await tester.pump();
// 수면 칩 선택
await tester.tap(find.text('수면'));
await tester.pump();
expect(find.text('아침 햇빛'), findsNothing);
expect(find.text('수면 스택'), findsOneWidget);
expect(find.text('카페인'), findsOneWidget);
});
testWidgets('빈 결과 → "항목이 없습니다"', (tester) async {
await tester.pumpWidget(buildHarness(const []));
await tester.pump();
expect(find.text('항목이 없습니다.'), findsOneWidget);
});
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/domain/catalog/catalog_item.dart';
import 'package:life_helper/domain/catalog/display_category.dart';
import 'package:life_helper/state/catalog_providers.dart';
import 'package:life_helper/ui/screens/protocol_preview_screen.dart';
ProtocolCatalogItem _sampleProtocol({List<String> refIds = const []}) =>
ProtocolCatalogItem(
id: 'morning_sunlight',
title: '아침 햇빛',
titleEn: 'Morning Sunlight',
summary: '기상 후 햇빛',
displayCategory: DisplayCategory.lightCircadian,
evidenceStrength: 'strong_rct',
referenceIds: refIds,
what: '기상 후 햇빛 노출.',
whenText: '기상 후 30~60분.',
dose: '5~10분.',
why: 'ipRGC 자극으로 일주기 리셋.',
how: const ['밖으로 나간다', '하늘을 쳐다본다'],
checkText: '60분 이내 외출',
caution: '직사 응시 금지',
defaultAnchor: const {'when': '기상 후', 'after_what': '세수'},
minDoseForStart: '2분',
sourceDoc: 'huberman-protocols.md',
);
void main() {
Widget buildHarness(CatalogItem item, {List<dynamic> refs = const []}) {
return ProviderScope(
overrides: [
referencesByIdsProvider.overrideWith((ref, ids) async => []),
],
child: MaterialApp(home: ProtocolPreviewScreen(item: item)),
);
}
testWidgets('Protocol: 모든 핵심 필드 표시', (tester) async {
final item = _sampleProtocol();
await tester.pumpWidget(buildHarness(item));
await tester.pump();
// Header + 상단 가시 필드.
expect(find.text('아침 햇빛'), findsAtLeastNWidgets(1));
expect(find.text('Morning Sunlight'), findsOneWidget);
expect(find.text('빛/일주기'), findsOneWidget);
// ListView lazy-builds — 하위 필드는 명시적 스크롤로 가져온다.
final scrollable = find.byType(Scrollable).first;
for (final t in [
'ipRGC 자극으로 일주기 리셋.',
'1. 밖으로 나간다',
'2. 하늘을 쳐다본다',
'60분 이내 외출',
'직사 응시 금지',
'2분',
'huberman-protocols.md',
]) {
await tester.scrollUntilVisible(find.text(t), 100,
scrollable: scrollable);
expect(find.text(t), findsOneWidget, reason: 'missing: $t');
}
});
testWidgets('"내 습관으로" 버튼 disabled + tooltip', (tester) async {
await tester.pumpWidget(buildHarness(_sampleProtocol()));
await tester.pump();
final button = tester.widget<FilledButton>(find.byType(FilledButton));
expect(button.onPressed, isNull);
expect(find.byType(Tooltip), findsAtLeastNWidgets(1));
});
testWidgets('reference 없으면 References 섹션 숨김', (tester) async {
await tester.pumpWidget(buildHarness(_sampleProtocol(refIds: const [])));
await tester.pump();
expect(find.textContaining('참고 ('), findsNothing);
});
}