[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

@@ -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),
),
);
}
}