import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../about/external/data/Program.dart'; import '../../about/external/data/SessionLog.dart'; import '../../about/external/data/SessionTemplate.dart'; import '../../about/internal/application/NotificationType.dart'; import '../../about/internal/application/TextType.dart'; import '../../designs/Component.dart'; import '../../designs/Responsive.dart'; import '../../designs/Shell.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/CommonUtils.dart'; import '../../utils/IntegrityEngine.dart'; import 'ConnectTraining.dart'; import 'Training.dart'; import 'ViewTraining.dart'; class TrainingState extends State implements ConnectTraining { ViewTraining? _model; Program _program = Program(); List _sessions = []; List _history = []; int _contacts = 0; bool _ceilingReached = false; int _recoveryHoursRemaining = 0; @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewTraining(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() { _model?.loadProgram(); } void _onBack() { Navigator.pop(context); } Widget _mobileView(BoxConstraints constraints) { return Sheet( eyebrow: _program.name.isEmpty ? "No program" : _program.name, title: "Training", onBack: _onBack, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ if (_ceilingReached || _recoveryHoursRemaining > 0) ...[ _stopCard(), const SizedBox(height: 24), ], if (_program.id == null) emptyState( CupertinoIcons.flame, "No active program", "Build a program with sessions, progression rules and scheduled deloads, and missed sessions start feeding your debt.", ) else ...[ _programCard(), const SizedBox(height: 28), sectionBreak("Sessions", caption: "${_sessions.length} per cycle"), if (_sessions.isEmpty) text("No sessions defined yet.", 13, TextType.Regular, color: colorGrey2) else ..._sessions.map(_sessionRow), const SizedBox(height: 28), sectionBreak("Volume", caption: "this week"), _volumeCard(), const SizedBox(height: 28), sectionBreak("Recent", caption: "last sessions"), if (_history.isEmpty) text("Nothing logged yet.", 13, TextType.Regular, color: colorGrey2) else ..._history.take(5).map(_historyRow), ], ], ), ); } /// Plyo and recovery are the two places the app refuses rather than nags. Widget _stopCard() { final bool ceiling = _ceilingReached; return card( background: colorStandingLockdownBg, borderColor: colorStandingLockdown.withValues(alpha: 0.25), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Icon(CupertinoIcons.hand_raised_fill, size: 16, color: colorStandingLockdown), const SizedBox(width: 8), text(ceiling ? "CONTACT CEILING" : "RECOVERY", 9, TextType.Bold, color: colorStandingLockdown, letterSpacing: 1.2), ], ), const SizedBox(height: 12), text( ceiling ? "Stop plyometrics this week." : "Not recovered yet.", 22, TextType.Light, color: colorPrimaryDark, ), const SizedBox(height: 10), text( ceiling ? "You are at $_contacts of ${_program.weeklyContactCeiling} ground contacts. Connective tissue does not recover on a motivation schedule — this is the one place the app stops you." : "$_recoveryHoursRemaining hours left before the next hard lower-body session. Training through this is not discipline, it is a shortcut to an injury.", 13, TextType.Regular, color: colorGrey2, height: 1.55, ), ], ), ); } Widget _programCard() { return card( background: colorPrimaryDark, borderColor: colorPrimaryDark, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text("ACTIVE PROGRAM", 9, TextType.Bold, color: colorWhite.withValues(alpha: 0.45), letterSpacing: 1.2), const SizedBox(height: 10), text(_program.name, 24, TextType.Light, color: colorWhite), const SizedBox(height: 20), Row( children: [ Expanded( child: labelled( "Weeks", "${_program.weeks}", valueSize: 18, valueColor: colorWhite, labelColor: colorWhite.withValues(alpha: 0.45), ), ), Expanded( child: labelled( "Per week", "${_program.sessionsPerWeek}", valueSize: 18, valueColor: colorWhite, labelColor: colorWhite.withValues(alpha: 0.45), ), ), Expanded( child: labelled( "Deloads", "${_program.deloadWeeks.length}", valueSize: 18, valueColor: colorWhite, labelColor: colorWhite.withValues(alpha: 0.45), ), ), ], ), ], ), ); } Widget _sessionRow(SessionTemplate session) { return Container( margin: const EdgeInsets.only(bottom: 10), child: card( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text(session.name, 15, TextType.Medium, color: colorPrimaryDark), const SizedBox(height: 6), text("${session.prescriptions.length} exercises", 11, TextType.Regular, color: colorGrey2), ], ), ), if (session.highIntensityLowerBody) pill("HIGH LOAD", colorStandingWarned, colorStandingWarnedBg, textSize: 9), ], ), ), ); } /// Volume per muscle group, not "you went to the gym". Widget _volumeCard() { final Map volume = IntegrityEngine.weeklySetsByMuscleGroup(_history); if (volume.isEmpty) { return card( child: text("No sets logged this week.", 13, TextType.Regular, color: colorGrey2), ); } return card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: volume.entries.map((entry) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ text(entry.key, 13, TextType.Regular, color: colorPrimaryDark), text("${entry.value} sets", 12, TextType.Bold, color: colorGrey2), ], ), const SizedBox(height: 6), meter(entry.value / 20), ], ), ); }).toList(), ), ); } Widget _historyRow(SessionLog session) { final bool watered = session.integrityScore > 0 && session.integrityScore < 0.7; return Container( margin: const EdgeInsets.only(bottom: 10), child: card( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text(session.templateName, 14, TextType.Medium, color: colorPrimaryDark), const SizedBox(height: 6), text( "${formatDate(session.startedAt)} · ${session.sets.length} sets · RPE ${session.sessionRpe.toStringAsFixed(1)}", 11, TextType.Regular, color: colorGrey2, ), ], ), ), if (session.integrityScore > 0) pill( "${(session.integrityScore * 100).round()}%", watered ? colorStandingGrounded : colorPositive, watered ? colorStandingGroundedBg : colorStandingGoodBg, textSize: 9, ), ], ), ), ); } // ── ConnectTraining ─────────────────────────────────────────────────────── @override void onProgramLoaded(Program program, List sessions) { setState(() { _program = program; _sessions = sessions; }); } @override void onHistoryLoaded(List sessions) { setState(() { _history = sessions; }); } @override void onContactCeilingReached(int contacts, int ceiling) { setState(() { _contacts = contacts; _ceilingReached = true; }); _model?.showApplicationNotification( NotificationType.warning, "Contact ceiling reached", "$contacts of $ceiling ground contacts this week. No more plyometrics until it resets.", true, true, null, ); } @override void onRecoveryBlocked(int hoursRemaining) { setState(() { _recoveryHoursRemaining = hoursRemaining; }); } }