import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../about/external/data/RoutineChain.dart'; import '../../about/external/data/RoutineStep.dart'; import '../../about/internal/application/CommitmentClass.dart'; import '../../about/internal/application/RoutineKind.dart'; import '../../about/internal/application/TextType.dart'; import '../../configs/Navigator.dart'; import '../../designs/AmbientBackground.dart'; import '../../designs/Component.dart'; import '../../designs/GlassSurface.dart'; import '../../designs/ProgressRing.dart'; import '../../designs/Responsive.dart'; import '../../designs/Shell.dart'; import '../../designs/buttons/Buttons.dart'; import '../../designs/scene/SceneArt.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/CommonUtils.dart'; import 'ConnectRoutines.dart'; import 'RoutineBuilder.dart'; import 'RoutineRunner.dart'; import 'Routines.dart'; import 'ViewRoutines.dart'; /// The routine library: what is due today up front, everything else below, /// filtered by kind. class RoutinesState extends State implements ConnectRoutines { ViewRoutines? _model; List _routines = []; /// Null shows every kind. RoutineKind? _kind; @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewRoutines(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() { _kind = widget.kind; _model?.loadRoutines(); } // ── Derived ─────────────────────────────────────────────────────────────── List get _filtered { if (_kind == null) { return _routines; } return _routines.where((item) => item.kind == _kind).toList(); } List get _today { final DateTime now = DateTime.now(); return _filtered.where((item) => item.scheduledOn(now)).toList(); } List get _rest { final DateTime now = DateTime.now(); return _filtered.where((item) => !item.scheduledOn(now)).toList(); } /// Today's routines that are not finished, soonest first — the featured /// rail. Falls back to the whole library when nothing is due. List get _upNext { final List open = _today.where((item) => !item.complete).toList(); final List source = open.isEmpty ? _filtered : open; return source.take(5).toList(); } int _countFor(RoutineKind? kind) { if (kind == null) { return _routines.length; } return _routines.where((item) => item.kind == kind).length; } // ── Handlers ────────────────────────────────────────────────────────────── void _onBack() { Navigator.pop(context); } void _onSelectKind(RoutineKind? kind) { setState(() { _kind = _kind == kind ? null : kind; }); } void _onBuild() async { final result = await GroundedNavigation() .navigateToPageWithData(RoutineBuilder(kind: _kind), context); if (result == true) { _model?.loadRoutines(); } } void _onEdit(RoutineChain routine) async { final result = await GroundedNavigation() .navigateToPageWithData(RoutineBuilder(routine: routine), context); if (result == true) { _model?.loadRoutines(); } } void _onStart(RoutineChain routine) async { if (routine.steps.isEmpty) { _onEdit(routine); return; } final result = await GroundedNavigation() .navigateToPageWithData(RoutineRunner(routine: routine.copy()), context); if (result == true) { _model?.loadRoutines(); } } void _onPreview(RoutineChain routine) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, barrierColor: colorBlack.withValues(alpha: 0.55), builder: (BuildContext sheetContext) { return _previewSheet(sheetContext, routine); }, ); } // ── Views ───────────────────────────────────────────────────────────────── Widget _mobileView(BoxConstraints constraints) { return Scaffold( backgroundColor: colorAmbientMid, body: AmbientBackground( child: SingleChildScrollView( padding: const EdgeInsets.only(bottom: 40), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ _hero(), Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ if (_routines.isNotEmpty) ...[ _todayStrip(), const SizedBox(height: 26), ], sectionBreak("Category", caption: _kind == null ? "all kinds" : "tap again to clear"), _kindRail(), const SizedBox(height: 22), if (_filtered.isEmpty) emptyState( _kind == null ? CupertinoIcons.square_stack_3d_up : kindIcon(_kind!), _kind == null ? "No routines yet" : "No ${kindLabel(_kind!).toLowerCase()} routines", "A routine is a chain: every step, in order. Break it and it is logged as partial, not quietly forgotten.", ) else ...[ sectionBreak( "Featured", caption: _today.isEmpty ? "from your library" : "up next today", ), _featuredRail(), const SizedBox(height: 28), if (_today.isNotEmpty) ...[ sectionBreak( "Active today", caption: "${_today.where((item) => item.complete).length} of ${_today.length} done", ), ..._today.map(_routineRow), const SizedBox(height: 18), ], if (_rest.isNotEmpty) ...[ sectionBreak("Library", caption: "${_rest.length} more"), ..._rest.map(_routineRow), ], ], const SizedBox(height: 24), roundedCornerButton( "Build a routine", _onBuild, icon: CupertinoIcons.add, ), ], ), ), ], ), ), ), ); } /// The image header: a painted scene for the selected kind, fading into /// the backdrop, with frosted controls and the day's headline on it. Widget _hero() { final RoutineKind shown = _kind ?? RoutineKind.Workout; return SizedBox( height: 330, child: Stack( fit: StackFit.expand, children: [ ShaderMask( shaderCallback: (Rect bounds) => const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF000000), Color(0x00000000)], stops: [0.55, 1.0], ).createShader(bounds), blendMode: BlendMode.dstIn, child: AnimatedSwitcher( duration: const Duration(milliseconds: 450), child: SceneArt( key: ValueKey(shown), palette: scenePalette(shown), seed: 40 + shown.index, figure: kindIcon(shown), figureScale: 0.36, figureAlignment: const Alignment(0.45, -0.1), ), ), ), Positioned( top: 0, left: 0, right: 0, child: SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 0), child: Row( children: [ glassCircle(CupertinoIcons.arrow_left, _onBack), const Spacer(), glassCircle(CupertinoIcons.add, _onBuild), ], ), ), ), ), Positioned( left: 20, right: 20, bottom: 18, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text( "${weekdayName(DateTime.now().weekday)} · ${_today.length} due", 13, TextType.Regular, color: colorInkSoft, ), const SizedBox(height: 6), text(_headline(), 32, TextType.Medium, color: colorInk, height: 1.1, letterSpacing: -0.8), ], ), ), ], ), ); } String _headline() { if (_routines.isEmpty) { return "Build the things\nyou do every time."; } final int open = _today.where((item) => !item.complete).length; if (_today.isEmpty) { return "Nothing scheduled.\nRun one anyway."; } if (open == 0) { return "Every chain\nheld today."; } return "Here's what to\nhold today."; } /// Today's load at a glance, on glass. Widget _todayStrip() { final DateTime now = DateTime.now(); final List due = _routines.where((item) => item.scheduledOn(now)).toList(); int seconds = 0; for (RoutineChain item in due) { seconds = seconds + item.totalSeconds; } final int nowMinute = now.hour * 60 + now.minute; final List ahead = due .where((item) => !item.complete && item.startMinute >= nowMinute) .toList() ..sort((a, b) => a.startMinute.compareTo(b.startMinute)); return GlassSurface( radius: BorderRadius.circular(24), tint: colorGlassStrong, padding: const EdgeInsets.fromLTRB(18, 16, 18, 16), child: Row( children: [ Expanded( child: _stripStat(CupertinoIcons.calendar_today, "${due.length}", "Due today"), ), Expanded( child: _stripStat(CupertinoIcons.clock, formatMinutes(seconds / 60), "Planned"), ), Expanded( child: _stripStat( CupertinoIcons.arrow_right_circle, ahead.isEmpty ? "—" : formatStartMinute(ahead.first.startMinute), "Next"), ), ], ), ); } Widget _stripStat(IconData icon, String value, String label) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text(value, 22, TextType.Light, color: colorInk), const SizedBox(height: 3), Row( children: [ Icon(icon, size: 11, color: colorInkSoft), const SizedBox(width: 4), text(label, 10, TextType.Regular, color: colorInkSoft), ], ), ], ); } /// Round painted portraits — tap to filter, tap again to clear. Widget _kindRail() { final List options = [null, ...RoutineKind.values]; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: options.map(_kindTile).toList(), ); } Widget _kindTile(RoutineKind? kind) { final bool selected = _kind == kind; return GestureDetector( onTap: () => _onSelectKind(kind), behavior: HitTestBehavior.opaque, child: SizedBox( width: 74, child: Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedContainer( duration: const Duration(milliseconds: 200), curve: Curves.easeOut, width: 64, height: 64, padding: const EdgeInsets.all(2.5), decoration: BoxDecoration( shape: BoxShape.circle, color: selected ? colorNight : colorWhite, boxShadow: const [ BoxShadow( color: Color(0x1F2A3A30), blurRadius: 14, offset: Offset(0, 6), ), ], ), child: ClipOval( child: kind == null ? Container( color: colorWhite, alignment: Alignment.center, child: Icon(CupertinoIcons.square_grid_2x2_fill, size: 22, color: colorInk), ) : SceneArt( palette: scenePalette(kind), seed: 3 + kind.index * 7, figure: kindIcon(kind), figureScale: 0.48, figureAlignment: const Alignment(0, 0.25), ), ), ), const SizedBox(height: 8), text( kind == null ? "All" : kindLabel(kind), 11, selected ? TextType.Medium : TextType.Regular, color: selected ? colorInk : colorInkSoft, maxLines: 1, ), const SizedBox(height: 1), text("${_countFor(kind)}", 10, TextType.Regular, color: colorInkSoft), ], ), ), ); } Widget _featuredRail() { final List items = _upNext; return SizedBox( height: 240, child: ListView.separated( scrollDirection: Axis.horizontal, clipBehavior: Clip.none, itemCount: items.length, separatorBuilder: (context, index) => const SizedBox(width: 12), itemBuilder: (context, index) => _featuredCard(items[index]), ), ); } /// The image-led card: the painted scene, a glass chip, the title and three /// light numbers along the bottom. Widget _featuredCard(RoutineChain routine) { final Color label = colorWhite.withValues(alpha: 0.75); final bool workout = routine.kind == RoutineKind.Workout; return GestureDetector( onTap: () => _onPreview(routine), child: Container( width: 200, decoration: BoxDecoration( borderRadius: BorderRadius.circular(26), boxShadow: const [ BoxShadow( color: Color(0x262A3A30), blurRadius: 24, offset: Offset(0, 10), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(26), child: Stack( fit: StackFit.expand, children: [ SceneArt( palette: scenePalette(routine.kind), seed: sceneSeed(routine.title), figure: kindIcon(routine.kind), figureScale: 0.44, figureAlignment: const Alignment(0.1, -0.25), shade: true, ), Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ GlassSurface( onDark: true, radius: BorderRadius.circular(99), padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 5), child: text( routine.commitmentClass == CommitmentClass.NonNegotiable ? "Must hold" : kindLabel(routine.kind), 10, TextType.Medium, color: colorWhite, ), ), const Spacer(), glassCircle(CupertinoIcons.play_fill, () => _onStart(routine), size: 32, onDark: true), ], ), const Spacer(), if (routine.startMinute >= 0) text( "${formatStartMinute(routine.startMinute)} · ${formatDays(routine.days)}", 10, TextType.Regular, color: label, maxLines: 1, ), const SizedBox(height: 3), text(routine.title, 17, TextType.Medium, color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis), const SizedBox(height: 10), Row( children: [ Expanded( child: _cardStat( "${(routine.totalSeconds / 60).round()}", CupertinoIcons.clock, "Min", label), ), Expanded( child: _cardStat("${routine.steps.length}", CupertinoIcons.list_bullet, "Steps", label), ), Expanded( child: _cardStat( workout ? "${routine.totalSets}" : routine.startMinute < 0 ? "Any" : formatStartMinute(routine.startMinute), workout ? CupertinoIcons.repeat : CupertinoIcons.alarm, workout ? "Sets" : "Starts", label), ), ], ), ], ), ), ], ), ), ), ); } Widget _cardStat(String value, IconData icon, String caption, Color tone) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text(value, 20, TextType.Light, color: colorWhite, maxLines: 1), const SizedBox(height: 2), Row( children: [ Icon(icon, size: 10, color: tone), const SizedBox(width: 3), text(caption, 9, TextType.Regular, color: tone), ], ), ], ); } Widget _routineRow(RoutineChain routine) { final Color tone = kindColor(routine.kind); final bool weighted = routine.commitmentClass != CommitmentClass.Standard; final List kicker = [ if (weighted) classLabel(routine.commitmentClass), if (routine.startMinute >= 0) formatStartMinute(routine.startMinute), formatDays(routine.days), ]; return Container( margin: const EdgeInsets.only(bottom: 10), child: card( padding: const EdgeInsets.fromLTRB(8, 8, 12, 8), onTap: () => _onPreview(routine), child: Row( children: [ SizedBox( width: 70, height: 70, child: ClipRRect( borderRadius: BorderRadius.circular(18), child: SceneArt( palette: scenePalette(routine.kind), seed: sceneSeed(routine.title), figure: kindIcon(routine.kind), figureScale: 0.46, figureAlignment: const Alignment(0, 0.1), ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text( kicker.join(" · "), 10, TextType.Medium, color: weighted ? classColor(routine.commitmentClass) : tone, maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 3), text(routine.title, 15, TextType.Medium, color: colorInk, maxLines: 1, overflow: TextOverflow.ellipsis), const SizedBox(height: 6), Row( children: [ _meta(CupertinoIcons.clock, formatMinutes(routine.totalSeconds / 60)), const SizedBox(width: 12), _countMeta(routine), ], ), ], ), ), const SizedBox(width: 10), routine.complete ? Container( width: 44, height: 44, alignment: Alignment.center, decoration: BoxDecoration( color: tone, shape: BoxShape.circle, ), child: Icon(Icons.check_rounded, size: 22, color: colorWhite), ) : ProgressRing( fraction: routine.partialCompletion, fill: tone, track: tone.withValues(alpha: 0.12), ), ], ), ), ); } Widget _countMeta(RoutineChain routine) { switch (routine.kind) { case RoutineKind.Workout: return _meta(CupertinoIcons.repeat, "${routine.totalSets} sets"); case RoutineKind.Meditation: return _meta(CupertinoIcons.circle_grid_hex, "${routine.steps.length} steps"); case RoutineKind.MustDo: return _meta(CupertinoIcons.checkmark_square, "${routine.steps.length} tasks"); } } Widget _meta(IconData icon, String value) { return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 12, color: colorInkSoft), const SizedBox(width: 4), text(value, 11, TextType.Regular, color: colorInkSoft), ], ); } /// The preview: the scene up top, then the whole chain laid out before you /// commit to starting it. Widget _previewSheet(BuildContext sheetContext, RoutineChain routine) { final Color tone = kindColor(routine.kind); return Container( constraints: BoxConstraints( maxHeight: MediaQuery.of(sheetContext).size.height * 0.9, ), decoration: const BoxDecoration( color: colorAmbientMid, borderRadius: BorderRadius.only( topLeft: Radius.circular(32), topRight: Radius.circular(32), ), ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Flexible( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 220, child: Stack( fit: StackFit.expand, children: [ SceneArt( palette: scenePalette(routine.kind), seed: sceneSeed(routine.title), figure: kindIcon(routine.kind), figureScale: 0.5, figureAlignment: const Alignment(0.4, -0.1), shade: true, ), Positioned( top: 10, left: 0, right: 0, child: Center( child: Container( width: 38, height: 4, decoration: BoxDecoration( color: colorWhite.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(999), ), ), ), ), Positioned( left: 24, right: 24, bottom: 18, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ GlassSurface( onDark: true, radius: BorderRadius.circular(99), padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 5), child: text(kindLabel(routine.kind), 10, TextType.Medium, color: colorWhite), ), const SizedBox(width: 6), GlassSurface( onDark: true, radius: BorderRadius.circular(99), padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 5), child: text( classLabel(routine.commitmentClass), 10, TextType.Medium, color: colorWhite), ), ], ), const SizedBox(height: 10), text(routine.title, 28, TextType.Medium, color: colorWhite, height: 1.1, letterSpacing: -0.6), ], ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(24, 18, 24, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (routine.note.isNotEmpty) ...[ text(routine.note, 13, TextType.Regular, color: colorInkSoft, height: 1.5), const SizedBox(height: 16), ], GlassSurface( radius: BorderRadius.circular(22), tint: colorGlassStrong, padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _stripStat( CupertinoIcons.clock, "${(routine.totalSeconds / 60).round()}", "Minutes"), ), Expanded( child: _stripStat(CupertinoIcons.list_bullet, "${routine.steps.length}", "Steps"), ), Expanded( child: _stripStat( CupertinoIcons.alarm, routine.startMinute < 0 ? "Any" : formatStartMinute( routine.startMinute), formatDays(routine.days)), ), ], ), ), const SizedBox(height: 22), sectionBreak("The chain", caption: routine.kind == RoutineKind.Workout ? "${routine.totalSets} sets, in order" : "${routine.steps.length} steps, in order"), ...routine.steps.asMap().entries.map((entry) => _previewStep(entry.key, entry.value, tone)), ], ), ), ], ), ), ), SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(24, 8, 24, 16), child: Row( children: [ Expanded( child: outlinedActionButton("Edit", () { Navigator.pop(sheetContext); _onEdit(routine); }, icon: CupertinoIcons.pencil), ), const SizedBox(width: 10), Expanded( flex: 2, child: roundedCornerButton("Start", () { Navigator.pop(sheetContext); _onStart(routine); }, icon: CupertinoIcons.play_fill, enabled: routine.steps.isNotEmpty), ), ], ), ), ), ], ), ); } Widget _previewStep(int index, RoutineStep step, Color tone) { return Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.fromLTRB(10, 10, 14, 10), decoration: BoxDecoration( color: colorWhite, borderRadius: BorderRadius.circular(18), ), child: Row( children: [ Container( width: 34, height: 34, alignment: Alignment.center, decoration: BoxDecoration( color: tone.withValues(alpha: 0.10), shape: BoxShape.circle, ), child: text("${index + 1}", 12, TextType.Medium, color: tone), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text(step.title, 14, TextType.Medium, color: colorInk), const SizedBox(height: 3), text(stepSummary(step), 11, TextType.Regular, color: colorInkSoft), ], ), ), Icon(stepModeIcon(step.mode), size: 15, color: colorInkSoft), ], ), ); } // ── ConnectRoutines ─────────────────────────────────────────────────────── @override void onRoutinesLoaded(List routines) { setState(() { _routines = routines; }); } }