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