diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index f649f61..a672a45 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -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. diff --git a/frontend/design.md b/frontend/design.md index 61c89c5..e9fd6fe 100644 --- a/frontend/design.md +++ b/frontend/design.md @@ -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. diff --git a/frontend/lib/Grounded/about/internal/application/CoachAction.dart b/frontend/lib/Grounded/about/internal/application/CoachAction.dart new file mode 100644 index 0000000..e31d6ef --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/CoachAction.dart @@ -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, + }); +} diff --git a/frontend/lib/Grounded/about/internal/application/CoachActionKind.dart b/frontend/lib/Grounded/about/internal/application/CoachActionKind.dart new file mode 100644 index 0000000..0ec8d6f --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/CoachActionKind.dart @@ -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, +} diff --git a/frontend/lib/Grounded/about/internal/application/CoachChart.dart b/frontend/lib/Grounded/about/internal/application/CoachChart.dart new file mode 100644 index 0000000..822aa59 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/CoachChart.dart @@ -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 labels; + + List values; + + /// Per-bar target; the dashed outline is drawn to this height. + List 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; +} diff --git a/frontend/lib/Grounded/about/internal/application/CoachContext.dart b/frontend/lib/Grounded/about/internal/application/CoachContext.dart new file mode 100644 index 0000000..1e9224c --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/CoachContext.dart @@ -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 runs; + + List routines; + + ProgressTargets targets; + + List plan; + + List overdue; + + double debtScore; + + CoachContext({ + required this.runs, + required this.routines, + required this.targets, + required this.plan, + required this.overdue, + required this.debtScore, + }); +} diff --git a/frontend/lib/Grounded/about/internal/application/CoachMessage.dart b/frontend/lib/Grounded/about/internal/application/CoachMessage.dart new file mode 100644 index 0000000..2747a5b --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/CoachMessage.dart @@ -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 actions; + + DateTime at; + + CoachMessage({ + required this.mine, + required this.body, + this.chart, + this.followUp = "", + List? actions, + DateTime? at, + }) : actions = actions ?? [], + at = at ?? DateTime.now(); +} diff --git a/frontend/lib/Grounded/designs/DashedOutline.dart b/frontend/lib/Grounded/designs/DashedOutline.dart new file mode 100644 index 0000000..d3c3184 --- /dev/null +++ b/frontend/lib/Grounded/designs/DashedOutline.dart @@ -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; +} diff --git a/frontend/lib/Grounded/designs/FloatingNav.dart b/frontend/lib/Grounded/designs/FloatingNav.dart index 5e47148..df894a7 100644 --- a/frontend/lib/Grounded/designs/FloatingNav.dart +++ b/frontend/lib/Grounded/designs/FloatingNav.dart @@ -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 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 createState() => FloatingNavState(); } diff --git a/frontend/lib/Grounded/designs/FloatingNavState.dart b/frontend/lib/Grounded/designs/FloatingNavState.dart new file mode 100644 index 0000000..59d5605 --- /dev/null +++ b/frontend/lib/Grounded/designs/FloatingNavState.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +import '../utils/Colors.dart'; +import 'FloatingNav.dart'; +import 'GlassSurface.dart'; + +class FloatingNavState extends State { + 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 order = [ + 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( + tween: ColorTween(end: active ? colorInk : colorInkSoft), + duration: const Duration(milliseconds: 260), + builder: (context, colour, child) => + Icon(entry.icon, size: 23, color: colour), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/Grounded/designs/text/Text.dart b/frontend/lib/Grounded/designs/text/Text.dart index 017fd0c..e3720c1 100644 --- a/frontend/lib/Grounded/designs/text/Text.dart +++ b/frontend/lib/Grounded/designs/text/Text.dart @@ -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 parts = marked.split("**"); + + return Text.rich( + TextSpan( + children: [ + 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, + ), + ); +} diff --git a/frontend/lib/Grounded/see/coach/Coach.dart b/frontend/lib/Grounded/see/coach/Coach.dart new file mode 100644 index 0000000..34211a2 --- /dev/null +++ b/frontend/lib/Grounded/see/coach/Coach.dart @@ -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 createState() => CoachState(); +} diff --git a/frontend/lib/Grounded/see/coach/CoachState.dart b/frontend/lib/Grounded/see/coach/CoachState.dart new file mode 100644 index 0000000..543585a --- /dev/null +++ b/frontend/lib/Grounded/see/coach/CoachState.dart @@ -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 implements ConnectCoach { + ViewCoach? _model; + + final List _thread = []; + + 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.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.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( + tween: Tween(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.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(); + } +} diff --git a/frontend/lib/Grounded/see/coach/ConnectCoach.dart b/frontend/lib/Grounded/see/coach/ConnectCoach.dart new file mode 100644 index 0000000..c58c90a --- /dev/null +++ b/frontend/lib/Grounded/see/coach/ConnectCoach.dart @@ -0,0 +1,5 @@ +import '../../about/internal/application/CoachMessage.dart'; + +abstract class ConnectCoach { + void onAnswered(CoachMessage answer); +} diff --git a/frontend/lib/Grounded/see/coach/ViewCoach.dart b/frontend/lib/Grounded/see/coach/ViewCoach.dart new file mode 100644 index 0000000..ba8b7e9 --- /dev/null +++ b/frontend/lib/Grounded/see/coach/ViewCoach.dart @@ -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.delayed(const Duration(milliseconds: 700)); + + connection.onAnswered(CoachEngine.answer(question, data, DateTime.now())); + } + + Future _gather() async { + final PageAndSort page = PageAndSort( + sort: Sort('asc', 'startMinute'), + page: Pageable(0, 0, 50, 0), + ); + + List routines = []; + try { + final response = + await getDataManager().getMyRoutines(HistoryRequest(query: page)); + routines = getRoutineChainList(response.data); + } catch (e) { + routines = []; + } + + List plan = []; + 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 = []; + } + + List overdue = []; + 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 = []; + } + + 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 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); +} diff --git a/frontend/lib/Grounded/see/home/HomeState.dart b/frontend/lib/Grounded/see/home/HomeState.dart index ea0493f..beb96ee 100644 --- a/frontend/lib/Grounded/see/home/HomeState.dart +++ b/frontend/lib/Grounded/see/home/HomeState.dart @@ -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 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 _opened = {0}; Standing _standing = Standing.Good; @@ -192,7 +196,14 @@ class HomeState extends State 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 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 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 implements ConnectHome { canPop: _tab == 0, onPopInvokedWithResult: _onBackToToday, child: AnnotatedRegion( - value: SystemUiOverlayStyle.light, + value: SystemUiOverlayStyle.dark, child: Scaffold( backgroundColor: colorAmbientMid, body: AmbientBackground( child: Stack( children: [ - IndexedStack( - index: _tab, - children: [ - _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( + // Animates from the previous switch count to the new one, so + // the tabs underneath are never rebuilt and keep their state. + tween: Tween(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: [ + _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 implements ConnectHome { selected: _tab, entries: [ ( - 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 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 implements ConnectHome { CupertinoIcons.bell, _onOpenOverdue, size: 46, - onDark: true, + onDark: false, dotted: _overdue.isNotEmpty, dotColor: colorBarNonNegotiable, ), @@ -602,7 +628,7 @@ class HomeState extends State implements ConnectHome { CupertinoIcons.person, _onOpenSettings, size: 46, - onDark: true, + onDark: false, dotted: _user.sickMode, dotColor: colorBarStandard, ), @@ -643,7 +669,7 @@ class HomeState extends State 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 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 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 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 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 implements ConnectHome { ? TextType.Medium : TextType.Regular, color: day == now.weekday - ? colorWhite + ? colorHeaderText : colorHeaderMuted), ], ), @@ -883,7 +910,8 @@ class HomeState extends State 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 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 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 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, diff --git a/frontend/lib/Grounded/utils/CoachEngine.dart b/frontend/lib/Grounded/utils/CoachEngine.dart new file mode 100644 index 0000000..5dfac29 --- /dev/null +++ b/frontend/lib/Grounded/utils/CoachEngine.dart @@ -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 suggestions = [ + askKeeping, + askSlip, + askToday, + askStreak, + askActive, + askDebt, + ]; + + static const List _letters = [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ]; + + static CoachMessage answer(String question, CoachContext data, DateTime now) { + final String q = question.toLowerCase(); + + if (_has(q, ["debt", "overdue", "owe", "standing", "late"])) { + return _debt(data, now); + } + if (_has(q, ["streak", "in a row"])) { + return _streak(data, now); + } + if (_has(q, [ + "slip", + "miss", + "break", + "fail", + "worst", + "weak", + "struggl" + ])) { + return _slip(data, now); + } + if (_has(q, ["today", "left", "next", "now", "should i"])) { + return _today(data, now); + } + if (_has(q, ["time", "minute", "active", "hour", "long"])) { + return _active(data, now); + } + if (_has(q, [ + "keep", + "routine", + "consistent", + "held", + "track", + "enough", + "doing", + "habit" + ])) { + return _keeping(data, now); + } + return _unknown(); + } + + static bool _has(String q, List words) => + words.any((word) => q.contains(word)); + + // ── Shared maths ────────────────────────────────────────────────────────── + + /// The last [count] days, oldest first, ending today. + static List _days(DateTime now, int count) { + final DateTime today = ProgressEngine.dayOf(now); + return List.generate( + count, (index) => today.subtract(Duration(days: count - 1 - index))); + } + + static List _on( + List 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 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 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 days = _days(now, 7); + final List held = days + .map((day) => ProgressEngine.held(_on(data.runs, day)).toDouble()) + .toList(); + final List 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: [ + 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 heldBy = List.filled(7, 0); + final List dueBy = List.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: [ + 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 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 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.filled(7, 1), + lastComplete: heldToday ? 6 : 5, + ), + followUp: heldToday + ? "Today already counts." + : "Today doesn't count yet. **One held routine** keeps it going.", + actions: [ + 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 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 parts = [ + "**${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: [ + 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 days = _days(now, 7); + final double daily = data.targets.minutesPerWeek / 7; + final List 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.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( + 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: [_ask(askToday)], + ); + } + + final List sorted = List.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( + 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(), + ); + } +} diff --git a/frontend/lib/Grounded/utils/Colors.dart b/frontend/lib/Grounded/utils/Colors.dart index 01e1fce..f1faf40 100644 --- a/frontend/lib/Grounded/utils/Colors.dart +++ b/frontend/lib/Grounded/utils/Colors.dart @@ -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 goalAccents = [ // 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);