[Developer] #204 Phase 1 MVP — Flutter app skeleton complete

- Drift 21 tables (8 catalog + 11 user + habit_dose_variants + meta_kv)
  with R1~R10 CHECK constraints and 19 indexes
- 8 hand-crafted seed JSON catalogs in app/assets/seed/
  (refs 84, protocols 34, methodologies 21, frame_patterns 30,
   reward_menu_items 30, break_protocols 8, common_frames 5, diet_patterns 5)
- 6 domain functions: recommend_variant, compute_streak,
  validate_frame_level, active_habit_quota, weekly_minimum_ratio,
  seed_importer (transactional, idempotent)
- 4 vertical-slice Riverpod screens: HabitList, HabitCreate, CheckIn, Streak
- 31 unit tests passing; flutter analyze clean
- OQ-5 streak semantics: missing entry ≠ explicit blank
  (missing = end of history; only TrackerValue.blank triggers Never-miss-twice)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:33:03 +09:00
parent 29befe4d97
commit 8fe6a8f378
76 changed files with 29059 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
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 '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 => 1;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async {
await m.createAll();
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');
},
);
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'));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import '../../../core/id.dart';
import '../../../domain/models/habit.dart';
import '../../../domain/rules/xor_protocol.dart';
import '../app_database.dart';
import '../tables/user_tables.dart';
part 'habit_dao.g.dart';
class HabitDraft {
final String userId;
final HabitType type;
final String title;
final String? protocolId;
final String? breakProtocolId;
final FrameLevel frameLevel;
final String frameFramedText;
final String? frameOriginalText;
final String? anchorWhen;
final String? anchorAfterWhat;
final String? anchorWhere;
final String startedAt;
final String? phaseId;
final List<VariantDraft> variants;
const HabitDraft({
required this.userId,
required this.type,
required this.title,
required this.frameLevel,
required this.frameFramedText,
required this.startedAt,
this.protocolId,
this.breakProtocolId,
this.frameOriginalText,
this.anchorWhen,
this.anchorAfterWhat,
this.anchorWhere,
this.phaseId,
this.variants = const [],
});
}
class VariantDraft {
final String label;
final String doseText;
final List<String> contextTags;
final List<String> conditionTags;
final bool isMinimum;
final int sortOrder;
const VariantDraft({
required this.label,
required this.doseText,
this.contextTags = const [],
this.conditionTags = const [],
this.isMinimum = false,
this.sortOrder = 0,
});
}
@DriftAccessor(tables: [Habits, HabitDoseVariants])
class HabitDao extends DatabaseAccessor<AppDatabase> with _$HabitDaoMixin {
HabitDao(super.db);
/// Insert habit + variants atomically.
Future<String> insertWithVariants(HabitDraft draft) async {
assertXorProtocol(
type: draft.type,
protocolId: draft.protocolId,
breakProtocolId: draft.breakProtocolId,
);
final habitId = generateUlid('hb');
await transaction(() async {
await into(habits).insert(HabitsCompanion.insert(
id: habitId,
userId: draft.userId,
type: draft.type.dbValue,
status: 'active',
title: draft.title,
protocolId: Value(draft.protocolId),
breakProtocolId: Value(draft.breakProtocolId),
frameLevel: draft.frameLevel.dbValue,
frameFramedText: draft.frameFramedText,
frameOriginalText: Value(draft.frameOriginalText),
anchorWhen: Value(draft.anchorWhen),
anchorAfterWhat: Value(draft.anchorAfterWhat),
anchorWhere: Value(draft.anchorWhere),
startedAt: draft.startedAt,
phaseId: Value(draft.phaseId),
));
for (final v in draft.variants) {
await into(habitDoseVariants).insert(HabitDoseVariantsCompanion.insert(
variantId: generateUlid('dv'),
habitId: habitId,
label: v.label,
doseText: v.doseText,
contextTagsJson: Value(jsonEncode(v.contextTags)),
conditionTagsJson: Value(jsonEncode(v.conditionTags)),
isMinimum: Value(v.isMinimum),
sortOrder: Value(v.sortOrder),
));
}
});
return habitId;
}
Future<int> countActive({
required String userId,
required HabitType type,
String? excludeHabitId,
}) async {
final query = select(habits)
..where((t) => t.userId.equals(userId))
..where((t) => t.status.equals('active'))
..where((t) => t.type.equals(type.dbValue));
if (excludeHabitId != null) {
query.where((t) => t.id.isNotValue(excludeHabitId));
}
final rows = await query.get();
return rows.length;
}
Future<List<Habit>> activeHabitsForUser(String userId) {
return (select(habits)
..where((t) => t.userId.equals(userId))
..where((t) => t.status.equals('active')))
.get();
}
Future<List<HabitDoseVariant>> variantsForHabit(String habitId) {
return (select(habitDoseVariants)
..where((t) => t.habitId.equals(habitId))
..orderBy([(t) => OrderingTerm.asc(t.sortOrder)]))
.get();
}
Future<List<HabitDoseVariant>> variantsByIds(Set<String> ids) {
if (ids.isEmpty) return Future.value(const []);
return (select(habitDoseVariants)
..where((t) => t.variantId.isIn(ids)))
.get();
}
}

View File

@@ -0,0 +1,12 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'habit_dao.dart';
// ignore_for_file: type=lint
mixin _$HabitDaoMixin on DatabaseAccessor<AppDatabase> {
$UsersTable get users => attachedDatabase.users;
$PhasesTable get phases => attachedDatabase.phases;
$HabitsTable get habits => attachedDatabase.habits;
$HabitDoseVariantsTable get habitDoseVariants =>
attachedDatabase.habitDoseVariants;
}

View File

@@ -0,0 +1,23 @@
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/user_tables.dart';
part 'meta_dao.g.dart';
@DriftAccessor(tables: [MetaKv])
class MetaDao extends DatabaseAccessor<AppDatabase> with _$MetaDaoMixin {
MetaDao(super.db);
Future<String?> find(String key) async {
final row = await (select(metaKv)..where((t) => t.key.equals(key)))
.getSingleOrNull();
return row?.value;
}
Future<void> put(String key, String value) async {
await into(metaKv).insertOnConflictUpdate(
MetaKvCompanion.insert(key: key, value: value),
);
}
}

View File

@@ -0,0 +1,8 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'meta_dao.dart';
// ignore_for_file: type=lint
mixin _$MetaDaoMixin on DatabaseAccessor<AppDatabase> {
$MetaKvTable get metaKv => attachedDatabase.metaKv;
}

View File

@@ -0,0 +1,80 @@
import 'package:drift/drift.dart';
import '../../../core/id.dart';
import '../../../core/time.dart';
import '../app_database.dart';
import '../tables/user_tables.dart';
part 'tracker_dao.g.dart';
class TrackerEntryDraft {
final String habitId;
final String date; // YYYY-MM-DD
final String value; // done | blank
final String? variantId;
final String? ctxLocation;
final String? ctxCondition;
final String? note;
const TrackerEntryDraft({
required this.habitId,
required this.date,
required this.value,
this.variantId,
this.ctxLocation,
this.ctxCondition,
this.note,
});
}
@DriftAccessor(tables: [TrackerEntries])
class TrackerDao extends DatabaseAccessor<AppDatabase> with _$TrackerDaoMixin {
TrackerDao(super.db);
Future<String> recordCheckIn(TrackerEntryDraft draft) async {
final id = generateUlid('te');
await into(trackerEntries).insert(TrackerEntriesCompanion.insert(
id: id,
habitId: draft.habitId,
date: draft.date,
value: draft.value,
loggedAt: Value(nowKst().toIso8601String()),
variantId: Value(draft.variantId),
ctxLocation: Value(draft.ctxLocation),
ctxCondition: Value(draft.ctxCondition),
note: Value(draft.note),
));
return id;
}
Future<List<TrackerEntry>> entriesForHabit(String habitId) {
return (select(trackerEntries)
..where((t) => t.habitId.equals(habitId))
..orderBy([(t) => OrderingTerm.asc(t.date)]))
.get();
}
/// Done entries in [start, end) for one user.
/// [start]/[end] are YYYY-MM-DD strings.
Future<List<TrackerEntry>> findDoneInRangeForUser({
required String userId,
required String startDate,
required String endDate,
String? habitId,
}) async {
final habitIds = await (select(db.habits)
..where((t) => t.userId.equals(userId)))
.map((h) => h.id)
.get();
if (habitIds.isEmpty) return const [];
final query = select(trackerEntries)
..where((t) => t.habitId.isIn(habitIds))
..where((t) => t.value.equals('done'))
..where((t) =>
t.date.isBiggerOrEqualValue(startDate) & t.date.isSmallerThanValue(endDate));
if (habitId != null) {
query.where((t) => t.habitId.equals(habitId));
}
return query.get();
}
}

View File

@@ -0,0 +1,13 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tracker_dao.dart';
// ignore_for_file: type=lint
mixin _$TrackerDaoMixin on DatabaseAccessor<AppDatabase> {
$UsersTable get users => attachedDatabase.users;
$PhasesTable get phases => attachedDatabase.phases;
$HabitsTable get habits => attachedDatabase.habits;
$HabitDoseVariantsTable get habitDoseVariants =>
attachedDatabase.habitDoseVariants;
$TrackerEntriesTable get trackerEntries => attachedDatabase.trackerEntries;
}

View File

@@ -0,0 +1,163 @@
import 'package:drift/drift.dart';
// 8 catalog tables, read-only. Source: schema/*.schema.json.
// Nested objects + arrays stored as JSON TEXT for read-only simplicity.
class Protocols extends Table {
TextColumn get id => text()();
TextColumn get category => text().check(const CustomExpression<bool>(
"category IN ('health','meditation','motivation','habit','learning','diet')"))();
TextColumn get title => text()();
TextColumn get titleEn => text().nullable()();
TextColumn get what => text()();
TextColumn get whenText => text().named('when_text')();
TextColumn get dose => text()();
TextColumn get why => text()();
TextColumn get howJson => text().named('how_json')();
TextColumn get checkText => text().named('check_text')();
TextColumn get caution => text().nullable()();
TextColumn get defaultAnchorJson => text().named('default_anchor_json').nullable()();
TextColumn get minDoseForStart => text().named('min_dose_for_start').nullable()();
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
TextColumn get evidenceStrength => text().named('evidence_strength').nullable().check(
const CustomExpression<bool>(
"evidence_strength IS NULL OR evidence_strength IN ('strong_rct','meta_analysis','observational','mechanistic','expert_opinion')"))();
TextColumn get sourceDoc => text().named('source_doc').nullable().check(
const CustomExpression<bool>(
"source_doc IS NULL OR source_doc IN ('huberman-protocols.md','diet-protocols.md')"))();
@override
Set<Column> get primaryKey => {id};
}
class BreakProtocols extends Table {
TextColumn get id => text()();
TextColumn get category => text().check(const CustomExpression<bool>(
"category IN ('alcohol','nicotine','porn_masturbation','social_media','sugar','caffeine','cannabis','behavioral')"))();
TextColumn get title => text()();
TextColumn get hubermanSummary => text().named('huberman_summary')();
TextColumn get frameExamplesJson => text().named('frame_examples_json').nullable()();
TextColumn get phasesJson => text().named('phases_json')();
TextColumn get defaultCommonFramesJson => text().named('default_common_frames_json')();
TextColumn get toolsJson => text().named('tools_json').nullable()();
TextColumn get medicalWarning => text().named('medical_warning').nullable()();
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class CommonFrames extends Table {
TextColumn get id => text().check(const CustomExpression<bool>(
"id IN ('dopamine_reset','urge_surf','environment_design','relapse_recovery','recovery_stack')"))();
TextColumn get title => text()();
TextColumn get what => text()();
TextColumn get why => text()();
TextColumn get dose => text().nullable()();
TextColumn get howJson => text().named('how_json').nullable()();
TextColumn get checkText => text().named('check_text')();
TextColumn get applicableBreakCategoriesJson =>
text().named('applicable_break_categories_json').nullable()();
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class Methodologies extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get originator => text()();
TextColumn get oneLineDefinition => text().named('one_line_definition')();
TextColumn get coreUnit => text().named('core_unit')();
TextColumn get procedureJson => text().named('procedure_json').nullable()();
TextColumn get toolsJson => text().named('tools_json').nullable()();
TextColumn get strengthsJson => text().named('strengths_json').nullable()();
TextColumn get weaknessesJson => text().named('weaknesses_json').nullable()();
TextColumn get goodFor => text().named('good_for').nullable()();
IntColumn get hubermanFitScore => integer().named('huberman_fit_score').check(
const CustomExpression<bool>("huberman_fit_score BETWEEN 1 AND 5"))();
BoolColumn get isCoreEngine =>
boolean().named('is_core_engine').withDefault(const Constant(false))();
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class FramePatterns extends Table {
TextColumn get id => text()();
TextColumn get domain => text().nullable().check(const CustomExpression<bool>(
"domain IS NULL OR domain IN ('food','drink','smoking','screen','porn','sleep','exercise','general')"))();
TextColumn get avoidanceKeyword => text().named('avoidance_keyword')();
TextColumn get l0Example => text().named('l0_example')();
TextColumn get l1SimpleReplace => text().named('l1_simple_replace').nullable()();
TextColumn get l2Suggestion => text().named('l2_suggestion')();
TextColumn get l3Identity => text().named('l3_identity').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class RewardMenuItems extends Table {
TextColumn get id => text()();
TextColumn get tierRecommended => text().named('tier_recommended').check(
const CustomExpression<bool>("tier_recommended IN ('T0','T1','T2','T3','T4')"))();
TextColumn get title => text()();
TextColumn get description => text().nullable()();
IntColumn get estimatedCostKrwMin => integer().named('estimated_cost_krw_min').nullable()();
IntColumn get estimatedCostKrwMax => integer().named('estimated_cost_krw_max').nullable()();
BoolColumn get isEffortTied => boolean().named('is_effort_tied').nullable()();
TextColumn get tagsJson => text().named('tags_json').nullable()();
TextColumn get avoidForBreakHabitsJson =>
text().named('avoid_for_break_habits_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}
@DataClassName('ReferenceRow')
class References extends Table {
TextColumn get id => text()();
TextColumn get kind => text().check(const CustomExpression<bool>(
"kind IN ('paper','podcast_episode','book','url','korean_explainer')"))();
TextColumn get title => text()();
TextColumn get authorsJson => text().named('authors_json').nullable()();
IntColumn get year => integer().nullable().check(
const CustomExpression<bool>("year IS NULL OR (year BETWEEN 1900 AND 2100)"))();
TextColumn get journal => text().nullable()();
TextColumn get doi => text().nullable()();
TextColumn get url => text().nullable()();
IntColumn get episodeNumber => integer().named('episode_number').nullable()();
TextColumn get publisher => text().nullable()();
TextColumn get evidenceStrength => text().named('evidence_strength').nullable().check(
const CustomExpression<bool>(
"evidence_strength IS NULL OR evidence_strength IN ('strong_rct','meta_analysis','observational','mechanistic','expert_opinion')"))();
BoolColumn get verified => boolean().nullable()();
TextColumn get note => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
class DietPatterns extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get core => text()();
TextColumn get strengthsJson => text().named('strengths_json').nullable()();
TextColumn get weaknessesJson => text().named('weaknesses_json').nullable()();
TextColumn get evidenceStrength => text().named('evidence_strength').check(
const CustomExpression<bool>(
"evidence_strength IN ('strong','moderate','mixed','weak')"))();
TextColumn get koreanContextFit => text().named('korean_context_fit').nullable().check(
const CustomExpression<bool>(
"korean_context_fit IS NULL OR korean_context_fit IN ('high','medium','low')"))();
TextColumn get starterLeversJson => text().named('starter_levers_json').nullable()();
TextColumn get medicalWarning => text().named('medical_warning').nullable()();
TextColumn get linkedProtocolIdsJson =>
text().named('linked_protocol_ids_json').nullable()();
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}

View File

@@ -0,0 +1,265 @@
import 'package:drift/drift.dart';
// 11 user-data tables + habit_dose_variants (normalized child per ADR-0002).
class Users extends Table {
TextColumn get id => text()();
TextColumn get displayName => text().named('display_name').nullable()();
TextColumn get locale => text().withDefault(const Constant('ko-KR'))();
TextColumn get timezone => text().withDefault(const Constant('Asia/Seoul'))();
TextColumn get createdAt => text().named('created_at')();
TextColumn get preferencesJson => text().named('preferences_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class Phases extends Table {
TextColumn get id => text()();
TextColumn get userId =>
text().named('user_id').customConstraint('REFERENCES users(id) NOT NULL')();
TextColumn get title => text().nullable()();
TextColumn get startedAt => text().named('started_at')();
TextColumn get endedAt => text().named('ended_at').nullable()();
IntColumn get durationWeeks => integer()
.named('duration_weeks')
.withDefault(const Constant(6))
.check(const CustomExpression<bool>("duration_weeks >= 1"))();
TextColumn get status => text().check(const CustomExpression<bool>(
"status IN ('active','completed','abandoned')"))();
TextColumn get intentionText => text().named('intention_text').nullable()();
BoolColumn get rewardDeclarationsLocked => boolean()
.named('reward_declarations_locked')
.withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
class Habits extends Table {
TextColumn get id => text()();
TextColumn get userId =>
text().named('user_id').customConstraint('REFERENCES users(id) NOT NULL')();
TextColumn get phaseId => text()
.named('phase_id')
.nullable()
.customConstraint('NULL REFERENCES phases(id) ON DELETE SET NULL')();
TextColumn get type =>
text().check(const CustomExpression<bool>("type IN ('build','break')"))();
TextColumn get status => text().check(const CustomExpression<bool>(
"status IN ('active','paused','completed','abandoned')"))();
TextColumn get title => text()();
TextColumn get protocolId => text()
.named('protocol_id')
.nullable()
.customConstraint('NULL REFERENCES protocols(id)')();
TextColumn get breakProtocolId => text()
.named('break_protocol_id')
.nullable()
.customConstraint('NULL REFERENCES break_protocols(id)')();
TextColumn get commonFrameIdsJson =>
text().named('common_frame_ids_json').nullable()();
TextColumn get frameLevel => text().named('frame_level').check(
const CustomExpression<bool>("frame_level IN ('L2','L3')"))(); // R3
TextColumn get frameOriginalText => text().named('frame_original_text').nullable()();
TextColumn get frameFramedText => text().named('frame_framed_text')();
TextColumn get anchorWhen => text().named('anchor_when').nullable()();
TextColumn get anchorAfterWhat => text().named('anchor_after_what').nullable()();
TextColumn get anchorWhere => text().named('anchor_where').nullable()();
IntColumn get stackPosition => integer().named('stack_position').nullable().check(
const CustomExpression<bool>("stack_position IS NULL OR stack_position >= 1"))();
TextColumn get minDose => text().named('min_dose').nullable()();
TextColumn get targetDose => text().named('target_dose').nullable()();
TextColumn get startedAt => text().named('started_at')();
TextColumn get endedAt => text().named('ended_at').nullable()();
TextColumn get tagsJson => text().named('tags_json').nullable()();
@override
Set<Column> get primaryKey => {id};
@override
List<String> get customConstraints => [
// XOR: build → protocol_id, break → break_protocol_id
"CHECK ((type = 'build' AND protocol_id IS NOT NULL AND break_protocol_id IS NULL) "
"OR (type = 'break' AND break_protocol_id IS NOT NULL AND protocol_id IS NULL))",
];
}
class HabitDoseVariants extends Table {
TextColumn get variantId => text().named('variant_id')();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) ON DELETE CASCADE NOT NULL')();
TextColumn get label => text()();
TextColumn get doseText => text().named('dose_text')();
TextColumn get contextTagsJson => text().named('context_tags_json').nullable()();
TextColumn get conditionTagsJson => text().named('condition_tags_json').nullable()();
BoolColumn get isMinimum =>
boolean().named('is_minimum').withDefault(const Constant(false))();
IntColumn get sortOrder =>
integer().named('sort_order').withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {variantId};
}
class IfThenRules extends Table {
TextColumn get id => text()();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) ON DELETE CASCADE NOT NULL')();
TextColumn get ifCondition => text().named('if_condition')();
TextColumn get thenAction => text().named('then_action')();
TextColumn get triggerType => text().named('trigger_type').nullable().check(
const CustomExpression<bool>(
"trigger_type IS NULL OR trigger_type IN ('time','location','emotion','preceding_action','urge')"))();
IntColumn get priority => integer()
.withDefault(const Constant(1))
.check(const CustomExpression<bool>("priority BETWEEN 1 AND 3"))();
TextColumn get createdAt => text().named('created_at').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class TrackerEntries extends Table {
TextColumn get id => text()();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) ON DELETE RESTRICT NOT NULL')();
TextColumn get date => text()();
TextColumn get value =>
text().check(const CustomExpression<bool>("value IN ('done','blank')"))(); // R5
TextColumn get loggedAt => text().named('logged_at').nullable()();
TextColumn get note => text().nullable().check(
const CustomExpression<bool>("note IS NULL OR length(note) <= 200"))();
TextColumn get variantId => text()
.named('variant_id')
.nullable()
.customConstraint(
'NULL REFERENCES habit_dose_variants(variant_id) ON DELETE SET NULL')();
TextColumn get ctxLocation => text().named('ctx_location').nullable()();
TextColumn get ctxCondition => text().named('ctx_condition').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class LapseLogs extends Table {
TextColumn get id => text()();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) NOT NULL')();
TextColumn get date => text()();
TextColumn get labelText => text().named('label_text')();
TextColumn get examineHaltJson => text().named('examine_halt_json')();
TextColumn get antecedentJson => text().named('antecedent_json')();
TextColumn get replan => text()();
TextColumn get nextAction => text().named('next_action')();
TextColumn get createdAt => text().named('created_at').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class UrgeLogs extends Table {
TextColumn get id => text()();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) NOT NULL')();
TextColumn get occurredAt => text().named('occurred_at')();
IntColumn get intensityBefore => integer().named('intensity_before').nullable().check(
const CustomExpression<bool>(
"intensity_before IS NULL OR intensity_before BETWEEN 0 AND 10"))();
IntColumn get intensityAfter => integer().named('intensity_after').nullable().check(
const CustomExpression<bool>(
"intensity_after IS NULL OR intensity_after BETWEEN 0 AND 10"))();
IntColumn get durationSeconds => integer().named('duration_seconds').nullable().check(
const CustomExpression<bool>(
"duration_seconds IS NULL OR duration_seconds >= 0"))();
TextColumn get bodyLocationJson => text().named('body_location_json').nullable()();
BoolColumn get passed => boolean()();
TextColumn get methodUsed => text().named('method_used').nullable().check(
const CustomExpression<bool>(
"method_used IS NULL OR method_used IN ('cyclic_sighing','walk','water','social_contact','if_then_action','other')"))();
@override
Set<Column> get primaryKey => {id};
}
class RewardDeclarations extends Table {
TextColumn get id => text()();
TextColumn get phaseId => text()
.named('phase_id')
.customConstraint('REFERENCES phases(id) NOT NULL')();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) NOT NULL')();
TextColumn get tier => text().check(
const CustomExpression<bool>("tier IN ('T0','T1','T2','T3','T4')"))();
TextColumn get milestoneRule => text().named('milestone_rule')();
TextColumn get milestoneMachineRuleJson =>
text().named('milestone_machine_rule_json').nullable()();
TextColumn get rewardText => text().named('reward_text')();
TextColumn get rewardMenuItemId => text()
.named('reward_menu_item_id')
.nullable()
.customConstraint('NULL REFERENCES reward_menu_items(id)')();
IntColumn get estimatedCostKrw => integer().named('estimated_cost_krw').nullable().check(
const CustomExpression<bool>(
"estimated_cost_krw IS NULL OR estimated_cost_krw >= 0"))();
BoolColumn get isEffortTied => boolean().named('is_effort_tied').nullable()();
TextColumn get declaredAt => text().named('declared_at')();
@override
Set<Column> get primaryKey => {id};
}
class RewardClaims extends Table {
TextColumn get id => text()();
TextColumn get declarationId => text()
.named('declaration_id')
.customConstraint('REFERENCES reward_declarations(id) NOT NULL')();
TextColumn get milestoneReachedAt => text().named('milestone_reached_at')();
BoolColumn get fulfilled => boolean()();
TextColumn get fulfilledAt => text().named('fulfilled_at').nullable()();
TextColumn get reflection => text().nullable().check(
const CustomExpression<bool>(
"reflection IS NULL OR length(reflection) <= 500"))();
@override
Set<Column> get primaryKey => {id};
}
class Reflections extends Table {
TextColumn get id => text()();
TextColumn get userId =>
text().named('user_id').customConstraint('REFERENCES users(id) NOT NULL')();
TextColumn get scope => text().check(const CustomExpression<bool>(
"scope IN ('weekly','monthly','phase_end')"))();
TextColumn get periodStart => text().named('period_start')();
TextColumn get periodEnd => text().named('period_end')();
TextColumn get phaseId => text()
.named('phase_id')
.nullable()
.customConstraint('NULL REFERENCES phases(id) ON DELETE SET NULL')();
TextColumn get kept => text().nullable()();
TextColumn get missed => text().nullable()();
TextColumn get adjust => text().nullable()();
TextColumn get identityNote => text().named('identity_note').nullable()();
RealColumn get minimumRatio => real().named('minimum_ratio').nullable().check(
const CustomExpression<bool>(
"minimum_ratio IS NULL OR (minimum_ratio BETWEEN 0 AND 1)"))();
TextColumn get createdAt => text().named('created_at').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class MetaKv extends Table {
TextColumn get key => text()();
TextColumn get value => text()();
@override
Set<Column> get primaryKey => {key};
}

View File

@@ -0,0 +1,232 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/services.dart' show rootBundle;
import '../../core/constants.dart';
import '../db/app_database.dart';
/// fn-seed-importer
///
/// Idempotent seed loader for the 8 catalog tables. Runs once per install
/// (gated by meta_kv[`seeded_v1`]). Transactional: either all 8 catalogs
/// import or none do.
///
/// JSON files live under `assets/seed/*.json` and ship as a top-level array.
class SeedImporter {
final AppDatabase db;
final Future<String> Function(String path) loadAsset;
SeedImporter(this.db, {Future<String> Function(String path)? loadAsset})
: loadAsset = loadAsset ?? rootBundle.loadString;
/// Import all catalogs if not already seeded. Returns true if the import
/// ran, false if it was a no-op.
Future<bool> importIfNeeded() async {
final marker = await (db.select(db.metaKv)
..where((t) => t.key.equals(kSeededV1Flag)))
.getSingleOrNull();
if (marker != null && marker.value == 'true') return false;
await db.transaction(() async {
await _importProtocols();
await _importBreakProtocols();
await _importCommonFrames();
await _importMethodologies();
await _importFramePatterns();
await _importRewardMenuItems();
await _importReferences();
await _importDietPatterns();
await db.into(db.metaKv).insertOnConflictUpdate(
MetaKvCompanion.insert(key: kSeededV1Flag, value: 'true'),
);
});
return true;
}
Future<List<dynamic>> _loadJsonArray(String fileName) async {
final raw = await loadAsset('assets/seed/$fileName');
final decoded = json.decode(raw);
if (decoded is! List) {
throw FormatException('$fileName: expected top-level JSON array');
}
return decoded;
}
String? _jsonField(Map<String, dynamic> m, String key) {
final v = m[key];
if (v == null) return null;
return json.encode(v);
}
// ---- 8 catalog adapters ----
Future<void> _importProtocols() async {
final rows = await _loadJsonArray('protocols.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.protocols).insertOnConflictUpdate(ProtocolsCompanion.insert(
id: r['id'] as String,
category: r['category'] as String,
title: r['title'] as String,
titleEn: Value(r['title_en'] as String?),
what: r['what'] as String,
whenText: r['when'] as String,
dose: r['dose'] as String,
why: r['why'] as String,
howJson: _jsonField(r, 'how') ?? '[]',
checkText: r['check'] as String,
caution: Value(r['caution'] as String?),
defaultAnchorJson: Value(_jsonField(r, 'default_anchor')),
minDoseForStart: Value(r['min_dose_for_start'] as String?),
referenceIdsJson: Value(_jsonField(r, 'reference_ids')),
evidenceStrength: Value(r['evidence_strength'] as String?),
sourceDoc: Value(r['source_doc'] as String?),
));
}
}
Future<void> _importBreakProtocols() async {
final rows = await _loadJsonArray('break_protocols.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.breakProtocols).insertOnConflictUpdate(
BreakProtocolsCompanion.insert(
id: r['id'] as String,
category: r['category'] as String,
title: r['title'] as String,
hubermanSummary: r['huberman_summary'] as String,
frameExamplesJson: Value(_jsonField(r, 'frame_examples')),
phasesJson: _jsonField(r, 'phases') ?? '[]',
defaultCommonFramesJson:
_jsonField(r, 'default_common_frames') ?? '[]',
toolsJson: Value(_jsonField(r, 'tools')),
medicalWarning: Value(r['medical_warning'] as String?),
referenceIdsJson: Value(_jsonField(r, 'reference_ids')),
),
);
}
}
Future<void> _importCommonFrames() async {
final rows = await _loadJsonArray('common_frames.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.commonFrames).insertOnConflictUpdate(
CommonFramesCompanion.insert(
id: r['id'] as String,
title: r['title'] as String,
what: r['what'] as String,
why: r['why'] as String,
dose: Value(r['dose'] as String?),
howJson: Value(_jsonField(r, 'how')),
checkText: r['check'] as String,
applicableBreakCategoriesJson:
Value(_jsonField(r, 'applicable_break_categories')),
referenceIdsJson: Value(_jsonField(r, 'reference_ids')),
),
);
}
}
Future<void> _importMethodologies() async {
final rows = await _loadJsonArray('methodologies.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.methodologies).insertOnConflictUpdate(
MethodologiesCompanion.insert(
id: r['id'] as String,
name: r['name'] as String,
originator: r['originator'] as String,
oneLineDefinition: r['one_line_definition'] as String,
coreUnit: r['core_unit'] as String,
procedureJson: Value(_jsonField(r, 'procedure')),
toolsJson: Value(_jsonField(r, 'tools')),
strengthsJson: Value(_jsonField(r, 'strengths')),
weaknessesJson: Value(_jsonField(r, 'weaknesses')),
goodFor: Value(r['good_for'] as String?),
hubermanFitScore: r['huberman_fit_score'] as int,
isCoreEngine: Value(r['is_core_engine'] as bool? ?? false),
referenceIdsJson: Value(_jsonField(r, 'reference_ids')),
),
);
}
}
Future<void> _importFramePatterns() async {
final rows = await _loadJsonArray('frame_patterns.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.framePatterns).insertOnConflictUpdate(
FramePatternsCompanion.insert(
id: r['id'] as String,
domain: Value(r['domain'] as String?),
avoidanceKeyword: r['avoidance_keyword'] as String,
l0Example: r['l0_example'] as String,
l1SimpleReplace: Value(r['l1_simple_replace'] as String?),
l2Suggestion: r['l2_suggestion'] as String,
l3Identity: Value(r['l3_identity'] as String?),
),
);
}
}
Future<void> _importRewardMenuItems() async {
final rows = await _loadJsonArray('reward_menu_items.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.rewardMenuItems).insertOnConflictUpdate(
RewardMenuItemsCompanion.insert(
id: r['id'] as String,
tierRecommended: r['tier_recommended'] as String,
title: r['title'] as String,
description: Value(r['description'] as String?),
estimatedCostKrwMin: Value(r['estimated_cost_krw_min'] as int?),
estimatedCostKrwMax: Value(r['estimated_cost_krw_max'] as int?),
isEffortTied: Value(r['is_effort_tied'] as bool?),
tagsJson: Value(_jsonField(r, 'tags')),
avoidForBreakHabitsJson:
Value(_jsonField(r, 'avoid_for_break_habits')),
),
);
}
}
Future<void> _importReferences() async {
final rows = await _loadJsonArray('references.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.references).insertOnConflictUpdate(
ReferencesCompanion.insert(
id: r['id'] as String,
kind: r['kind'] as String,
title: r['title'] as String,
authorsJson: Value(_jsonField(r, 'authors')),
year: Value(r['year'] as int?),
journal: Value(r['journal'] as String?),
doi: Value(r['doi'] as String?),
url: Value(r['url'] as String?),
episodeNumber: Value(r['episode_number'] as int?),
publisher: Value(r['publisher'] as String?),
evidenceStrength: Value(r['evidence_strength'] as String?),
verified: Value(r['verified'] as bool?),
note: Value(r['note'] as String?),
),
);
}
}
Future<void> _importDietPatterns() async {
final rows = await _loadJsonArray('diet_patterns.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.dietPatterns).insertOnConflictUpdate(
DietPatternsCompanion.insert(
id: r['id'] as String,
name: r['name'] as String,
core: r['core'] as String,
strengthsJson: Value(_jsonField(r, 'strengths')),
weaknessesJson: Value(_jsonField(r, 'weaknesses')),
evidenceStrength: r['evidence_strength'] as String,
koreanContextFit: Value(r['korean_context_fit'] as String?),
starterLeversJson: Value(_jsonField(r, 'starter_levers')),
medicalWarning: Value(r['medical_warning'] as String?),
linkedProtocolIdsJson: Value(_jsonField(r, 'linked_protocol_ids')),
referenceIdsJson: Value(_jsonField(r, 'reference_ids')),
),
);
}
}
}