From 321d3af53be30af94c91351f95db4e39ebfa687e Mon Sep 17 00:00:00 2001 From: joungmin Date: Fri, 12 Jun 2026 17:20:13 +0900 Subject: [PATCH] =?UTF-8?q?[03-Developer]=20#226=20Catalog=20Gallery=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/assets/seed/protocols.json | 229 +++++++++++++----- app/lib/data/catalog/catalog_repository.dart | 133 ++++++++++ app/lib/data/db/app_database.dart | 30 ++- app/lib/data/db/app_database.g.dart | 115 ++++----- app/lib/data/db/daos/habit_dao.g.dart | 17 ++ app/lib/data/db/daos/meta_dao.g.dart | 8 + app/lib/data/db/daos/tracker_dao.g.dart | 22 ++ app/lib/data/db/tables/catalog_tables.dart | 3 +- app/lib/domain/catalog/catalog_item.dart | 166 +++++++++++++ app/lib/domain/catalog/display_category.dart | 35 +++ app/lib/state/catalog_providers.dart | 37 +++ app/lib/ui/screens/habit_list_screen.dart | 33 ++- .../ui/screens/protocol_gallery_screen.dart | 79 ++++++ .../ui/screens/protocol_preview_screen.dart | 207 ++++++++++++++++ app/lib/ui/widgets/catalog_card.dart | 105 ++++++++ app/lib/ui/widgets/category_chip_row.dart | 50 ++++ app/lib/ui/widgets/reference_expand_card.dart | 71 ++++++ .../data/catalog/catalog_repository_test.dart | 114 +++++++++ app/test/data/db/migration_v1_to_v2_test.dart | 155 ++++++++++++ app/test/data/seed/seed_importer_test.dart | 2 +- app/test/data/seed/test_seeds.dart | 126 ++++++++++ .../domain/catalog/display_category_test.dart | 59 +++++ app/test/ui/protocol_gallery_screen_test.dart | 83 +++++++ app/test/ui/protocol_preview_screen_test.dart | 81 +++++++ 24 files changed, 1814 insertions(+), 146 deletions(-) create mode 100644 app/lib/data/catalog/catalog_repository.dart create mode 100644 app/lib/domain/catalog/catalog_item.dart create mode 100644 app/lib/domain/catalog/display_category.dart create mode 100644 app/lib/state/catalog_providers.dart create mode 100644 app/lib/ui/screens/protocol_gallery_screen.dart create mode 100644 app/lib/ui/screens/protocol_preview_screen.dart create mode 100644 app/lib/ui/widgets/catalog_card.dart create mode 100644 app/lib/ui/widgets/category_chip_row.dart create mode 100644 app/lib/ui/widgets/reference_expand_card.dart create mode 100644 app/test/data/catalog/catalog_repository_test.dart create mode 100644 app/test/data/db/migration_v1_to_v2_test.dart create mode 100644 app/test/data/seed/test_seeds.dart create mode 100644 app/test/domain/catalog/display_category_test.dart create mode 100644 app/test/ui/protocol_gallery_screen_test.dart create mode 100644 app/test/ui/protocol_preview_screen_test.dart diff --git a/app/assets/seed/protocols.json b/app/assets/seed/protocols.json index 98531ec..a8714aa 100644 --- a/app/assets/seed/protocols.json +++ b/app/assets/seed/protocols.json @@ -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" } diff --git a/app/lib/data/catalog/catalog_repository.dart b/app/lib/data/catalog/catalog_repository.dart new file mode 100644 index 0000000..ddca016 --- /dev/null +++ b/app/lib/data/catalog/catalog_repository.dart @@ -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`. +/// +/// 본 이슈 (#226) 의 핵심 변환 한 점. 본 함수는 fn-catalog_repository.md 의 알고리즘대로. +class CatalogRepository { + CatalogRepository(this._db); + + final AppDatabase _db; + + /// 47 항목 (protocols 34 + break 8 + diet 5) 을 displayCategory 기준 정렬해 반환. + Future> 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 = []; + + 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 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> referencesByIds(List 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 _decodeIds(String? jsonStr) { + if (jsonStr == null) return const []; + final decoded = jsonDecode(jsonStr); + return decoded is List ? decoded.cast() : const []; +} + +List _decodeList(String? jsonStr) { + if (jsonStr == null) return const []; + final decoded = jsonDecode(jsonStr); + return decoded is List ? decoded.map((e) => e.toString()).toList() : const []; +} + +Map? _decodeAnchor(String? jsonStr) { + if (jsonStr == null) return null; + final decoded = jsonDecode(jsonStr); + return decoded is Map ? decoded : null; +} diff --git a/app/lib/data/db/app_database.dart b/app/lib/data/db/app_database.dart index 4c8aeeb..34383d1 100644 --- a/app/lib/data/db/app_database.dart +++ b/app/lib/data/db/app_database.dart @@ -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 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 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(); +} diff --git a/app/lib/data/db/app_database.g.dart b/app/lib/data/db/app_database.g.dart index 8610c84..58bb2b7 100644 --- a/app/lib/data/db/app_database.g.dart +++ b/app/lib/data/db/app_database.g.dart @@ -27,7 +27,8 @@ class $ProtocolsTable extends Protocols aliasedName, false, check: () => const CustomExpression( - "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> _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('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> _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('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('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> _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> _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> _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('habit_id')!; @@ -17930,10 +17920,7 @@ final class $$HabitDoseVariantsTableReferences static MultiTypedResultKey<$TrackerEntriesTable, List> _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('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('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('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('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('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('habit_id')!; @@ -20113,10 +20091,7 @@ final class $$RewardDeclarationsTableReferences static MultiTypedResultKey<$RewardClaimsTable, List> _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('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('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('phase_id'); diff --git a/app/lib/data/db/daos/habit_dao.g.dart b/app/lib/data/db/daos/habit_dao.g.dart index c1b1f6e..be7d7f1 100644 --- a/app/lib/data/db/daos/habit_dao.g.dart +++ b/app/lib/data/db/daos/habit_dao.g.dart @@ -9,4 +9,21 @@ mixin _$HabitDaoMixin on DatabaseAccessor { $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, + ); } diff --git a/app/lib/data/db/daos/meta_dao.g.dart b/app/lib/data/db/daos/meta_dao.g.dart index b505747..8cd90e3 100644 --- a/app/lib/data/db/daos/meta_dao.g.dart +++ b/app/lib/data/db/daos/meta_dao.g.dart @@ -5,4 +5,12 @@ part of 'meta_dao.dart'; // ignore_for_file: type=lint mixin _$MetaDaoMixin on DatabaseAccessor { $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); } diff --git a/app/lib/data/db/daos/tracker_dao.g.dart b/app/lib/data/db/daos/tracker_dao.g.dart index 1128c7b..b69f8e6 100644 --- a/app/lib/data/db/daos/tracker_dao.g.dart +++ b/app/lib/data/db/daos/tracker_dao.g.dart @@ -10,4 +10,26 @@ mixin _$TrackerDaoMixin on DatabaseAccessor { $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, + ); } diff --git a/app/lib/data/db/tables/catalog_tables.dart b/app/lib/data/db/tables/catalog_tables.dart index 012a2b4..d5a990c 100644 --- a/app/lib/data/db/tables/catalog_tables.dart +++ b/app/lib/data/db/tables/catalog_tables.dart @@ -6,7 +6,8 @@ import 'package:drift/drift.dart'; class Protocols extends Table { TextColumn get id => text()(); TextColumn get category => text().check(const CustomExpression( - "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()(); diff --git a/app/lib/domain/catalog/catalog_item.dart b/app/lib/domain/catalog/catalog_item.dart new file mode 100644 index 0000000..de19cbc --- /dev/null +++ b/app/lib/domain/catalog/catalog_item.dart @@ -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 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 referenceIds; + + final String what; + final String whenText; + final String dose; + final String why; + final List how; + final String checkText; + final String? caution; + final Map? 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 referenceIds; + + /// 원본 break 카테고리 (alcohol / nicotine / ...). 카드 sub-tag 로 활용. + final String breakCategory; + final String hubermanSummary; + final List phases; + final List defaultCommonFrames; + final List 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 referenceIds; + + final String name; + final String core; + final List strengths; + final List weaknesses; + final String? koreanContextFit; + final List starterLevers; + final String? medicalWarning; + final List linkedProtocolIds; +} + +/// 그룹핑 헬퍼. 빈 카테고리 키는 결과 map 에 미포함. +Map> groupByCategory( + List items) { + final result = >{}; + for (final item in items) { + result.putIfAbsent(item.displayCategory, () => []).add(item); + } + return result; +} diff --git a/app/lib/domain/catalog/display_category.dart b/app/lib/domain/catalog/display_category.dart new file mode 100644 index 0000000..412a97b --- /dev/null +++ b/app/lib/domain/catalog/display_category.dart @@ -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; + } +} diff --git a/app/lib/state/catalog_providers.dart b/app/lib/state/catalog_providers.dart new file mode 100644 index 0000000..cf5fb0f --- /dev/null +++ b/app/lib/state/catalog_providers.dart @@ -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((ref) { + return CatalogRepository(ref.watch(appDatabaseProvider)); +}); + +/// 갤러리 진입 시 1회 로드. seed 가 끝난 가정 (bootstrap 이 보장). +final catalogItemsProvider = FutureProvider>((ref) async { + // bootstrap 가 끝난 후에만 의미 있음. + await ref.watch(bootstrapProvider.future); + return ref.watch(catalogRepositoryProvider).all(); +}); + +/// 빈 카테고리 키는 결과에 미포함 — 갤러리는 결과 key 만 칩으로 렌더. +final groupedByCategoryProvider = + FutureProvider>>((ref) async { + final items = await ref.watch(catalogItemsProvider.future); + return groupByCategory(items); +}); + +/// Preview 화면용. id → 단건. 미매칭 시 null. +final catalogItemByIdProvider = + FutureProvider.family((ref, id) async { + return ref.watch(catalogRepositoryProvider).byId(id); +}); + +/// reference id 리스트 → ReferenceRow 들. ids 비면 빈 리스트. +final referencesByIdsProvider = + FutureProvider.family, List>((ref, ids) async { + return ref.watch(catalogRepositoryProvider).referencesByIds(ids); +}); diff --git a/app/lib/ui/screens/habit_list_screen.dart b/app/lib/ui/screens/habit_list_screen.dart index ce2514b..278c3b1 100644 --- a/app/lib/ui/screens/habit_list_screen.dart +++ b/app/lib/ui/screens/habit_list_screen.dart @@ -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(), + )); + } } diff --git a/app/lib/ui/screens/protocol_gallery_screen.dart b/app/lib/ui/screens/protocol_gallery_screen.dart new file mode 100644 index 0000000..439df65 --- /dev/null +++ b/app/lib/ui/screens/protocol_gallery_screen.dart @@ -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 createState() => + _ProtocolGalleryScreenState(); +} + +class _ProtocolGalleryScreenState extends ConsumerState { + 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 []); + // 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]), + ), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/app/lib/ui/screens/protocol_preview_screen.dart b/app/lib/ui/screens/protocol_preview_screen.dart new file mode 100644 index 0000000..ebd741b --- /dev/null +++ b/app/lib/ui/screens/protocol_preview_screen.dart @@ -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 _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 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 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 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('내 습관으로 (다음 업데이트 예정)'), + ), + ), + ), + ); + } +} diff --git a/app/lib/ui/widgets/catalog_card.dart b/app/lib/ui/widgets/catalog_card.dart new file mode 100644 index 0000000..c30b3a7 --- /dev/null +++ b/app/lib/ui/widgets/catalog_card.dart @@ -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), + ), + ); + } +} diff --git a/app/lib/ui/widgets/category_chip_row.dart b/app/lib/ui/widgets/category_chip_row.dart new file mode 100644 index 0000000..2a194a4 --- /dev/null +++ b/app/lib/ui/widgets/category_chip_row.dart @@ -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 categories; + final DisplayCategory? selected; + final ValueChanged 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), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/ui/widgets/reference_expand_card.dart b/app/lib/ui/widgets/reference_expand_card.dart new file mode 100644 index 0000000..1940648 --- /dev/null +++ b/app/lib/ui/widgets/reference_expand_card.dart @@ -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), + ); + } +} diff --git a/app/test/data/catalog/catalog_repository_test.dart b/app/test/data/catalog/catalog_repository_test.dart new file mode 100644 index 0000000..2621c90 --- /dev/null +++ b/app/test/data/catalog/catalog_repository_test.dart @@ -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 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().single; + expect(p.displayCategory, DisplayCategory.lightCircadian); + + final b = items.whereType().single; + expect(b.displayCategory, DisplayCategory.breakHabit); + + final d = items.whereType().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); + }); +} diff --git a/app/test/data/db/migration_v1_to_v2_test.dart b/app/test/data/db/migration_v1_to_v2_test.dart new file mode 100644 index 0000000..9ba59a3 --- /dev/null +++ b/app/test/data/db/migration_v1_to_v2_test.dart @@ -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 _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 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())); + + // 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); + }); + }); +} diff --git a/app/test/data/seed/seed_importer_test.dart b/app/test/data/seed/seed_importer_test.dart index 538f753..8cc0a10 100644 --- a/app/test/data/seed/seed_importer_test.dart +++ b/app/test/data/seed/seed_importer_test.dart @@ -7,7 +7,7 @@ const _protocols = ''' [ { "id": "morning_sunlight", - "category": "health", + "category": "light_circadian", "title": "아침 햇빛", "what": "기상 후 햇빛.", "when": "기상 후 30~60분.", diff --git a/app/test/data/seed/test_seeds.dart b/app/test/data/seed/test_seeds.dart new file mode 100644 index 0000000..10e8349 --- /dev/null +++ b/app/test/data/seed/test_seeds.dart @@ -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 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'); +} diff --git a/app/test/domain/catalog/display_category_test.dart b/app/test/domain/catalog/display_category_test.dart new file mode 100644 index 0000000..47e5a94 --- /dev/null +++ b/app/test/domain/catalog/display_category_test.dart @@ -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 = {}; + for (final r in rows.cast>()) { + 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'); + }); +} diff --git a/app/test/ui/protocol_gallery_screen_test.dart b/app/test/ui/protocol_gallery_screen_test.dart new file mode 100644 index 0000000..bf751b1 --- /dev/null +++ b/app/test/ui/protocol_gallery_screen_test.dart @@ -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 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); + }); +} diff --git a/app/test/ui/protocol_preview_screen_test.dart b/app/test/ui/protocol_preview_screen_test.dart new file mode 100644 index 0000000..c9cdf72 --- /dev/null +++ b/app/test/ui/protocol_preview_screen_test.dart @@ -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 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 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(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); + }); +}