Add Coach, take the reference's colours and copy its nav and motion

Coach is the centre tab: questions about routines, slips, streak, today,
active time and debt, answered on the device from the user's own data,
with a target bar chart and buttons that start routines or open the right
screen. The palette moves to neutral grey, near-black and one orange
accent, and Home's header becomes a light panel. The nav is now
overlapping frosted circles whose active disc pops forward, and tabs fade
and rise in on switch. Goals leaves the nav for Coach and stays one tap
away in Home's category row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
alvocool
2026-11-09 07:22:35 +03:00
parent d3431ca5de
commit 4cd3f6c038
18 changed files with 1688 additions and 169 deletions

View File

@@ -0,0 +1,522 @@
import '../about/external/data/Commitment.dart';
import '../about/external/data/RoutineChain.dart';
import '../about/internal/application/CoachAction.dart';
import '../about/internal/application/CoachActionKind.dart';
import '../about/internal/application/CoachChart.dart';
import '../about/internal/application/CoachContext.dart';
import '../about/internal/application/CoachMessage.dart';
import '../about/internal/application/CommitmentStatus.dart';
import '../about/internal/application/RoutineRunRecord.dart';
import 'CommonUtils.dart';
import 'ProgressEngine.dart';
/// The coach: answers a fixed set of questions from the user's own data.
/// No model, no guessing — every number in an answer is computed from the
/// run log, the routines, the plan and the overdue queue in [CoachContext].
/// A question it cannot place gets the list of what it can answer.
class CoachEngine {
static const String askKeeping = "Am I keeping my routines?";
static const String askSlip = "Where do I slip?";
static const String askStreak = "How's my streak?";
static const String askToday = "What's left today?";
static const String askActive = "Am I hitting my active time?";
static const String askDebt = "Why is my debt where it is?";
static const List<String> suggestions = <String>[
askKeeping,
askSlip,
askToday,
askStreak,
askActive,
askDebt,
];
static const List<String> _letters = <String>[
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun"
];
static CoachMessage answer(String question, CoachContext data, DateTime now) {
final String q = question.toLowerCase();
if (_has(q, <String>["debt", "overdue", "owe", "standing", "late"])) {
return _debt(data, now);
}
if (_has(q, <String>["streak", "in a row"])) {
return _streak(data, now);
}
if (_has(q, <String>[
"slip",
"miss",
"break",
"fail",
"worst",
"weak",
"struggl"
])) {
return _slip(data, now);
}
if (_has(q, <String>["today", "left", "next", "now", "should i"])) {
return _today(data, now);
}
if (_has(q, <String>["time", "minute", "active", "hour", "long"])) {
return _active(data, now);
}
if (_has(q, <String>[
"keep",
"routine",
"consistent",
"held",
"track",
"enough",
"doing",
"habit"
])) {
return _keeping(data, now);
}
return _unknown();
}
static bool _has(String q, List<String> words) =>
words.any((word) => q.contains(word));
// ── Shared maths ──────────────────────────────────────────────────────────
/// The last [count] days, oldest first, ending today.
static List<DateTime> _days(DateTime now, int count) {
final DateTime today = ProgressEngine.dayOf(now);
return List<DateTime>.generate(
count, (index) => today.subtract(Duration(days: count - 1 - index)));
}
static List<RoutineRunRecord> _on(
List<RoutineRunRecord> runs, DateTime day) =>
ProgressEngine.between(runs, day, day.add(const Duration(days: 1)));
/// A day's target: the saved weekly target spread evenly, or, until one is
/// saved, what the schedule asks for that day.
static double _targetOn(CoachContext data, DateTime day) {
if (data.targets.set) {
return data.targets.routinesPerWeek / 7;
}
return data.routines
.where((routine) => routine.scheduledOn(day))
.length
.toDouble();
}
static String _label(DateTime day, DateTime now) =>
ProgressEngine.dayOf(day) == ProgressEngine.dayOf(now)
? "Today"
: _letters[day.weekday - 1];
static String _times(int count) => count == 1 ? "once" : "$count times";
/// Held rate per routine over the last [days], counted against the days it
/// was scheduled. Routines with no schedule in the window are skipped.
static List<({RoutineChain routine, int held, int due})> _rates(
CoachContext data, DateTime now, int days) {
final List<DateTime> window = _days(now, days + 1).take(days).toList();
final List<({RoutineChain routine, int held, int due})> result = [];
for (RoutineChain routine in data.routines) {
int due = 0;
int held = 0;
for (DateTime day in window) {
if (!routine.scheduledOn(day)) {
continue;
}
due = due + 1;
if (_on(data.runs, day)
.any((run) => run.routineId == routine.id && run.held)) {
held = held + 1;
}
}
if (due > 0) {
result.add((routine: routine, held: held, due: due));
}
}
result.sort((a, b) => (a.held / a.due).compareTo(b.held / b.due));
return result;
}
static RoutineChain? _dueNow(CoachContext data, DateTime now) {
final List<RoutineChain> due = data.routines
.where((routine) =>
routine.scheduledOn(now) &&
!routine.complete &&
routine.steps.isNotEmpty)
.toList()
..sort((a, b) => a.startMinute.compareTo(b.startMinute));
return due.isEmpty ? null : due.first;
}
static CoachAction _start(RoutineChain routine, {bool primary = true}) =>
CoachAction(
label: "Start ${routine.title.toLowerCase()}",
kind: CoachActionKind.StartRoutine,
routineId: routine.id ?? "",
primary: primary,
);
static CoachAction _ask(String question) => CoachAction(
label: question, kind: CoachActionKind.Ask, question: question);
// ── Answers ───────────────────────────────────────────────────────────────
static CoachMessage _keeping(CoachContext data, DateTime now) {
final List<DateTime> days = _days(now, 7);
final List<double> held = days
.map((day) => ProgressEngine.held(_on(data.runs, day)).toDouble())
.toList();
final List<double> targets =
days.map((day) => _targetOn(data, day)).toList();
int missed = 0;
int best = -1;
double bestGap = 0;
for (int index = 0; index < 6; index++) {
if (targets[index] > 0 && held[index] < targets[index]) {
missed = missed + 1;
}
final double gap = held[index] - targets[index];
if (gap > bestGap) {
bestGap = gap;
best = index;
}
}
final List<({RoutineChain routine, int held, int due})> rates =
_rates(data, now, 14);
final weakest = rates.isEmpty ? null : rates.first;
String body;
if (missed == 0) {
body = "Yes. You met your target on **every one of the last 6 days**.";
} else {
body =
"Not quite. You missed your target on **$missed of the last 6 days**";
body = weakest == null
? "$body."
: "$body, mostly because **${weakest.routine.title}** held just "
"${weakest.held} of ${weakest.due} times in two weeks.";
}
final RoutineChain? due = _dueNow(data, now);
final double todayGap = targets[6] - held[6];
String followUp;
if (todayGap > 0) {
followUp = "Today needs **${todayGap.ceil()} more** to hit target.";
if (due != null) {
final int minuteNow = now.hour * 60 + now.minute;
followUp = due.startMinute < 0
? "$followUp ${due.title} is still to do."
: due.startMinute < minuteNow
? "$followUp ${due.title} was due at ${formatStartMinute(due.startMinute)} and is still open."
: "$followUp ${due.title} is next, at ${formatStartMinute(due.startMinute)}.";
}
} else {
followUp = "Today's target is already met.";
}
return CoachMessage(
mine: false,
body: body,
chart: CoachChart(
title: data.targets.set
? "Held per day · target ${data.targets.routinesPerWeek} a week"
: "Held per day · target is your schedule",
labels: days.map((day) => _label(day, now)).toList(),
values: held,
targets: targets,
highlight: best,
callout: best < 0 ? "" : "+${bestGap.round()}",
lastComplete: 5,
),
followUp: followUp,
actions: <CoachAction>[
if (due != null && todayGap > 0) _start(due),
if (!data.targets.set)
CoachAction(
label: "Set a target",
kind: CoachActionKind.OpenTargets,
primary: due == null || todayGap <= 0,
),
_ask(askSlip),
],
);
}
static CoachMessage _slip(CoachContext data, DateTime now) {
final List<({RoutineChain routine, int held, int due})> rates =
_rates(data, now, 28);
if (rates.isEmpty) {
return CoachMessage(
mine: false,
body:
"Nothing to go on yet: **no scheduled routines** in the last four weeks.",
followUp: "Give a routine some days of the week and this fills in.",
);
}
final weakest = rates.first;
final RoutineChain routine = weakest.routine;
// Held and scheduled per weekday over the four weeks.
final List<double> heldBy = List<double>.filled(7, 0);
final List<double> dueBy = List<double>.filled(7, 0);
for (DateTime day in _days(now, 29).take(28)) {
if (!routine.scheduledOn(day)) {
continue;
}
dueBy[day.weekday - 1] = dueBy[day.weekday - 1] + 1;
if (_on(data.runs, day)
.any((run) => run.routineId == routine.id && run.held)) {
heldBy[day.weekday - 1] = heldBy[day.weekday - 1] + 1;
}
}
int weakDay = -1;
double weakRate = 2;
for (int index = 0; index < 7; index++) {
if (dueBy[index] == 0) {
continue;
}
final double rate = heldBy[index] / dueBy[index];
if (rate < weakRate) {
weakRate = rate;
weakDay = index;
}
}
final int percent = (weakest.held / weakest.due * 100).round();
final RoutineChain? due = routine.scheduledOn(now) &&
!routine.complete &&
routine.steps.isNotEmpty
? routine
: null;
return CoachMessage(
mine: false,
body: "**${routine.title}** is where it slips: held **${weakest.held} of "
"${weakest.due}** scheduled days in four weeks ($percent%).",
chart: CoachChart(
title: "${routine.title} · held by weekday, last 4 weeks",
labels: _letters,
values: heldBy,
targets: dueBy,
highlight: weakDay,
callout: weakDay < 0
? ""
: "${heldBy[weakDay].round()}/${dueBy[weakDay].round()}",
),
followUp: weakDay < 0
? ""
: "**${weekdayName(weakDay + 1)}** is the weak day. "
"${rates.length > 1 ? 'Your steadiest is ${rates.last.routine.title}, at ${(rates.last.held / rates.last.due * 100).round()}%.' : ''}",
actions: <CoachAction>[
if (due != null) _start(due),
_ask(askKeeping),
],
);
}
static CoachMessage _streak(CoachContext data, DateTime now) {
final int streak = ProgressEngine.streak(data.runs, now);
// Longest run of held days in the log.
final List<DateTime> heldDays = data.runs
.where((run) => run.held)
.map((run) => ProgressEngine.dayOf(run.startedAt))
.toSet()
.toList()
..sort();
int longest = 0;
int current = 0;
DateTime? previous;
for (DateTime day in heldDays) {
current = previous != null && day.difference(previous).inDays == 1
? current + 1
: 1;
if (current > longest) {
longest = current;
}
previous = day;
}
final bool heldToday = ProgressEngine.held(_on(data.runs, now)) > 0;
final RoutineChain? due = _dueNow(data, now);
final List<DateTime> days = _days(now, 7);
return CoachMessage(
mine: false,
body: streak == 0
? "No streak right now. Your longest was **$longest days**."
: "**$streak ${streak == 1 ? 'day' : 'days'}** in a row with a routine held. "
"Your longest is **$longest days**.",
chart: CoachChart(
title: "Routines held per day · a streak needs one",
labels: days.map((day) => _label(day, now)).toList(),
values: days
.map((day) => ProgressEngine.held(_on(data.runs, day)).toDouble())
.toList(),
targets: List<double>.filled(7, 1),
lastComplete: heldToday ? 6 : 5,
),
followUp: heldToday
? "Today already counts."
: "Today doesn't count yet. **One held routine** keeps it going.",
actions: <CoachAction>[
if (!heldToday && due != null) _start(due),
CoachAction(
label: "Open progress",
kind: CoachActionKind.OpenProgress,
primary: heldToday || due == null),
],
);
}
static CoachMessage _today(CoachContext data, DateTime now) {
final List<RoutineChain> left = data.routines
.where((routine) => routine.scheduledOn(now) && !routine.complete)
.toList()
..sort((a, b) => a.startMinute.compareTo(b.startMinute));
final int open =
data.plan.where((item) => item.status == CommitmentStatus.Open).length;
final RoutineChain? due = _dueNow(data, now);
final List<String> parts = <String>[
"**${left.length} ${left.length == 1 ? 'routine' : 'routines'}**",
"**$open ${open == 1 ? 'commitment' : 'commitments'}**",
];
String body = left.isEmpty && open == 0
? "Nothing left today. **Every routine and commitment is done.**"
: "Still to do today: ${parts.join(' and ')}.";
if (data.overdue.isNotEmpty) {
body = "$body Plus **${data.overdue.length} overdue** from before.";
}
return CoachMessage(
mine: false,
body: body,
followUp: left.isEmpty
? ""
: left
.map((routine) =>
"${routine.startMinute >= 0 ? formatStartMinute(routine.startMinute) : 'Any time'} · ${routine.title}")
.join("\n"),
actions: <CoachAction>[
if (due != null) _start(due),
if (data.overdue.isNotEmpty)
CoachAction(
label: "Clear overdue",
kind: CoachActionKind.OpenOverdue,
primary: due == null),
],
);
}
static CoachMessage _active(CoachContext data, DateTime now) {
final List<DateTime> days = _days(now, 7);
final double daily = data.targets.minutesPerWeek / 7;
final List<double> minutes = days
.map((day) => ProgressEngine.minutes(_on(data.runs, day)).toDouble())
.toList();
final int week = ProgressEngine.minutes(ProgressEngine.between(
data.runs, days.first, days.last.add(const Duration(days: 1))));
int under = 0;
for (int index = 0; index < 6; index++) {
if (minutes[index] < daily) {
under = under + 1;
}
}
int best = -1;
double bestGap = 0;
for (int index = 0; index < 7; index++) {
if (minutes[index] - daily > bestGap) {
bestGap = minutes[index] - daily;
best = index;
}
}
return CoachMessage(
mine: false,
body: week >= data.targets.minutesPerWeek
? "Yes: **${formatMinutes(week)}** in the last 7 days, over your "
"${formatMinutes(data.targets.minutesPerWeek)} target."
: "**${formatMinutes(week)}** in the last 7 days, short of "
"${formatMinutes(data.targets.minutesPerWeek)}. You were under on "
"**$under of the last 6 days**.",
chart: CoachChart(
title: "Minutes per day · target ${formatMinutes(daily.round())}",
labels: days.map((day) => _label(day, now)).toList(),
values: minutes,
targets: List<double>.filled(7, daily),
highlight: best,
callout: best < 0 ? "" : "+${bestGap.round()}m",
lastComplete: 5,
),
followUp: "Partial runs count toward time even when the chain broke.",
actions: <CoachAction>[
CoachAction(
label: "Change target",
kind: CoachActionKind.OpenTargets,
primary: true),
_ask(askKeeping),
],
);
}
static CoachMessage _debt(CoachContext data, DateTime now) {
if (data.overdue.isEmpty) {
return CoachMessage(
mine: false,
body:
"Debt is **${formatDebt(data.debtScore)}** and **nothing is overdue**. "
"What's left is decaying on its own.",
actions: <CoachAction>[_ask(askToday)],
);
}
final List<Commitment> sorted = List<Commitment>.from(data.overdue)
..sort((a, b) => b.deferralCount.compareTo(a.deferralCount));
final Commitment worst = sorted.first;
return CoachMessage(
mine: false,
body:
"Debt is **${formatDebt(data.debtScore)}**, from **${data.overdue.length} "
"overdue** ${data.overdue.length == 1 ? 'item' : 'items'}.",
followUp: worst.deferralCount > 0
? "**${worst.title}** carries the most: deferred ${_times(worst.deferralCount)} "
"and ${worst.daysOverdue} ${worst.daysOverdue == 1 ? 'day' : 'days'} past its window."
: "**${worst.title}** is ${worst.daysOverdue} ${worst.daysOverdue == 1 ? 'day' : 'days'} past its window.",
actions: <CoachAction>[
CoachAction(
label: "Clear overdue",
kind: CoachActionKind.OpenOverdue,
primary: true),
_ask(askToday),
],
);
}
static CoachMessage _unknown() {
return CoachMessage(
mine: false,
body: "I only answer from **what's logged here**: routines, streaks, "
"active time, today's plan and debt. Try one of these.",
actions: suggestions.take(4).map(_ask).toList(),
);
}
}