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

@@ -109,7 +109,7 @@ status field. That's what makes honest history and excuse analysis possible.
## 6. Design language
See `design.md`. In short: sage-to-cream ambient backdrop, frosted glass
See `design.md`. In short: neutral light-grey backdrop with one orange accent, frosted glass
controls, painted `SceneArt` instead of photos, image-led cards, two-tone
greetings, night buttons with a lime spark, softly rounded corners (never
pill-soft) and a floating round-button nav that switches tabs in place.

View File

@@ -57,9 +57,13 @@ directly above a large dark value. Used everywhere numbers appear.
## Colour
Near-black ink (`colorPrimaryDark #141414`) on warm off-white
(`colorPrimaryLight #EDEEEA`), with a single green accent (`colorPrimary
#2F6F4E`). Enforcement tiers own the rest of the palette:
After the coach reference: near-black ink and buttons (`colorInk`,
`colorNight #1C1C1C`) on a neutral light grey ground (`colorAmbient*`
around `#EAEAEA`), frosted near-white cards (`colorGlassStrong`) with white
edges, and one warm orange accent (`colorAccent #EC6A36`, soft
`colorAccentSoft`). `colorLime` is that orange too: the spark on dark
buttons. Enforcement tiers keep their own colours, so standing still reads
at a glance:
| Standing | Colour |
|---|---|
@@ -94,7 +98,7 @@ square top edge → `sectionHeader` (label + light display title) → content.
## Home
Home opens on a deep forest header block (`colorHeaderTop` → `colorHeaderBottom`):
Home opens on a light header panel (`colorHeaderTop` → `colorHeaderBottom`, text `colorHeaderText`):
greeting and avatar, a Today / Weekly toggle, a seven-day streak panel ending
today (a leaf on every kept day, kept = 80%+), then the numbers. Today shows
minutes held against planned, a ring with + that opens the "Add to today"
@@ -138,3 +142,16 @@ goals and a chat.
active minutes per week and reminders before scheduled routines. Until it
is saved once, Progress measures against the schedule and shows a night
"Set your weekly target" prompt. Targets never touch debt.
## Coach
`see/coach/`: the centre tab. Questions about your own data, answered by
`CoachEngine` from the run log, routines, today's plan, the overdue queue
and the debt score. There is no model and no guessing: six question kinds
(keeping routines, where you slip, streak, what's left today, active time,
debt), matched by keyword; anything else gets the list. An answer is a
frosted card with **bold** figures (`markedText`), an optional bar chart
(solid accent where the target was met, soft accent where short, a dashed
outline for the target, a callout over the standout bar) and buttons that
do something real: start a routine, open overdue, targets or progress, or
ask the follow-up. Messages fade and rise in; bars grow.

View File

@@ -0,0 +1,24 @@
import 'CoachActionKind.dart';
class CoachAction {
String label;
CoachActionKind kind;
/// For [CoachActionKind.StartRoutine].
String routineId;
/// For [CoachActionKind.Ask].
String question;
/// The filled button; the rest are outlined.
bool primary;
CoachAction({
required this.label,
required this.kind,
this.routineId = "",
this.question = "",
this.primary = false,
});
}

View File

@@ -0,0 +1,18 @@
/// What a button under a coach answer does. Every action does something real
/// in the app; the coach never offers a button that only talks.
enum CoachActionKind {
/// Run a routine now.
StartRoutine,
/// Open the overdue queue.
OpenOverdue,
/// Open the Targets & reminders setup.
OpenTargets,
/// Open the progress screen.
OpenProgress,
/// Ask a follow-up question.
Ask,
}

View File

@@ -0,0 +1,31 @@
/// A small bar chart inside a coach answer: one bar per day against a dashed
/// target outline.
class CoachChart {
/// "Target · 3 a day"
String title;
List<String> labels;
List<double> values;
/// Per-bar target; the dashed outline is drawn to this height.
List<double> targets;
/// A bar to call out with [callout] above it, or -1.
int highlight;
String callout;
/// Bars after this index have not happened yet (today and beyond).
int lastComplete;
CoachChart({
required this.title,
required this.labels,
required this.values,
required this.targets,
this.highlight = -1,
this.callout = "",
int? lastComplete,
}) : lastComplete = lastComplete ?? labels.length - 1;
}

View File

@@ -0,0 +1,29 @@
import '../../external/data/Commitment.dart';
import '../../external/data/RoutineChain.dart';
import 'ProgressTargets.dart';
import 'RoutineRunRecord.dart';
/// Everything the coach may answer from. Nothing outside this is used, so
/// every answer is traceable to the user's own data.
class CoachContext {
List<RoutineRunRecord> runs;
List<RoutineChain> routines;
ProgressTargets targets;
List<Commitment> plan;
List<Commitment> overdue;
double debtScore;
CoachContext({
required this.runs,
required this.routines,
required this.targets,
required this.plan,
required this.overdue,
required this.debtScore,
});
}

View File

@@ -0,0 +1,28 @@
import 'CoachAction.dart';
import 'CoachChart.dart';
/// One turn in the coach thread. Answers carry **bold** markup in [body] and
/// [followUp], an optional chart between them, and actions under them.
class CoachMessage {
bool mine;
String body;
CoachChart? chart;
String followUp;
List<CoachAction> actions;
DateTime at;
CoachMessage({
required this.mine,
required this.body,
this.chart,
this.followUp = "",
List<CoachAction>? actions,
DateTime? at,
}) : actions = actions ?? <CoachAction>[],
at = at ?? DateTime.now();
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
/// A dashed rounded-rectangle outline: the target a bar is measured against.
class DashedOutline extends CustomPainter {
final Color color;
final double radius;
const DashedOutline({required this.color, this.radius = 10});
@override
void paint(Canvas canvas, Size size) {
if (size.height <= 0) {
return;
}
final Paint paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.2;
final Path outline = Path()
..addRRect(RRect.fromRectAndRadius(
Offset.zero & size, Radius.circular(radius)));
const double dash = 4;
const double gap = 3;
for (final metric in outline.computeMetrics()) {
double distance = 0;
while (distance < metric.length) {
canvas.drawPath(
metric.extractPath(distance, distance + dash), paint);
distance = distance + dash + gap;
}
}
}
@override
bool shouldRepaint(DashedOutline oldDelegate) =>
oldDelegate.color != color || oldDelegate.radius != radius;
}

View File

@@ -1,14 +1,14 @@
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
import 'GlassSurface.dart';
import 'FloatingNavState.dart';
typedef NavEntry = ({IconData icon, String label, VoidCallback onTap});
/// The floating tab bar: a cluster of round frosted buttons, icons only.
/// The open tab rises out of the row as a solid white disc. It switches tabs
/// in place; nothing here pushes a page.
class FloatingNav extends StatelessWidget {
/// The floating tab bar, after the reference: a row of frosted circles that
/// just overlap, icons only. The open tab is a solid white disc that pops
/// forward over its neighbours; a pressed circle dips. It switches tabs in
/// place — nothing here pushes a page.
class FloatingNav extends StatefulWidget {
final List<NavEntry> entries;
final int selected;
@@ -19,84 +19,6 @@ class FloatingNav extends StatelessWidget {
required this.selected,
});
static const double _size = 58;
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Center(
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(_size)),
boxShadow: [
BoxShadow(
color: Color(0x1F1A1F1C),
blurRadius: 30,
offset: Offset(0, 12),
),
],
),
child: GlassSurface(
radius: const BorderRadius.all(Radius.circular(_size)),
tint: colorGlass,
padding: const EdgeInsets.all(5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int index = 0; index < entries.length; index++) ...[
if (index > 0) const SizedBox(width: 4),
_button(entries[index], index == selected),
],
],
),
),
),
),
),
);
}
Widget _button(NavEntry entry, bool active) {
return Semantics(
button: true,
selected: active,
label: entry.label,
child: Tooltip(
message: entry.label,
child: GestureDetector(
onTap: entry.onTap,
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: _size,
height: _size,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active ? colorWhite : colorWhite.withValues(alpha: 0.35),
border: Border.all(
color: colorWhite.withValues(alpha: active ? 1 : 0.6),
width: 1,
),
boxShadow: active
? const [
BoxShadow(
color: Color(0x261A1F1C),
blurRadius: 16,
offset: Offset(0, 6),
),
]
: const [],
),
child: Icon(entry.icon,
size: 22, color: active ? colorInk : colorInkSoft),
),
),
),
);
}
State<FloatingNav> createState() => FloatingNavState();
}

View File

@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
import 'FloatingNav.dart';
import 'GlassSurface.dart';
class FloatingNavState extends State<FloatingNav> {
static const double _size = 60;
/// Neighbours overlap by this much, as in the reference.
static const double _overlap = 6;
int _pressed = -1;
void _onPressDown(int index) {
setState(() {
_pressed = index;
});
}
void _onPressEnd() {
setState(() {
_pressed = -1;
});
}
@override
Widget build(BuildContext context) {
final int count = widget.entries.length;
const double step = _size - _overlap;
// The active disc is painted last so it sits in front of both neighbours.
final List<int> order = <int>[
for (int index = 0; index < count; index++)
if (index != widget.selected) index,
if (widget.selected >= 0 && widget.selected < count) widget.selected,
];
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Center(
child: SizedBox(
width: step * (count - 1) + _size,
height: _size + 8,
child: Stack(
clipBehavior: Clip.none,
children: [
for (int index in order)
AnimatedPositioned(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
left: step * index,
top: index == widget.selected ? 0 : 4,
child: _button(index),
),
],
),
),
),
),
);
}
Widget _button(int index) {
final NavEntry entry = widget.entries[index];
final bool active = index == widget.selected;
final double scale = _pressed == index ? 0.9 : (active ? 1.06 : 1.0);
return Semantics(
button: true,
selected: active,
label: entry.label,
child: GestureDetector(
onTap: entry.onTap,
onTapDown: (_) => _onPressDown(index),
onTapUp: (_) => _onPressEnd(),
onTapCancel: _onPressEnd,
behavior: HitTestBehavior.opaque,
child: AnimatedScale(
scale: scale,
duration: Duration(milliseconds: _pressed == index ? 90 : 260),
curve: active ? Curves.easeOutBack : Curves.easeOutCubic,
child: AnimatedContainer(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
width: _size,
height: _size,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: active
? const [
BoxShadow(
color: Color(0x2E000000),
blurRadius: 20,
offset: Offset(0, 8),
),
]
: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 14,
offset: Offset(0, 6),
),
],
),
child: GlassSurface(
radius: const BorderRadius.all(Radius.circular(_size)),
tint: active ? colorWhite : colorGlass,
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: colorWhite.withValues(alpha: active ? 1 : 0.85),
width: 1.2),
),
child: TweenAnimationBuilder<Color?>(
tween: ColorTween(end: active ? colorInk : colorInkSoft),
duration: const Duration(milliseconds: 260),
builder: (context, colour, child) =>
Icon(entry.icon, size: 23, color: colour),
),
),
),
),
),
),
);
}
}

View File

@@ -37,3 +37,34 @@ Widget text(
),
);
}
/// Copy with **bold** phrases marked inline, for answers that need to stress
/// a number. Regular for the body, Medium for the marked parts.
Widget markedText(
String marked,
double textSize, {
Color? color,
double? height,
}) {
final List<String> parts = marked.split("**");
return Text.rich(
TextSpan(
children: <InlineSpan>[
for (int index = 0; index < parts.length; index++)
TextSpan(
text: parts[index],
style: TextStyle(
fontFamily: getTextType(
index.isOdd ? TextType.Medium : TextType.Regular),
),
),
],
),
style: TextStyle(
color: color ?? colorPrimaryDark,
fontSize: textSize,
height: height,
),
);
}

View File

@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
import 'CoachState.dart';
class Coach extends StatefulWidget {
/// Shown as a tab inside Home: no back arrow, and room at the bottom
/// for the nav.
final bool tab;
const Coach({super.key, this.tab = false});
@override
State<Coach> createState() => CoachState();
}

View File

@@ -0,0 +1,562 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:stacked/stacked.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/CoachMessage.dart';
import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart';
import '../../designs/DashedOutline.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/Responsive.dart';
import '../../designs/AmbientBackground.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/CoachEngine.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../overdue/OverdueQueue.dart';
import '../progress/Progress.dart';
import '../progress/TargetSetup.dart';
import '../routine/RoutineRunner.dart';
import 'Coach.dart';
import 'ConnectCoach.dart';
import 'ViewCoach.dart';
class CoachState extends State<Coach> implements ConnectCoach {
ViewCoach? _model;
final List<CoachMessage> _thread = <CoachMessage>[];
final TextEditingController _input = TextEditingController();
final FocusNode _focus = FocusNode();
final ScrollController _scroll = ScrollController();
bool _thinking = false;
static const double _barHeight = 96;
@override
void dispose() {
_input.dispose();
_focus.dispose();
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewCoach>.reactive(
viewModelBuilder: () => ViewCoach(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context);
}
void _onNewChat() {
setState(() {
_thread.clear();
_thinking = false;
});
}
void _onSend() {
_send(_input.text);
}
void _onSubmitted(String value) {
_send(value);
}
void _send(String question) {
final String trimmed = question.trim();
if (trimmed.isEmpty || _thinking) {
return;
}
_input.clear();
setState(() {
_thread.add(ViewCoach.question(trimmed));
_thinking = true;
});
_toBottom();
_model?.ask(trimmed);
}
void _onAction(CoachAction action) async {
switch (action.kind) {
case CoachActionKind.Ask:
_send(action.question);
return;
case CoachActionKind.OpenOverdue:
await GroundedNavigation()
.navigateToPageWithData(const OverdueQueue(), context);
return;
case CoachActionKind.OpenTargets:
await GroundedNavigation()
.navigateToPageWithData(const TargetSetup(), context);
return;
case CoachActionKind.OpenProgress:
await GroundedNavigation()
.navigateToPageWithData(const Progress(), context);
return;
case CoachActionKind.StartRoutine:
final RoutineChain? routine =
await _model?.findRoutine(action.routineId);
if (routine == null || !mounted) {
return;
}
await GroundedNavigation().navigateToPageWithData(
RoutineRunner(routine: routine.copy()), context);
return;
}
}
void _toBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scroll.hasClients) {
return;
}
_scroll.animateTo(
_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 350),
curve: Curves.easeOutCubic,
);
});
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
return Scaffold(
backgroundColor: colorAmbientMid,
resizeToAvoidBottomInset: true,
body: AmbientBackground(
child: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(),
Expanded(
child: _thread.isEmpty ? _intro() : _threadView(),
),
Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, widget.tab ? 104 : 20),
child: _composer(),
),
],
),
),
),
);
}
Widget _header() {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 16, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!widget.tab) ...[
glassCircle(CupertinoIcons.arrow_left, _onBack, size: 46),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("Coach", 32, TextType.Medium,
color: colorInk, letterSpacing: -0.6),
const SizedBox(height: 2),
text("Answers from your own data", 15, TextType.Regular,
color: colorInkSoft),
],
),
),
Tooltip(
message: "New chat",
child: glassCircle(
CupertinoIcons.arrow_counterclockwise, _onNewChat,
size: 50),
),
],
),
);
}
/// Before the first question: what the coach can answer, as one-tap asks.
Widget _intro() {
return ListView(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
children: [
text(
"Ask about your routines, streak, time or debt. Every answer is worked out from what you've logged — nothing is guessed.",
15,
TextType.Regular,
color: colorInkSoft,
height: 1.5,
),
const SizedBox(height: 22),
for (int index = 0; index < CoachEngine.suggestions.length; index++)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _suggestion(CoachEngine.suggestions[index]),
).animate().fadeIn(duration: 300.ms, delay: (60 * index).ms).slideY(
begin: 0.15, end: 0, duration: 300.ms, delay: (60 * index).ms),
],
);
}
Widget _suggestion(String question) {
return GestureDetector(
onTap: () => _send(question),
child: Container(
padding: const EdgeInsets.fromLTRB(18, 16, 14, 16),
decoration: BoxDecoration(
color: colorGlassStrong,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: colorGlassBorder),
),
child: Row(
children: [
Icon(CupertinoIcons.sparkles, size: 18, color: colorAccent),
const SizedBox(width: 12),
Expanded(
child: text(question, 15, TextType.Medium, color: colorInk),
),
Icon(CupertinoIcons.arrow_up_right, size: 16, color: colorInkSoft),
],
),
),
);
}
Widget _threadView() {
return ListView.builder(
controller: _scroll,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
itemCount: _thread.length + 1 + (_thinking ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0) {
return _stamp(_thread.first.at);
}
if (index - 1 < _thread.length) {
final CoachMessage message = _thread[index - 1];
return (message.mine ? _question(message) : _answer(message))
.animate()
.fadeIn(duration: 320.ms)
.slideY(
begin: 0.12,
end: 0,
duration: 380.ms,
curve: Curves.easeOutCubic);
}
return _typing();
},
);
}
Widget _stamp(DateTime at) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Center(
child: text("Today ${formatStartMinute(at.hour * 60 + at.minute)}", 13,
TextType.Regular,
color: colorInkSoft),
),
);
}
Widget _question(CoachMessage message) {
return Align(
alignment: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(left: 60, bottom: 14),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 13),
decoration: const BoxDecoration(
color: colorNight,
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: text(message.body, 15, TextType.Medium, color: colorWhite),
),
);
}
Widget _answer(CoachMessage message) {
return Container(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
decoration: BoxDecoration(
color: colorGlassStrong,
borderRadius: const BorderRadius.all(Radius.circular(18)),
border: Border.all(color: colorGlassBorder),
boxShadow: const [
BoxShadow(
color: Color(0x0F000000),
blurRadius: 24,
offset: Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
markedText(message.body, 15, color: colorInk, height: 1.5),
if (message.chart != null) ...[
const SizedBox(height: 16),
_chart(message.chart!),
],
if (message.followUp.isNotEmpty) ...[
const SizedBox(height: 16),
markedText(message.followUp, 15, color: colorInk, height: 1.5),
],
if (message.actions.isNotEmpty) ...[
const SizedBox(height: 18),
Wrap(
spacing: 8,
runSpacing: 8,
children: message.actions.map(_actionButton).toList(),
),
],
],
),
);
}
Widget _actionButton(CoachAction action) {
return GestureDetector(
onTap: () => _onAction(action),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
decoration: BoxDecoration(
color: action.primary ? colorAccent : colorWhite,
borderRadius: const BorderRadius.all(Radius.circular(14)),
border: Border.all(color: action.primary ? colorAccent : colorBorder),
),
child: text(action.label, 14, TextType.Medium,
color: action.primary ? colorWhite : colorInk),
),
);
}
/// Bars against dashed target outlines. Days met in solid accent, days
/// short in a soft tint, the day still running as an outline only.
Widget _chart(CoachChart chart) {
double peak = 1;
for (int index = 0; index < chart.values.length; index++) {
peak = [peak, chart.values[index], chart.targets[index]]
.reduce((a, b) => a > b ? a : b);
}
return Container(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.6),
borderRadius: const BorderRadius.all(Radius.circular(14)),
border: Border.all(color: colorGlassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(chart.title, 12, TextType.Regular, color: colorInkSoft),
const SizedBox(height: 12),
SizedBox(
height: _barHeight + 50,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List<Widget>.generate(chart.values.length, (index) {
return Expanded(child: _bar(chart, index, peak));
}),
),
),
],
),
);
}
Widget _bar(CoachChart chart, int index, double peak) {
final double value = chart.values[index];
final double target = chart.targets[index];
final bool running = index > chart.lastComplete;
final bool met = target <= 0 || value >= target;
final double targetHeight = _barHeight * (target / peak);
final double valueHeight = _barHeight * (value / peak);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (index == chart.highlight && chart.callout.isNotEmpty)
Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: colorWhite,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: colorBorder),
),
child: text(chart.callout, 10, TextType.Medium, color: colorInk),
).animate().fadeIn(duration: 300.ms, delay: 500.ms),
SizedBox(
height: (targetHeight > valueHeight ? targetHeight : valueHeight)
.clamp(8, _barHeight),
child: Stack(
alignment: Alignment.bottomCenter,
children: [
if (target > 0)
Positioned.fill(
top: null,
child: SizedBox(
height: targetHeight.clamp(8, _barHeight),
child: CustomPaint(
painter: DashedOutline(
color: colorAccent.withValues(alpha: 0.55)),
),
),
),
if (!running)
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: valueHeight),
duration: Duration(milliseconds: 600 + index * 60),
curve: Curves.easeOutCubic,
builder: (context, height, child) => Container(
height: height,
decoration: BoxDecoration(
color: met ? colorAccent : colorAccentSoft,
borderRadius:
const BorderRadius.all(Radius.circular(10)),
),
),
),
],
),
),
const SizedBox(height: 8),
text(chart.labels[index], 11, TextType.Regular,
color: colorInkSoft, maxLines: 1),
],
),
);
}
Widget _typing() {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
decoration: BoxDecoration(
color: colorGlassStrong,
borderRadius: const BorderRadius.all(Radius.circular(18)),
border: Border.all(color: colorGlassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List<Widget>.generate(3, (index) {
return Container(
width: 7,
height: 7,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: const BoxDecoration(
color: colorInkSoft,
shape: BoxShape.circle,
),
)
.animate(onPlay: (controller) => controller.repeat())
.fadeOut(duration: 450.ms, delay: (150 * index).ms)
.then()
.fadeIn(duration: 450.ms);
}),
),
),
);
}
/// The ask bar, as in the reference: a frosted field with the send button
/// as a solid accent disc.
Widget _composer() {
return Container(
padding: const EdgeInsets.fromLTRB(18, 6, 6, 6),
decoration: BoxDecoration(
color: colorGlassStrong,
borderRadius: const BorderRadius.all(Radius.circular(20)),
border: Border.all(color: colorGlassBorder),
boxShadow: const [
BoxShadow(
color: Color(0x0F000000),
blurRadius: 20,
offset: Offset(0, 6),
),
],
),
child: Row(
children: [
Expanded(
child: quickAddField(
_input,
"Ask about routines, streaks, debt",
_onSubmitted,
focusNode: _focus,
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _onSend,
child: Container(
width: 50,
height: 50,
decoration: const BoxDecoration(
color: colorAccent,
shape: BoxShape.circle,
),
child: Icon(CupertinoIcons.arrow_up, size: 22, color: colorWhite),
),
),
],
),
);
}
// ── ConnectCoach ──────────────────────────────────────────────────────────
@override
void onAnswered(CoachMessage answer) {
if (!mounted) {
return;
}
setState(() {
_thinking = false;
_thread.add(answer);
});
_toBottom();
}
}

View File

@@ -0,0 +1,5 @@
import '../../about/internal/application/CoachMessage.dart';
abstract class ConnectCoach {
void onAnswered(CoachMessage answer);
}

View File

@@ -0,0 +1,107 @@
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/RoutineChain.dart';
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
import '../../about/external/data/pages/request/HistoryRequest.dart';
import '../../about/external/data/pages/request/PageAndSort.dart';
import '../../about/external/data/pages/request/Pageable.dart';
import '../../about/external/data/pages/request/Sort.dart';
import '../../about/external/data/pages/response/CommitmentPage.dart';
import '../../about/internal/application/CoachContext.dart';
import '../../about/internal/application/CoachMessage.dart';
import '../../utils/CoachEngine.dart';
import '../../utils/ObjectConvertors.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectCoach.dart';
class ViewCoach extends ParentViewModel {
ConnectCoach connection;
ViewCoach(super.context, this.connection);
/// The coach reads fresh data for every question, so an answer never
/// lags behind a routine just logged. Network parts fail quietly: the run
/// log on the device still answers most questions.
void ask(String question) async {
final CoachContext data = await _gather();
// A short beat so the answer reads as considered, not instant.
await Future<void>.delayed(const Duration(milliseconds: 700));
connection.onAnswered(CoachEngine.answer(question, data, DateTime.now()));
}
Future<CoachContext> _gather() async {
final PageAndSort page = PageAndSort(
sort: Sort('asc', 'startMinute'),
page: Pageable(0, 0, 50, 0),
);
List<RoutineChain> routines = <RoutineChain>[];
try {
final response =
await getDataManager().getMyRoutines(HistoryRequest(query: page));
routines = getRoutineChainList(response.data);
} catch (e) {
routines = <RoutineChain>[];
}
List<Commitment> plan = <Commitment>[];
try {
final response = await getDataManager().getTodayPlan(CommitmentsRequest(
query: PageAndSort(
sort: Sort('asc', 'dueStart'),
page: Pageable(0, 0, 50, 0),
),
));
plan = CommitmentPage.fromJson(response.data).content;
} catch (e) {
plan = <Commitment>[];
}
List<Commitment> overdue = <Commitment>[];
try {
final response = await getDataManager().getOverdueQueue(HistoryRequest(
query: PageAndSort(
sort: Sort('desc', 'dueEnd'),
page: Pageable(0, 0, 50, 0),
),
));
overdue = CommitmentPage.fromJson(response.data).content;
} catch (e) {
overdue = <Commitment>[];
}
return CoachContext(
runs: await getDataManager().getRunLog(),
routines: routines,
targets: await getDataManager().getProgressTargets(),
plan: plan,
overdue: overdue,
debtScore: await getDataManager().getCachedDebtScore(),
);
}
/// The routine a "Start" button names, fetched fresh so it runs current
/// steps.
Future<RoutineChain?> findRoutine(String id) async {
try {
final response = await getDataManager().getMyRoutines(HistoryRequest(
query: PageAndSort(
sort: Sort('asc', 'startMinute'),
page: Pageable(0, 0, 50, 0),
),
));
for (RoutineChain routine in getRoutineChainList(response.data)) {
if (routine.id == id) {
return routine;
}
}
} catch (e) {
return null;
}
return null;
}
static CoachMessage question(String text) =>
CoachMessage(mine: true, body: text);
}

View File

@@ -41,6 +41,7 @@ import '../../utils/Thresholds.dart';
import '../../utils/ToneEngine.dart';
import '../commitment/NewCommitment.dart';
import '../excuse/ExcuseReport.dart';
import '../coach/Coach.dart';
import '../goal/Goals.dart';
import '../progress/Progress.dart';
import '../group/GroupChat.dart';
@@ -84,6 +85,9 @@ class HomeState extends State<Home> implements ConnectHome {
/// The open tab, and the tabs built so far.
int _tab = 0;
/// Counts tab switches; drives the fade-in of the incoming tab.
int _switches = 0;
final Set<int> _opened = <int>{0};
Standing _standing = Standing.Good;
@@ -192,7 +196,14 @@ class HomeState extends State<Home> implements ConnectHome {
NavigatorType.justOpen, const ReportCardScreen(), context);
}
void _onOpenGoals() => _onNavGoals();
void _onOpenGoals() async {
final result = await GroundedNavigation()
.navigateToPageWithData(const Goals(), context);
if (result == true) {
_model?.loadPlan();
}
}
void _onOpenGroups() => _onNavGroups();
@@ -255,6 +266,7 @@ class HomeState extends State<Home> implements ConnectHome {
setState(() {
_tab = index;
_opened.add(index);
_switches = _switches + 1;
});
// Back on Today: pick up whatever changed in the other tabs.
@@ -268,7 +280,7 @@ class HomeState extends State<Home> implements ConnectHome {
void _onNavRoutines() => _onSelectTab(1);
void _onNavGoals() => _onSelectTab(2);
void _onNavCoach() => _onSelectTab(2);
void _onNavGroups() => _onSelectTab(3);
@@ -412,29 +424,43 @@ class HomeState extends State<Home> implements ConnectHome {
canPop: _tab == 0,
onPopInvokedWithResult: _onBackToToday,
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
value: SystemUiOverlayStyle.dark,
child: Scaffold(
backgroundColor: colorAmbientMid,
body: AmbientBackground(
child: Stack(
children: [
IndexedStack(
index: _tab,
children: <Widget>[
_todayView(queueIsHome),
_opened.contains(1)
? const Routines(tab: true)
: const SizedBox.shrink(),
_opened.contains(2)
? const Goals(tab: true)
: const SizedBox.shrink(),
_opened.contains(3)
? const Groups(tab: true)
: const SizedBox.shrink(),
_opened.contains(4)
? const Progress(tab: true)
: const SizedBox.shrink(),
],
TweenAnimationBuilder<double>(
// Animates from the previous switch count to the new one, so
// the tabs underneath are never rebuilt and keep their state.
tween: Tween<double>(end: _switches.toDouble()),
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
builder: (context, value, child) => Opacity(
opacity: (1 - (_switches - value)).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, 14 * (_switches - value)),
child: child,
),
),
child: IndexedStack(
index: _tab,
children: <Widget>[
_todayView(queueIsHome),
_opened.contains(1)
? const Routines(tab: true)
: const SizedBox.shrink(),
_opened.contains(2)
? const Coach(tab: true)
: const SizedBox.shrink(),
_opened.contains(3)
? const Groups(tab: true)
: const SizedBox.shrink(),
_opened.contains(4)
? const Progress(tab: true)
: const SizedBox.shrink(),
],
),
),
Positioned(
left: 0,
@@ -444,27 +470,27 @@ class HomeState extends State<Home> implements ConnectHome {
selected: _tab,
entries: <NavEntry>[
(
icon: CupertinoIcons.house_fill,
icon: CupertinoIcons.house,
label: "Today",
onTap: _onNavToday
),
(
icon: CupertinoIcons.square_stack_3d_up_fill,
icon: CupertinoIcons.square_stack_3d_up,
label: "Routines",
onTap: _onNavRoutines
),
(
icon: CupertinoIcons.flag_fill,
label: "Goals",
onTap: _onNavGoals
icon: CupertinoIcons.sparkles,
label: "Coach",
onTap: _onNavCoach
),
(
icon: CupertinoIcons.person_3_fill,
icon: CupertinoIcons.person_3,
label: "Groups",
onTap: _onNavGroups
),
(
icon: CupertinoIcons.chart_bar_alt_fill,
icon: CupertinoIcons.chart_pie,
label: "Progress",
onTap: _onNavProgress
),
@@ -578,12 +604,12 @@ class HomeState extends State<Home> implements ConnectHome {
text(_greetingWord(), 14, TextType.Regular,
color: colorHeaderMuted),
const SizedBox(width: 6),
Icon(_greetingIcon(), size: 15, color: colorLime),
Icon(_greetingIcon(), size: 15, color: colorAccent),
],
),
const SizedBox(height: 3),
text(name, 22, TextType.Medium,
color: colorWhite,
color: colorHeaderText,
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
@@ -593,7 +619,7 @@ class HomeState extends State<Home> implements ConnectHome {
CupertinoIcons.bell,
_onOpenOverdue,
size: 46,
onDark: true,
onDark: false,
dotted: _overdue.isNotEmpty,
dotColor: colorBarNonNegotiable,
),
@@ -602,7 +628,7 @@ class HomeState extends State<Home> implements ConnectHome {
CupertinoIcons.person,
_onOpenSettings,
size: 46,
onDark: true,
onDark: false,
dotted: _user.sickMode,
dotColor: colorBarStandard,
),
@@ -643,7 +669,7 @@ class HomeState extends State<Home> implements ConnectHome {
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: text(label, 16, active ? TextType.Medium : TextType.Regular,
color: active ? colorWhite : colorHeaderMuted),
color: active ? colorHeaderText : colorHeaderMuted),
),
);
}
@@ -678,11 +704,11 @@ class HomeState extends State<Home> implements ConnectHome {
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
text("${_streak()}", 28, TextType.Medium, color: colorWhite),
text("${_streak()}", 28, TextType.Medium, color: colorHeaderText),
const SizedBox(width: 8),
text("day streak", 16, TextType.Regular, color: colorHeaderMuted),
const Spacer(),
Icon(Icons.eco, size: 32, color: colorLime),
Icon(Icons.eco, size: 32, color: colorAccent),
],
),
const SizedBox(height: 16),
@@ -709,7 +735,7 @@ class HomeState extends State<Home> implements ConnectHome {
mainAxisSize: MainAxisSize.min,
children: [
text(label, 14, today ? TextType.Medium : TextType.Regular,
color: today ? colorWhite : colorHeaderMuted),
color: today ? colorHeaderText : colorHeaderMuted),
const SizedBox(height: 10),
SizedBox(
height: 26,
@@ -718,7 +744,7 @@ class HomeState extends State<Home> implements ConnectHome {
? Icon(Icons.eco, size: 24, color: colorBarElective)
: text("${date.day}", 16,
today ? TextType.Medium : TextType.Regular,
color: today ? colorWhite : colorHeaderMuted),
color: today ? colorHeaderText : colorHeaderMuted),
),
),
],
@@ -758,7 +784,8 @@ class HomeState extends State<Home> implements ConnectHome {
stroke: 4,
fill: colorBarElective,
track: colorHeaderLine,
child: Icon(CupertinoIcons.add, size: 30, color: colorWhite),
child:
Icon(CupertinoIcons.add, size: 30, color: colorHeaderText),
),
),
],
@@ -854,7 +881,7 @@ class HomeState extends State<Home> implements ConnectHome {
? TextType.Medium
: TextType.Regular,
color: day == now.weekday
? colorWhite
? colorHeaderText
: colorHeaderMuted),
],
),
@@ -883,7 +910,8 @@ class HomeState extends State<Home> implements ConnectHome {
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
text(value, 38, TextType.Medium, color: colorWhite, letterSpacing: -1),
text(value, 38, TextType.Medium,
color: colorHeaderText, letterSpacing: -1),
const SizedBox(width: 4),
Flexible(
child: text(unit, 16, TextType.Regular,
@@ -927,7 +955,7 @@ class HomeState extends State<Home> implements ConnectHome {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
text(grade, 46, TextType.Medium, color: colorWhite, height: 1),
text(grade, 46, TextType.Medium, color: colorHeaderText, height: 1),
const SizedBox(height: 2),
text("Score", 16, TextType.Regular, color: colorHeaderMuted),
],
@@ -960,7 +988,7 @@ class HomeState extends State<Home> implements ConnectHome {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(label, 14, TextType.Medium, color: colorWhite, maxLines: 1),
text(label, 14, TextType.Medium, color: colorHeaderText, maxLines: 1),
const SizedBox(height: 9),
Container(
height: 7,
@@ -983,10 +1011,10 @@ class HomeState extends State<Home> implements ConnectHome {
/// Standing, debt and overdue in one quiet line under the numbers.
Widget _standingLine() {
final Color tone = _standing == Standing.Good
? colorBarElective
? colorStandingGood
: _standing == Standing.Warned
? colorBarStandard
: colorBarNonNegotiable;
? colorStandingWarned
: colorStandingGrounded;
return GestureDetector(
onTap: _overdue.isEmpty ? _onOpenReportCard : _onOpenOverdue,

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