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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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