- 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>
233 lines
9.3 KiB
Dart
233 lines
9.3 KiB
Dart
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')),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|