Files
life-helper/app/lib/domain/recommend/recommend_variant.dart
joungmin 8fe6a8f378 [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>
2026-06-12 10:33:03 +09:00

68 lines
2.0 KiB
Dart

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