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

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

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

View File

@@ -0,0 +1,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();
}

View 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,
});
}

View 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 [],
});
}

View 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,
});
}

View 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,
});
}

View 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;
}

View 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,
);
}

View 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;
}

View 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);
}

View File

@@ -0,0 +1,2 @@
/// R5: tracker_entry.value must be 'done' or 'blank'.
bool validateTrackerValue(String s) => s == 'done' || s == 'blank';

View 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');
}
}
}

View 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')}';

View 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,
);
}