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:
562
frontend/lib/Grounded/see/coach/CoachState.dart
Normal file
562
frontend/lib/Grounded/see/coach/CoachState.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user