- 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
208 lines
7.0 KiB
Dart
208 lines
7.0 KiB
Dart
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('내 습관으로 (다음 업데이트 예정)'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|