[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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user