[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:
4
app/lib/core/constants.dart
Normal file
4
app/lib/core/constants.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
/// Default single-user id for local-only Phase 1.
|
||||
const String kLocalDefaultUserId = 'u_local_default';
|
||||
|
||||
const String kSeededV1Flag = 'seeded_v1';
|
||||
14
app/lib/core/id.dart
Normal file
14
app/lib/core/id.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:ulid/ulid.dart';
|
||||
|
||||
/// Typed ULID with prefix (e.g. `hb_01J9XYZ...`).
|
||||
String generateUlid(String prefix) {
|
||||
if (prefix.isEmpty) {
|
||||
throw ArgumentError('prefix must be non-empty');
|
||||
}
|
||||
return '${prefix}_${Ulid().toString()}';
|
||||
}
|
||||
|
||||
final RegExp _ulidRegex =
|
||||
RegExp(r'^[A-Za-z]{1,8}_[0-9A-HJKMNP-TV-Z]{26}$');
|
||||
|
||||
bool isValidUlid(String s) => _ulidRegex.hasMatch(s);
|
||||
16
app/lib/core/result.dart
Normal file
16
app/lib/core/result.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
/// Minimal Result sum type. Use for domain operations that may fail.
|
||||
sealed class Result<T, E> {
|
||||
const Result();
|
||||
bool get isOk => this is Ok<T, E>;
|
||||
bool get isErr => this is Err<T, E>;
|
||||
}
|
||||
|
||||
final class Ok<T, E> extends Result<T, E> {
|
||||
final T value;
|
||||
const Ok(this.value);
|
||||
}
|
||||
|
||||
final class Err<T, E> extends Result<T, E> {
|
||||
final E error;
|
||||
const Err(this.error);
|
||||
}
|
||||
19
app/lib/core/time.dart
Normal file
19
app/lib/core/time.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
/// Time helpers. KST default. ISO 8601 string for DB storage.
|
||||
DateTime nowKst() => DateTime.now().toLocal();
|
||||
|
||||
/// Strip time → YYYY-MM-DD (DB date column).
|
||||
String dateOnly(DateTime d) {
|
||||
final local = d.toLocal();
|
||||
return '${local.year.toString().padLeft(4, '0')}-'
|
||||
'${local.month.toString().padLeft(2, '0')}-'
|
||||
'${local.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Monday 00:00 of the week containing [d].
|
||||
DateTime weekStart(DateTime d) {
|
||||
final local = d.toLocal();
|
||||
final mondayOffset = (local.weekday - DateTime.monday) % 7;
|
||||
final monday = DateTime(local.year, local.month, local.day)
|
||||
.subtract(Duration(days: mondayOffset));
|
||||
return monday;
|
||||
}
|
||||
144
app/lib/data/db/app_database.dart
Normal file
144
app/lib/data/db/app_database.dart
Normal 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'));
|
||||
}
|
||||
21754
app/lib/data/db/app_database.g.dart
Normal file
21754
app/lib/data/db/app_database.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
147
app/lib/data/db/daos/habit_dao.dart
Normal file
147
app/lib/data/db/daos/habit_dao.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
12
app/lib/data/db/daos/habit_dao.g.dart
Normal file
12
app/lib/data/db/daos/habit_dao.g.dart
Normal 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;
|
||||
}
|
||||
23
app/lib/data/db/daos/meta_dao.dart
Normal file
23
app/lib/data/db/daos/meta_dao.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
8
app/lib/data/db/daos/meta_dao.g.dart
Normal file
8
app/lib/data/db/daos/meta_dao.g.dart
Normal 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;
|
||||
}
|
||||
80
app/lib/data/db/daos/tracker_dao.dart
Normal file
80
app/lib/data/db/daos/tracker_dao.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
13
app/lib/data/db/daos/tracker_dao.g.dart
Normal file
13
app/lib/data/db/daos/tracker_dao.g.dart
Normal 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;
|
||||
}
|
||||
163
app/lib/data/db/tables/catalog_tables.dart
Normal file
163
app/lib/data/db/tables/catalog_tables.dart
Normal 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};
|
||||
}
|
||||
265
app/lib/data/db/tables/user_tables.dart
Normal file
265
app/lib/data/db/tables/user_tables.dart
Normal 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};
|
||||
}
|
||||
232
app/lib/data/seed/seed_importer.dart
Normal file
232
app/lib/data/seed/seed_importer.dart
Normal 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')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
150
app/lib/domain/frame/validate_frame_level.dart
Normal file
150
app/lib/domain/frame/validate_frame_level.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import '../models/frame_pattern.dart';
|
||||
import '../models/habit.dart';
|
||||
|
||||
enum FrameValidationStatus { accept, warn, reject }
|
||||
|
||||
class FrameInput {
|
||||
final FrameLevel level;
|
||||
final String? originalText;
|
||||
final String framedText;
|
||||
const FrameInput({
|
||||
required this.level,
|
||||
required this.framedText,
|
||||
this.originalText,
|
||||
});
|
||||
}
|
||||
|
||||
class AvoidanceHit {
|
||||
final String keyword;
|
||||
final int startIndex;
|
||||
final int endIndex;
|
||||
final FramePatternModel source;
|
||||
const AvoidanceHit({
|
||||
required this.keyword,
|
||||
required this.startIndex,
|
||||
required this.endIndex,
|
||||
required this.source,
|
||||
});
|
||||
}
|
||||
|
||||
class FrameSuggestion {
|
||||
final FrameLevel level;
|
||||
final String text;
|
||||
final FramePatternModel source;
|
||||
const FrameSuggestion({
|
||||
required this.level,
|
||||
required this.text,
|
||||
required this.source,
|
||||
});
|
||||
}
|
||||
|
||||
class FrameValidationResult {
|
||||
final FrameValidationStatus status;
|
||||
final List<AvoidanceHit> avoidanceHits;
|
||||
final List<FrameSuggestion> suggestions;
|
||||
const FrameValidationResult({
|
||||
required this.status,
|
||||
this.avoidanceHits = const [],
|
||||
this.suggestions = const [],
|
||||
});
|
||||
}
|
||||
|
||||
/// fn-validate-frame-level: R3 (L0/L1 reject) + R7 (avoidance keywords).
|
||||
FrameValidationResult validateFrameLevel(
|
||||
FrameInput input, {
|
||||
required Iterable<FramePatternModel> knownPatterns,
|
||||
}) {
|
||||
if (input.framedText.isEmpty) {
|
||||
return const FrameValidationResult(
|
||||
status: FrameValidationStatus.reject,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.level == FrameLevel.l0 || input.level == FrameLevel.l1) {
|
||||
final suggestions = _buildSuggestions(input.framedText, knownPatterns);
|
||||
return FrameValidationResult(
|
||||
status: FrameValidationStatus.reject,
|
||||
suggestions: suggestions,
|
||||
);
|
||||
}
|
||||
|
||||
final hits = detectAvoidanceKeywords(input.framedText, knownPatterns);
|
||||
if (hits.isEmpty) {
|
||||
return const FrameValidationResult(status: FrameValidationStatus.accept);
|
||||
}
|
||||
|
||||
final suggestions = _buildSuggestionsFromHits(hits);
|
||||
return FrameValidationResult(
|
||||
status: FrameValidationStatus.warn,
|
||||
avoidanceHits: hits,
|
||||
suggestions: suggestions,
|
||||
);
|
||||
}
|
||||
|
||||
List<AvoidanceHit> detectAvoidanceKeywords(
|
||||
String text,
|
||||
Iterable<FramePatternModel> patterns,
|
||||
) {
|
||||
final hits = <AvoidanceHit>[];
|
||||
final seen = <String>{};
|
||||
for (final p in patterns) {
|
||||
var idx = text.indexOf(p.avoidanceKeyword);
|
||||
while (idx >= 0) {
|
||||
final key = '$idx:${p.avoidanceKeyword}';
|
||||
if (!seen.contains(key)) {
|
||||
seen.add(key);
|
||||
hits.add(AvoidanceHit(
|
||||
keyword: p.avoidanceKeyword,
|
||||
startIndex: idx,
|
||||
endIndex: idx + p.avoidanceKeyword.length,
|
||||
source: p,
|
||||
));
|
||||
}
|
||||
idx = text.indexOf(p.avoidanceKeyword, idx + 1);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
List<FrameSuggestion> _buildSuggestions(
|
||||
String text,
|
||||
Iterable<FramePatternModel> patterns,
|
||||
) {
|
||||
final relevant = patterns
|
||||
.where((p) => text.contains(p.avoidanceKeyword))
|
||||
.toList();
|
||||
// If no keyword matches (L0 with no detectable avoidance), suggest from
|
||||
// 'general' domain.
|
||||
final pool = relevant.isEmpty
|
||||
? patterns.where((p) => p.domain == 'general').toList()
|
||||
: relevant;
|
||||
return _expandSuggestions(pool);
|
||||
}
|
||||
|
||||
List<FrameSuggestion> _buildSuggestionsFromHits(List<AvoidanceHit> hits) {
|
||||
final unique = <String, FramePatternModel>{};
|
||||
for (final h in hits) {
|
||||
unique[h.source.id] = h.source;
|
||||
}
|
||||
return _expandSuggestions(unique.values.toList());
|
||||
}
|
||||
|
||||
List<FrameSuggestion> _expandSuggestions(List<FramePatternModel> sources) {
|
||||
final out = <FrameSuggestion>[];
|
||||
for (final p in sources) {
|
||||
out.add(FrameSuggestion(
|
||||
level: FrameLevel.l2,
|
||||
text: p.l2Suggestion,
|
||||
source: p,
|
||||
));
|
||||
if (p.l3Identity != null) {
|
||||
out.add(FrameSuggestion(
|
||||
level: FrameLevel.l3,
|
||||
text: p.l3Identity!,
|
||||
source: p,
|
||||
));
|
||||
}
|
||||
if (out.length >= 5) break;
|
||||
}
|
||||
return out.take(5).toList();
|
||||
}
|
||||
19
app/lib/domain/models/frame_pattern.dart
Normal file
19
app/lib/domain/models/frame_pattern.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
class FramePatternModel {
|
||||
final String id;
|
||||
final String? domain;
|
||||
final String avoidanceKeyword;
|
||||
final String l0Example;
|
||||
final String? l1SimpleReplace;
|
||||
final String l2Suggestion;
|
||||
final String? l3Identity;
|
||||
|
||||
const FramePatternModel({
|
||||
required this.id,
|
||||
this.domain,
|
||||
required this.avoidanceKeyword,
|
||||
required this.l0Example,
|
||||
this.l1SimpleReplace,
|
||||
required this.l2Suggestion,
|
||||
this.l3Identity,
|
||||
});
|
||||
}
|
||||
92
app/lib/domain/models/habit.dart
Normal file
92
app/lib/domain/models/habit.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
enum HabitType { build, breakHabit }
|
||||
|
||||
extension HabitTypeX on HabitType {
|
||||
String get dbValue => this == HabitType.build ? 'build' : 'break';
|
||||
}
|
||||
|
||||
enum HabitStatus { active, paused, completed, abandoned }
|
||||
|
||||
extension HabitStatusX on HabitStatus {
|
||||
String get dbValue => name;
|
||||
}
|
||||
|
||||
enum FrameLevel { l0, l1, l2, l3 }
|
||||
|
||||
extension FrameLevelX on FrameLevel {
|
||||
String get dbValue => name.toUpperCase();
|
||||
static FrameLevel? fromDb(String s) {
|
||||
switch (s) {
|
||||
case 'L0':
|
||||
return FrameLevel.l0;
|
||||
case 'L1':
|
||||
return FrameLevel.l1;
|
||||
case 'L2':
|
||||
return FrameLevel.l2;
|
||||
case 'L3':
|
||||
return FrameLevel.l3;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class HabitDoseVariantModel {
|
||||
final String variantId;
|
||||
final String habitId;
|
||||
final String label;
|
||||
final String doseText;
|
||||
final List<String> contextTags;
|
||||
final List<String> conditionTags;
|
||||
final bool isMinimum;
|
||||
final int sortOrder;
|
||||
|
||||
const HabitDoseVariantModel({
|
||||
required this.variantId,
|
||||
required this.habitId,
|
||||
required this.label,
|
||||
required this.doseText,
|
||||
this.contextTags = const [],
|
||||
this.conditionTags = const [],
|
||||
this.isMinimum = false,
|
||||
this.sortOrder = 0,
|
||||
});
|
||||
}
|
||||
|
||||
class HabitModel {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String? phaseId;
|
||||
final HabitType type;
|
||||
final HabitStatus status;
|
||||
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; // YYYY-MM-DD
|
||||
final String? endedAt;
|
||||
final List<HabitDoseVariantModel> doseVariants;
|
||||
|
||||
const HabitModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
this.phaseId,
|
||||
required this.type,
|
||||
required this.status,
|
||||
required this.title,
|
||||
this.protocolId,
|
||||
this.breakProtocolId,
|
||||
required this.frameLevel,
|
||||
required this.frameFramedText,
|
||||
this.frameOriginalText,
|
||||
this.anchorWhen,
|
||||
this.anchorAfterWhat,
|
||||
this.anchorWhere,
|
||||
required this.startedAt,
|
||||
this.endedAt,
|
||||
this.doseVariants = const [],
|
||||
});
|
||||
}
|
||||
23
app/lib/domain/models/phase.dart
Normal file
23
app/lib/domain/models/phase.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
enum PhaseStatus { active, completed, abandoned }
|
||||
|
||||
class PhaseModel {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String? title;
|
||||
final String startedAt; // YYYY-MM-DD
|
||||
final String? endedAt;
|
||||
final int durationWeeks;
|
||||
final PhaseStatus status;
|
||||
final bool rewardDeclarationsLocked;
|
||||
|
||||
const PhaseModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
this.title,
|
||||
required this.startedAt,
|
||||
this.endedAt,
|
||||
this.durationWeeks = 6,
|
||||
required this.status,
|
||||
this.rewardDeclarationsLocked = false,
|
||||
});
|
||||
}
|
||||
29
app/lib/domain/models/tracker_entry.dart
Normal file
29
app/lib/domain/models/tracker_entry.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
enum TrackerValue { done, blank }
|
||||
|
||||
extension TrackerValueX on TrackerValue {
|
||||
String get dbValue => name;
|
||||
}
|
||||
|
||||
class TrackerEntryModel {
|
||||
final String id;
|
||||
final String habitId;
|
||||
final String date; // YYYY-MM-DD
|
||||
final TrackerValue value;
|
||||
final String? variantId;
|
||||
final String? ctxLocation;
|
||||
final String? ctxCondition;
|
||||
final String? note;
|
||||
final String? loggedAt;
|
||||
|
||||
const TrackerEntryModel({
|
||||
required this.id,
|
||||
required this.habitId,
|
||||
required this.date,
|
||||
required this.value,
|
||||
this.variantId,
|
||||
this.ctxLocation,
|
||||
this.ctxCondition,
|
||||
this.note,
|
||||
this.loggedAt,
|
||||
});
|
||||
}
|
||||
67
app/lib/domain/recommend/recommend_variant.dart
Normal file
67
app/lib/domain/recommend/recommend_variant.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import '../models/habit.dart';
|
||||
|
||||
class CheckInContext {
|
||||
final String? location;
|
||||
final String? condition;
|
||||
const CheckInContext({this.location, this.condition});
|
||||
}
|
||||
|
||||
enum RecommendReason { exactMatch, partial, fallbackMinimum, fallbackFirst }
|
||||
|
||||
class VariantPick {
|
||||
final HabitDoseVariantModel variant;
|
||||
final int score;
|
||||
final RecommendReason reason;
|
||||
const VariantPick(this.variant, this.score, this.reason);
|
||||
}
|
||||
|
||||
/// fn-recommend-variant: returns best variant for current context.
|
||||
/// O(N). Pure function. Returns null if habit has no variants.
|
||||
VariantPick? recommendVariant(HabitModel habit, CheckInContext ctx) {
|
||||
final variants = habit.doseVariants;
|
||||
if (variants.isEmpty) return null;
|
||||
|
||||
final scored = variants
|
||||
.map((v) => MapEntry(v, _scoreVariant(v, ctx)))
|
||||
.toList();
|
||||
|
||||
// Stable sort: score desc, then sortOrder asc.
|
||||
scored.sort((a, b) {
|
||||
final byScore = b.value.compareTo(a.value);
|
||||
if (byScore != 0) return byScore;
|
||||
return a.key.sortOrder.compareTo(b.key.sortOrder);
|
||||
});
|
||||
|
||||
final best = scored.first;
|
||||
if (best.value > 0) {
|
||||
final reason =
|
||||
best.value >= 4 ? RecommendReason.exactMatch : RecommendReason.partial;
|
||||
return VariantPick(best.key, best.value, reason);
|
||||
}
|
||||
|
||||
// Fallback: first is_minimum variant (by sortOrder).
|
||||
final minimum = variants
|
||||
.where((v) => v.isMinimum)
|
||||
.fold<HabitDoseVariantModel?>(null,
|
||||
(a, v) => a == null || v.sortOrder < a.sortOrder ? v : a);
|
||||
if (minimum != null) {
|
||||
return VariantPick(minimum, 0, RecommendReason.fallbackMinimum);
|
||||
}
|
||||
// No is_minimum — fall back to first by sortOrder.
|
||||
final first = [...variants]..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
|
||||
return VariantPick(first.first, 0, RecommendReason.fallbackFirst);
|
||||
}
|
||||
|
||||
int _scoreVariant(HabitDoseVariantModel v, CheckInContext ctx) {
|
||||
var s = 0;
|
||||
if (ctx.location != null && v.contextTags.contains(ctx.location)) {
|
||||
s += 2;
|
||||
}
|
||||
if (ctx.condition != null && v.conditionTags.contains(ctx.condition)) {
|
||||
s += 2;
|
||||
}
|
||||
if (v.isMinimum && ctx.condition == '나쁨') {
|
||||
s += 1;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
42
app/lib/domain/rules/active_habit_quota.dart
Normal file
42
app/lib/domain/rules/active_habit_quota.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import '../models/habit.dart';
|
||||
|
||||
/// R1/R2: max active habits per type.
|
||||
/// - build: ≤ 3
|
||||
/// - break: ≤ 1
|
||||
const int kMaxActiveBuild = 3;
|
||||
const int kMaxActiveBreak = 1;
|
||||
|
||||
class QuotaResult {
|
||||
final HabitType type;
|
||||
final int currentCount;
|
||||
final int limit;
|
||||
final bool allowed;
|
||||
|
||||
const QuotaResult({
|
||||
required this.type,
|
||||
required this.currentCount,
|
||||
required this.limit,
|
||||
required this.allowed,
|
||||
});
|
||||
|
||||
String get reason {
|
||||
if (allowed) return 'ok';
|
||||
return type == HabitType.build
|
||||
? 'build habit quota reached (≤ $kMaxActiveBuild active)'
|
||||
: 'break habit quota reached (≤ $kMaxActiveBreak active)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure judgment: caller passes in the current active count.
|
||||
QuotaResult judgeActiveHabitQuota({
|
||||
required HabitType type,
|
||||
required int currentActiveCount,
|
||||
}) {
|
||||
final limit = type == HabitType.build ? kMaxActiveBuild : kMaxActiveBreak;
|
||||
return QuotaResult(
|
||||
type: type,
|
||||
currentCount: currentActiveCount,
|
||||
limit: limit,
|
||||
allowed: currentActiveCount < limit,
|
||||
);
|
||||
}
|
||||
9
app/lib/domain/rules/phase_anchor.dart
Normal file
9
app/lib/domain/rules/phase_anchor.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
/// R6: warn if anchor change happens mid-phase (>= 1 week in).
|
||||
bool phaseAnchorChangeWarning({
|
||||
required String phaseStartedAt,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final start = DateTime.parse(phaseStartedAt);
|
||||
final daysIn = now.difference(start).inDays;
|
||||
return daysIn >= 7;
|
||||
}
|
||||
9
app/lib/domain/rules/reward_window.dart
Normal file
9
app/lib/domain/rules/reward_window.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
/// R4: reward_declaration must be created within phase.started_at + 7 days.
|
||||
bool validateRewardDeclarationWindow({
|
||||
required String phaseStartedAt, // YYYY-MM-DD
|
||||
required DateTime now,
|
||||
}) {
|
||||
final start = DateTime.parse(phaseStartedAt);
|
||||
final cutoff = start.add(const Duration(days: 7));
|
||||
return !now.isAfter(cutoff);
|
||||
}
|
||||
2
app/lib/domain/rules/tracker_value.dart
Normal file
2
app/lib/domain/rules/tracker_value.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
/// R5: tracker_entry.value must be 'done' or 'blank'.
|
||||
bool validateTrackerValue(String s) => s == 'done' || s == 'blank';
|
||||
20
app/lib/domain/rules/xor_protocol.dart
Normal file
20
app/lib/domain/rules/xor_protocol.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import '../models/habit.dart';
|
||||
|
||||
/// XOR: build → protocol_id (only); break → break_protocol_id (only).
|
||||
void assertXorProtocol({
|
||||
required HabitType type,
|
||||
required String? protocolId,
|
||||
required String? breakProtocolId,
|
||||
}) {
|
||||
if (type == HabitType.build) {
|
||||
if (protocolId == null || breakProtocolId != null) {
|
||||
throw ArgumentError(
|
||||
'build habit requires protocol_id and no break_protocol_id');
|
||||
}
|
||||
} else {
|
||||
if (breakProtocolId == null || protocolId != null) {
|
||||
throw ArgumentError(
|
||||
'break habit requires break_protocol_id and no protocol_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
162
app/lib/domain/streak/compute_streak.dart
Normal file
162
app/lib/domain/streak/compute_streak.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
import '../models/tracker_entry.dart';
|
||||
|
||||
enum RewardTier { t0, t1, t2, t3, t4 }
|
||||
|
||||
extension RewardTierX on RewardTier {
|
||||
String get dbValue {
|
||||
switch (this) {
|
||||
case RewardTier.t0:
|
||||
return 'T0';
|
||||
case RewardTier.t1:
|
||||
return 'T1';
|
||||
case RewardTier.t2:
|
||||
return 'T2';
|
||||
case RewardTier.t3:
|
||||
return 'T3';
|
||||
case RewardTier.t4:
|
||||
return 'T4';
|
||||
}
|
||||
}
|
||||
|
||||
int get rank => RewardTier.values.indexOf(this);
|
||||
}
|
||||
|
||||
class StreakState {
|
||||
final int currentStreak;
|
||||
final int longestStreak;
|
||||
final int doneCountInPhase42;
|
||||
final int doneCountInWindow30;
|
||||
final RewardTier currentTier;
|
||||
final bool neverMissTwiceBroken;
|
||||
|
||||
const StreakState({
|
||||
required this.currentStreak,
|
||||
required this.longestStreak,
|
||||
required this.doneCountInPhase42,
|
||||
required this.doneCountInWindow30,
|
||||
required this.currentTier,
|
||||
required this.neverMissTwiceBroken,
|
||||
});
|
||||
|
||||
static const StreakState empty = StreakState(
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
doneCountInPhase42: 0,
|
||||
doneCountInWindow30: 0,
|
||||
currentTier: RewardTier.t0,
|
||||
neverMissTwiceBroken: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// fn-compute-streak: computes streak + 5-tier milestone with Never miss twice.
|
||||
///
|
||||
/// OQ-5 decision (2026-06-11):
|
||||
/// - 2+ consecutive blank → tier demoted (T3→T2, T2→T1, T1→T0), streak = 0,
|
||||
/// neverMissTwiceBroken = true.
|
||||
/// - 1 blank → streak = 0, tier stays. Next done starts at 1.
|
||||
///
|
||||
/// Pure function. [habitStartedAt] is the habit's started_at (YYYY-MM-DD).
|
||||
StreakState computeStreak({
|
||||
required Iterable<TrackerEntryModel> entries,
|
||||
required DateTime asOf,
|
||||
required String habitStartedAt,
|
||||
}) {
|
||||
// 1. Index by date string (YYYY-MM-DD).
|
||||
final byDate = <String, TrackerEntryModel>{};
|
||||
for (final e in entries) {
|
||||
byDate[e.date] = e;
|
||||
}
|
||||
|
||||
if (byDate.isEmpty) return StreakState.empty;
|
||||
|
||||
final startDate = DateTime.parse(habitStartedAt);
|
||||
final asOfDate = DateTime(asOf.year, asOf.month, asOf.day);
|
||||
|
||||
// 2. currentStreak (Never miss twice).
|
||||
//
|
||||
// Semantics:
|
||||
// - Walk back from asOf, stopping at habit start.
|
||||
// - "No entry record" for a date means tracking hasn't reached that day (or
|
||||
// user hasn't synced) — treat as the end of streak history, do not penalize.
|
||||
// - Explicit TrackerValue.blank is the penalty signal. 1 blank zeroes the
|
||||
// streak (tier stays); 2 consecutive blanks set neverMissTwiceBroken.
|
||||
var streak = 0;
|
||||
var consecutiveBlank = 0;
|
||||
var neverMissTwiceBroken = false;
|
||||
var cursor = asOfDate;
|
||||
|
||||
while (!cursor.isBefore(startDate)) {
|
||||
final e = byDate[_ymd(cursor)];
|
||||
if (e == null) {
|
||||
// End of recorded history.
|
||||
break;
|
||||
}
|
||||
if (e.value == TrackerValue.blank) {
|
||||
consecutiveBlank += 1;
|
||||
if (consecutiveBlank >= 2) {
|
||||
neverMissTwiceBroken = true;
|
||||
streak = 0;
|
||||
break;
|
||||
}
|
||||
// 1 blank so far: streak is broken, but keep walking one more day to
|
||||
// detect a possible double-blank.
|
||||
streak = 0;
|
||||
cursor = cursor.subtract(const Duration(days: 1));
|
||||
continue;
|
||||
}
|
||||
// Done.
|
||||
if (consecutiveBlank > 0) {
|
||||
// Previous step saw a single blank, now done → single-blank confirmed.
|
||||
break;
|
||||
}
|
||||
streak += 1;
|
||||
cursor = cursor.subtract(const Duration(days: 1));
|
||||
}
|
||||
|
||||
// 3. longestStreak over all entries.
|
||||
final sortedDates = byDate.keys.toList()..sort();
|
||||
var longest = 0;
|
||||
var run = 0;
|
||||
for (final d in sortedDates) {
|
||||
if (byDate[d]!.value == TrackerValue.done) {
|
||||
run += 1;
|
||||
if (run > longest) longest = run;
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Window counts.
|
||||
final win30Start = asOfDate.subtract(const Duration(days: 29));
|
||||
final win42Start = asOfDate.subtract(const Duration(days: 41));
|
||||
var window30 = 0;
|
||||
var window42 = 0;
|
||||
for (final e in byDate.values) {
|
||||
if (e.value != TrackerValue.done) continue;
|
||||
final d = DateTime.parse(e.date);
|
||||
if (!d.isBefore(win30Start) && !d.isAfter(asOfDate)) window30 += 1;
|
||||
if (!d.isBefore(win42Start) && !d.isAfter(asOfDate)) window42 += 1;
|
||||
}
|
||||
|
||||
// 5. Tier judgment.
|
||||
var tier = RewardTier.t0;
|
||||
if (streak >= 3) tier = RewardTier.t1;
|
||||
if (streak >= 7) tier = RewardTier.t2;
|
||||
if (window30 >= 24 && tier.rank < RewardTier.t3.rank) tier = RewardTier.t3;
|
||||
final phaseDay = asOfDate.difference(startDate).inDays + 1;
|
||||
if (phaseDay >= 42 && window42 >= 30) tier = RewardTier.t4;
|
||||
|
||||
return StreakState(
|
||||
currentStreak: streak,
|
||||
longestStreak: longest,
|
||||
doneCountInPhase42: window42,
|
||||
doneCountInWindow30: window30,
|
||||
currentTier: tier,
|
||||
neverMissTwiceBroken: neverMissTwiceBroken,
|
||||
);
|
||||
}
|
||||
|
||||
String _ymd(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-'
|
||||
'${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}';
|
||||
66
app/lib/domain/streak/weekly_minimum_ratio.dart
Normal file
66
app/lib/domain/streak/weekly_minimum_ratio.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import '../models/habit.dart';
|
||||
import '../models/tracker_entry.dart';
|
||||
|
||||
/// fn-weekly-minimum-ratio
|
||||
///
|
||||
/// Computes the share of done check-ins in the last 7 days that used the
|
||||
/// habit's "minimum" dose variant. Useful for L2-conditional habits where the
|
||||
/// user picks a minimum option on bad-condition days. A high ratio is fine
|
||||
/// (the protocol's whole point), but it also signals the user is mostly
|
||||
/// running on minimum dose — a context the UI can surface.
|
||||
///
|
||||
/// Returns 0.0 when there are no done entries in the window (no division by
|
||||
/// zero).
|
||||
class WeeklyMinimumRatio {
|
||||
final int totalDone;
|
||||
final int minimumUsed;
|
||||
final double ratio; // 0.0..1.0
|
||||
final DateTime windowStart; // inclusive, YYYY-MM-DD == windowStart
|
||||
final DateTime windowEnd; // inclusive
|
||||
|
||||
const WeeklyMinimumRatio({
|
||||
required this.totalDone,
|
||||
required this.minimumUsed,
|
||||
required this.ratio,
|
||||
required this.windowStart,
|
||||
required this.windowEnd,
|
||||
});
|
||||
}
|
||||
|
||||
/// Pure function: caller resolves the variant rows and passes them in.
|
||||
///
|
||||
/// - [entries] should already be filtered to the habit and to value=done.
|
||||
/// - [variantsById] maps variant_id → variant (only minimums need to be
|
||||
/// present, but a full map is fine).
|
||||
/// - [asOf] is treated as the inclusive end of the 7-day window.
|
||||
WeeklyMinimumRatio computeWeeklyMinimumRatio({
|
||||
required Iterable<TrackerEntryModel> entries,
|
||||
required Map<String, HabitDoseVariantModel> variantsById,
|
||||
required DateTime asOf,
|
||||
}) {
|
||||
final end = DateTime(asOf.year, asOf.month, asOf.day);
|
||||
final start = end.subtract(const Duration(days: 6));
|
||||
|
||||
var totalDone = 0;
|
||||
var minimumUsed = 0;
|
||||
|
||||
for (final e in entries) {
|
||||
if (e.value != TrackerValue.done) continue;
|
||||
final d = DateTime.parse(e.date);
|
||||
if (d.isBefore(start) || d.isAfter(end)) continue;
|
||||
totalDone += 1;
|
||||
final vId = e.variantId;
|
||||
if (vId == null) continue;
|
||||
final v = variantsById[vId];
|
||||
if (v != null && v.isMinimum) minimumUsed += 1;
|
||||
}
|
||||
|
||||
final ratio = totalDone == 0 ? 0.0 : minimumUsed / totalDone;
|
||||
return WeeklyMinimumRatio(
|
||||
totalDone: totalDone,
|
||||
minimumUsed: minimumUsed,
|
||||
ratio: ratio,
|
||||
windowStart: start,
|
||||
windowEnd: end,
|
||||
);
|
||||
}
|
||||
30
app/lib/main.dart
Normal file
30
app/lib/main.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'state/providers.dart';
|
||||
import 'ui/screens/habit_list_screen.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final db = await openProductionDatabase();
|
||||
runApp(ProviderScope(
|
||||
overrides: [appDatabaseProvider.overrideWithValue(db)],
|
||||
child: const LifeHelperApp(),
|
||||
));
|
||||
}
|
||||
|
||||
class LifeHelperApp extends StatelessWidget {
|
||||
const LifeHelperApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'life-helper',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HabitListScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
app/lib/state/providers.dart
Normal file
65
app/lib/state/providers.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/constants.dart';
|
||||
import '../core/time.dart';
|
||||
import '../data/db/app_database.dart';
|
||||
import '../data/db/daos/habit_dao.dart';
|
||||
import '../data/db/daos/meta_dao.dart';
|
||||
import '../data/db/daos/tracker_dao.dart';
|
||||
import '../data/seed/seed_importer.dart';
|
||||
|
||||
/// Override in tests with an in-memory database.
|
||||
final appDatabaseProvider = Provider<AppDatabase>((ref) {
|
||||
throw UnimplementedError('appDatabaseProvider must be overridden in main()');
|
||||
});
|
||||
|
||||
Future<AppDatabase> openProductionDatabase() async {
|
||||
final file = await appDatabaseFile();
|
||||
return AppDatabase(NativeDatabase.createInBackground(file));
|
||||
}
|
||||
|
||||
final habitDaoProvider = Provider<HabitDao>((ref) {
|
||||
return HabitDao(ref.watch(appDatabaseProvider));
|
||||
});
|
||||
|
||||
final trackerDaoProvider = Provider<TrackerDao>((ref) {
|
||||
return TrackerDao(ref.watch(appDatabaseProvider));
|
||||
});
|
||||
|
||||
final metaDaoProvider = Provider<MetaDao>((ref) {
|
||||
return MetaDao(ref.watch(appDatabaseProvider));
|
||||
});
|
||||
|
||||
/// One-time bootstrap: ensure default user row + seed catalogs.
|
||||
final bootstrapProvider = FutureProvider<void>((ref) async {
|
||||
final db = ref.watch(appDatabaseProvider);
|
||||
// Ensure default user.
|
||||
final existing = await (db.select(db.users)
|
||||
..where((t) => t.id.equals(kLocalDefaultUserId)))
|
||||
.getSingleOrNull();
|
||||
if (existing == null) {
|
||||
await db.into(db.users).insert(UsersCompanion.insert(
|
||||
id: kLocalDefaultUserId,
|
||||
displayName: const drift.Value('You'),
|
||||
createdAt: nowKst().toIso8601String(),
|
||||
));
|
||||
}
|
||||
// Seed catalogs (idempotent).
|
||||
await SeedImporter(db).importIfNeeded();
|
||||
if (kDebugMode) {
|
||||
debugPrint('bootstrap done');
|
||||
}
|
||||
});
|
||||
|
||||
/// Active habits stream for current user.
|
||||
final activeHabitsProvider = StreamProvider<List<Habit>>((ref) {
|
||||
final db = ref.watch(appDatabaseProvider);
|
||||
return (db.select(db.habits)
|
||||
..where((t) => t.userId.equals(kLocalDefaultUserId))
|
||||
..where((t) => t.status.equals('active'))
|
||||
..orderBy([(t) => drift.OrderingTerm.asc(t.startedAt)]))
|
||||
.watch();
|
||||
});
|
||||
82
app/lib/ui/screens/check_in_screen.dart
Normal file
82
app/lib/ui/screens/check_in_screen.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/time.dart';
|
||||
import '../../data/db/daos/tracker_dao.dart';
|
||||
import '../../state/providers.dart';
|
||||
|
||||
class CheckInScreen extends ConsumerStatefulWidget {
|
||||
final String habitId;
|
||||
const CheckInScreen({super.key, required this.habitId});
|
||||
|
||||
@override
|
||||
ConsumerState<CheckInScreen> createState() => _CheckInScreenState();
|
||||
}
|
||||
|
||||
class _CheckInScreenState extends ConsumerState<CheckInScreen> {
|
||||
bool _saving = false;
|
||||
|
||||
Future<void> _record(String value) async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final dao = ref.read(trackerDaoProvider);
|
||||
await dao.recordCheckIn(TrackerEntryDraft(
|
||||
habitId: widget.habitId,
|
||||
date: _ymd(nowKst()),
|
||||
value: value,
|
||||
));
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(value == 'done' ? '체크인 완료' : '오늘은 비움')),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('실패: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('체크인')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('오늘 (${_ymd(nowKst())})',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : () => _record('done'),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text('완료', style: TextStyle(fontSize: 18)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _saving ? null : () => _record('blank'),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('비움'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _ymd(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-'
|
||||
'${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}';
|
||||
137
app/lib/ui/screens/habit_create_screen.dart
Normal file
137
app/lib/ui/screens/habit_create_screen.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../core/time.dart';
|
||||
import '../../data/db/daos/habit_dao.dart';
|
||||
import '../../domain/models/habit.dart';
|
||||
import '../../domain/rules/active_habit_quota.dart';
|
||||
import '../../state/providers.dart';
|
||||
|
||||
class HabitCreateScreen extends ConsumerStatefulWidget {
|
||||
const HabitCreateScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HabitCreateScreen> createState() => _HabitCreateScreenState();
|
||||
}
|
||||
|
||||
class _HabitCreateScreenState extends ConsumerState<HabitCreateScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _titleCtrl = TextEditingController();
|
||||
final _framedCtrl = TextEditingController();
|
||||
HabitType _type = HabitType.build;
|
||||
FrameLevel _level = FrameLevel.l2;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleCtrl.dispose();
|
||||
_framedCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final dao = ref.read(habitDaoProvider);
|
||||
final count = await dao.countActive(
|
||||
userId: kLocalDefaultUserId,
|
||||
type: _type,
|
||||
);
|
||||
final quota = judgeActiveHabitQuota(type: _type, currentActiveCount: count);
|
||||
if (!quota.allowed) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(quota.reason)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await dao.insertWithVariants(HabitDraft(
|
||||
userId: kLocalDefaultUserId,
|
||||
type: _type,
|
||||
title: _titleCtrl.text.trim(),
|
||||
// Placeholder: vertical-slice uses the first seeded protocol.
|
||||
protocolId: _type == HabitType.build ? 'morning_sunlight' : null,
|
||||
breakProtocolId: _type == HabitType.breakHabit ? 'alcohol' : null,
|
||||
frameLevel: _level,
|
||||
frameFramedText: _framedCtrl.text.trim(),
|
||||
startedAt: _ymd(nowKst()),
|
||||
));
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('저장 실패: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('새 습관')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _titleCtrl,
|
||||
decoration: const InputDecoration(labelText: '제목'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? '제목을 입력하세요' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<HabitType>(
|
||||
initialValue: _type,
|
||||
items: const [
|
||||
DropdownMenuItem(value: HabitType.build, child: Text('만들기 (build)')),
|
||||
DropdownMenuItem(
|
||||
value: HabitType.breakHabit, child: Text('없애기 (break)')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _type = v ?? HabitType.build),
|
||||
decoration: const InputDecoration(labelText: '타입'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<FrameLevel>(
|
||||
initialValue: _level,
|
||||
items: const [
|
||||
DropdownMenuItem(value: FrameLevel.l2, child: Text('L2 · 조건부 긍정')),
|
||||
DropdownMenuItem(value: FrameLevel.l3, child: Text('L3 · 정체성')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _level = v ?? FrameLevel.l2),
|
||||
decoration: const InputDecoration(labelText: '프레임 레벨'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _framedCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '프레임 문구',
|
||||
hintText: '예: 아침 햇빛을 10분 받는다',
|
||||
),
|
||||
maxLines: 2,
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? '프레임 문구를 입력하세요' : null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: Text(_saving ? '저장 중...' : '저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _ymd(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-'
|
||||
'${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}';
|
||||
72
app/lib/ui/screens/habit_list_screen.dart
Normal file
72
app/lib/ui/screens/habit_list_screen.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../state/providers.dart';
|
||||
import 'check_in_screen.dart';
|
||||
import 'habit_create_screen.dart';
|
||||
import 'streak_screen.dart';
|
||||
|
||||
class HabitListScreen extends ConsumerWidget {
|
||||
const HabitListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final boot = ref.watch(bootstrapProvider);
|
||||
final habitsAsync = ref.watch(activeHabitsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('습관')),
|
||||
body: boot.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, st) => Center(child: Text('초기화 실패: $e')),
|
||||
data: (_) => habitsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, st) => Center(child: Text('로드 실패: $e')),
|
||||
data: (habits) {
|
||||
if (habits.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('아직 습관이 없습니다. + 버튼으로 추가하세요.'),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: habits.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final h = habits[i];
|
||||
return ListTile(
|
||||
title: Text(h.title),
|
||||
subtitle: Text(
|
||||
'${h.type} · ${h.frameLevel} · ${h.frameFramedText}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.show_chart),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => StreakScreen(habitId: h.id),
|
||||
));
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => CheckInScreen(habitId: h.id),
|
||||
));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const HabitCreateScreen(),
|
||||
));
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
app/lib/ui/screens/streak_screen.dart
Normal file
102
app/lib/ui/screens/streak_screen.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/time.dart';
|
||||
import '../../domain/models/tracker_entry.dart';
|
||||
import '../../domain/streak/compute_streak.dart';
|
||||
import '../../state/providers.dart';
|
||||
|
||||
class StreakScreen extends ConsumerWidget {
|
||||
final String habitId;
|
||||
const StreakScreen({super.key, required this.habitId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final db = ref.watch(appDatabaseProvider);
|
||||
final habitFuture = (db.select(db.habits)
|
||||
..where((t) => t.id.equals(habitId)))
|
||||
.getSingle();
|
||||
final entriesFuture = ref.read(trackerDaoProvider).entriesForHabit(habitId);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('스트릭')),
|
||||
body: FutureBuilder(
|
||||
future: Future.wait([habitFuture, entriesFuture]),
|
||||
builder: (context, snap) {
|
||||
if (!snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(child: Text('실패: ${snap.error}'));
|
||||
}
|
||||
final habit = snap.data![0] as dynamic;
|
||||
final entryRows = snap.data![1] as List;
|
||||
final entries = entryRows.map((r) {
|
||||
return TrackerEntryModel(
|
||||
id: r.id as String,
|
||||
habitId: r.habitId as String,
|
||||
date: r.date as String,
|
||||
value: (r.value as String) == 'done'
|
||||
? TrackerValue.done
|
||||
: TrackerValue.blank,
|
||||
variantId: r.variantId as String?,
|
||||
ctxLocation: r.ctxLocation as String?,
|
||||
ctxCondition: r.ctxCondition as String?,
|
||||
note: r.note as String?,
|
||||
loggedAt: r.loggedAt as String?,
|
||||
);
|
||||
}).toList();
|
||||
final state = computeStreak(
|
||||
entries: entries,
|
||||
asOf: nowKst(),
|
||||
habitStartedAt: habit.startedAt as String,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(habit.title as String,
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 24),
|
||||
_Row('현재 스트릭', '${state.currentStreak}일'),
|
||||
_Row('최장 스트릭', '${state.longestStreak}일'),
|
||||
_Row('최근 30일 / 완료', '${state.doneCountInWindow30}회'),
|
||||
_Row('Phase 42일 / 완료', '${state.doneCountInPhase42}회'),
|
||||
const Divider(height: 32),
|
||||
_Row('현재 티어', state.currentTier.dbValue),
|
||||
if (state.neverMissTwiceBroken)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'⚠ Never miss twice 발동 — 티어 강등',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
const _Row(this.label, this.value);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label)),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user