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

View File

@@ -26,16 +26,23 @@ MaterialColor createMaterialColor(Color color) {
// Grounded reads as an institution, not a toy: near-black ink, one hard
// accent, and a warning palette that carries the enforcement tiers.
var colorWhite = const Color(0xFFFFFFFF);
var colorPrimary = const Color(0xFF2F6F4E);
var colorPrimary = const Color(0xFF1C1C1C);
var colorPrimaryDark = const Color(0xFF141414);
var colorPrimaryDark2 = const Color(0xFF232323);
var colorPrimaryLight = const Color(0xFFEDEEEA);
var colorPrimaryLight2 = const Color(0xFFF4F5F2);
var colorPrimaryLight = const Color(0xFFEAEAEA);
var colorPrimaryLight2 = const Color(0xFFF3F3F3);
var colorAccent = const Color(0xFFB5442F);
var colorMilkWhite = const Color(0xFFD9D8CE);
var colorMuted = const Color(0xFFE6EAE6);
var colorSecondary = const Color(0xFFF1F4F1);
/// The one accent, after the reference: a warm orange.
const Color colorAccent = Color(0xFFEC6A36);
/// The accent at rest: bars that fell short, soft fills.
const Color colorAccentSoft = Color(0xFFF4B89C);
/// The faintest accent wash, behind accent text.
const Color colorAccentWash = Color(0xFFFBE4D9);
var colorMilkWhite = const Color(0xFFDADADA);
var colorMuted = const Color(0xFFE2E2E2);
var colorSecondary = const Color(0xFFF1F1F1);
var colorPositive = const Color(0xFF2F6F4E);
var colorNegative = const Color(0xFFB3261E);
@@ -106,22 +113,23 @@ const List<Color> goalAccents = <Color>[
// The light, airy surface everything now sits on: sage mist drifting into
// cream, frosted glass for controls, ink for type and one lime spark for the
// primary transport control.
const Color colorAmbientTop = Color(0xFFD5E3DA);
const Color colorAmbientMid = Color(0xFFEAEFE6);
const Color colorAmbientBottom = Color(0xFFF5F1E6);
const Color colorAmbientGlowSage = Color(0xFFB9D6C4);
const Color colorAmbientGlowWarm = Color(0xFFF3DEC0);
const Color colorInk = Color(0xFF1A1F1C);
const Color colorInkSoft = Color(0xFF8E9690);
const Color colorGlass = Color(0x8CFFFFFF);
const Color colorGlassStrong = Color(0xD9FFFFFF);
const Color colorGlassBorder = Color(0xB3FFFFFF);
const Color colorLime = Color(0xFFB9F25B);
const Color colorNight = Color(0xFF18211C);
const Color colorFrost = Color(0xE6F6F7F2);
const Color colorAmbientTop = Color(0xFFE6E6E6);
const Color colorAmbientMid = Color(0xFFEAEAEA);
const Color colorAmbientBottom = Color(0xFFEFEFEF);
const Color colorAmbientGlowSage = Color(0xFFF7F7F7);
const Color colorAmbientGlowWarm = Color(0xFFF4ECE7);
const Color colorInk = Color(0xFF1C1C1C);
const Color colorInkSoft = Color(0xFF8A8A8A);
const Color colorGlass = Color(0x99F7F7F7);
const Color colorGlassStrong = Color(0xE6F4F4F4);
const Color colorGlassBorder = Color(0xE6FFFFFF);
/// The spark on dark buttons and badges; the reference orange.
const Color colorLime = Color(0xFFEC6A36);
const Color colorNight = Color(0xFF1C1C1C);
const Color colorFrost = Color(0xE6F4F4F4);
// Night lit from the top-left: the tile, the insight card, the action button.
const Color colorNightLift = Color(0xFF2B3A31);
const Color colorNightGlow = Color(0xFF3A4A40);
const Color colorNightLift = Color(0xFF2B2B2B);
const Color colorNightGlow = Color(0xFF3A3A3A);
// The overdue callout: a warm blush rather than an alarm panel.
const Color colorOverdueWarm = Color(0xFFF9E1D8);
const Color colorOverdueWarmEnd = Color(0xFFFFF6EF);
@@ -129,17 +137,18 @@ const Color colorOverdueGlow = Color(0xFFD7654B);
// ── Home header ───────────────────────────────────────────────────────────────
// The deep forest block the day sits in, with brighter bars that read on it.
const Color colorHeaderTop = Color(0xFF203D30);
const Color colorHeaderBottom = Color(0xFF15281F);
const Color colorHeaderPanel = Color(0x14FFFFFF);
const Color colorHeaderLine = Color(0x26FFFFFF);
const Color colorHeaderMuted = Color(0x99FFFFFF);
const Color colorHeaderSelected = Color(0xFF3D6B53);
const Color colorBarNonNegotiable = Color(0xFFE8735A);
const Color colorBarStandard = Color(0xFFF2C14E);
const Color colorBarElective = Color(0xFF6FD39B);
const Color colorHeaderTop = Color(0xFFF3F3F3);
const Color colorHeaderBottom = Color(0xFFECECEC);
const Color colorHeaderPanel = Color(0xB3FFFFFF);
const Color colorHeaderLine = Color(0x14000000);
const Color colorHeaderMuted = Color(0xFF8A8A8A);
const Color colorHeaderText = Color(0xFF1C1C1C);
const Color colorHeaderSelected = Color(0xFFFFFFFF);
const Color colorBarNonNegotiable = Color(0xFFEC6A36);
const Color colorBarStandard = Color(0xFFF29A72);
const Color colorBarElective = Color(0xFFF6C3AA);
// The drag handle on white sheets.
const Color colorGrabber = Color(0xFFDDE1DE);
const Color colorGrabber = Color(0xFFD6D6D6);
// ── Scene art ─────────────────────────────────────────────────────────────────
// The painted landscapes that stand in for photography on cards. Each kind has
@@ -164,9 +173,9 @@ const Color colorChartGrid = Color(0x0F000000);
const Color colorChartAxis = Color(0xFF8A8A8A);
// ── Surfaces ──────────────────────────────────────────────────────────────────
const Color colorCard = Color(0xFFFFFFFF);
const Color colorSheetBackground = Color(0xFFF6F5F2);
const Color colorInset = Color(0xFFFAFAF8);
const Color colorCard = Color(0xFFF7F7F7);
const Color colorSheetBackground = Color(0xFFF2F2F2);
const Color colorInset = Color(0xFFF7F7F7);
// ── Dark surfaces ─────────────────────────────────────────────────────────────
const Color colorDarkBg = Color(0xFF080808);