- 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
169 lines
5.9 KiB
Dart
169 lines
5.9 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:drift/drift.dart';
|
|
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';
|
|
|
|
part 'app_database.g.dart';
|
|
|
|
@DriftDatabase(tables: [
|
|
// Catalog 8
|
|
Protocols,
|
|
BreakProtocols,
|
|
CommonFrames,
|
|
Methodologies,
|
|
FramePatterns,
|
|
RewardMenuItems,
|
|
References,
|
|
DietPatterns,
|
|
// User 11 + 정규화 부속 1
|
|
Users,
|
|
Phases,
|
|
Habits,
|
|
HabitDoseVariants,
|
|
IfThenRules,
|
|
TrackerEntries,
|
|
LapseLogs,
|
|
UrgeLogs,
|
|
RewardDeclarations,
|
|
RewardClaims,
|
|
Reflections,
|
|
// Meta
|
|
MetaKv,
|
|
])
|
|
class AppDatabase extends _$AppDatabase {
|
|
AppDatabase(super.e);
|
|
|
|
/// In-memory for tests.
|
|
AppDatabase.memory() : super(NativeDatabase.memory());
|
|
|
|
@override
|
|
int get schemaVersion => 2;
|
|
|
|
@override
|
|
MigrationStrategy get migration => MigrationStrategy(
|
|
onCreate: (m) async {
|
|
await m.createAll();
|
|
await _createIndexes(m);
|
|
},
|
|
onUpgrade: (m, from, to) async {
|
|
// 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)');
|
|
}
|
|
},
|
|
);
|
|
|
|
Future<void> _createIndexes(Migrator m) async {
|
|
// Catalog indexes
|
|
await m.createIndex(Index('IDX_protocols_category',
|
|
'CREATE INDEX IDX_protocols_category ON protocols(category)'));
|
|
await m.createIndex(Index(
|
|
'IDX_break_protocols_category',
|
|
'CREATE UNIQUE INDEX IDX_break_protocols_category '
|
|
'ON break_protocols(category)'));
|
|
await m.createIndex(Index(
|
|
'IDX_methodologies_core',
|
|
'CREATE INDEX IDX_methodologies_core ON methodologies(is_core_engine) '
|
|
'WHERE is_core_engine = 1'));
|
|
await m.createIndex(Index(
|
|
'IDX_frame_patterns_keyword',
|
|
'CREATE INDEX IDX_frame_patterns_keyword '
|
|
'ON frame_patterns(avoidance_keyword)'));
|
|
await m.createIndex(Index(
|
|
'IDX_reward_menu_tier',
|
|
'CREATE INDEX IDX_reward_menu_tier '
|
|
'ON reward_menu_items(tier_recommended)'));
|
|
await m.createIndex(Index('IDX_references_kind',
|
|
'CREATE INDEX IDX_references_kind ON "references"(kind)'));
|
|
await m.createIndex(Index(
|
|
'IDX_references_doi',
|
|
'CREATE INDEX IDX_references_doi ON "references"(doi) '
|
|
'WHERE doi IS NOT NULL'));
|
|
await m.createIndex(Index(
|
|
'IDX_diet_patterns_evidence',
|
|
'CREATE INDEX IDX_diet_patterns_evidence '
|
|
'ON diet_patterns(evidence_strength)'));
|
|
await m.createIndex(Index(
|
|
'IDX_diet_patterns_kfit',
|
|
'CREATE INDEX IDX_diet_patterns_kfit '
|
|
'ON diet_patterns(korean_context_fit) '
|
|
'WHERE korean_context_fit IS NOT NULL'));
|
|
|
|
// User indexes
|
|
await m.createIndex(Index(
|
|
'IDX_phases_user_status',
|
|
'CREATE INDEX IDX_phases_user_status '
|
|
'ON phases(user_id, status)'));
|
|
await m.createIndex(Index(
|
|
'IDX_habits_user_status_type',
|
|
'CREATE INDEX IDX_habits_user_status_type '
|
|
'ON habits(user_id, status, type)'));
|
|
await m.createIndex(Index('IDX_habits_phase',
|
|
'CREATE INDEX IDX_habits_phase ON habits(phase_id)'));
|
|
await m.createIndex(Index(
|
|
'IDX_habit_dose_variants_habit',
|
|
'CREATE INDEX IDX_habit_dose_variants_habit '
|
|
'ON habit_dose_variants(habit_id)'));
|
|
await m.createIndex(Index(
|
|
'IDX_habit_dose_variants_habit_min',
|
|
'CREATE INDEX IDX_habit_dose_variants_habit_min '
|
|
'ON habit_dose_variants(habit_id, is_minimum)'));
|
|
await m.createIndex(Index('IDX_if_then_habit',
|
|
'CREATE INDEX IDX_if_then_habit ON if_then_rules(habit_id)'));
|
|
await m.createIndex(Index(
|
|
'UQ_tracker_habit_date',
|
|
'CREATE UNIQUE INDEX UQ_tracker_habit_date '
|
|
'ON tracker_entries(habit_id, date)'));
|
|
await m.createIndex(Index(
|
|
'IDX_tracker_habit_date',
|
|
'CREATE INDEX IDX_tracker_habit_date '
|
|
'ON tracker_entries(habit_id, date)'));
|
|
await m.createIndex(Index('IDX_tracker_date',
|
|
'CREATE INDEX IDX_tracker_date ON tracker_entries(date)'));
|
|
await m.createIndex(Index(
|
|
'IDX_lapse_habit_date',
|
|
'CREATE INDEX IDX_lapse_habit_date '
|
|
'ON lapse_logs(habit_id, date)'));
|
|
await m.createIndex(Index(
|
|
'IDX_urge_habit_occurred',
|
|
'CREATE INDEX IDX_urge_habit_occurred '
|
|
'ON urge_logs(habit_id, occurred_at)'));
|
|
await m.createIndex(Index(
|
|
'IDX_reflections_user_scope',
|
|
'CREATE INDEX IDX_reflections_user_scope '
|
|
'ON reflections(user_id, scope)'));
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|