Routines: workout, meditation and must-do routines with timed, rep and tick steps, sets and rest. A library screen, a builder with templates and drag-to-reorder steps, and a full-screen player that auto-advances timed phases, counts backgrounding and logs broken chains as partial. Goals: a goal editor (deadline, colour, category, default class, archive), routines linked to goals, and a goal field on tasks and routines. Tasks added from inside a goal are now actually attached to it via parentId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
788 lines
27 KiB
Dart
788 lines
27 KiB
Dart
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/Component.dart';
|
|
import '../../designs/ProgressRing.dart';
|
|
import '../../designs/Responsive.dart';
|
|
import '../../designs/Shell.dart';
|
|
import '../../designs/buttons/Buttons.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<Routines> implements ConnectRoutines {
|
|
ViewRoutines? _model;
|
|
|
|
List<RoutineChain> _routines = <RoutineChain>[];
|
|
|
|
/// Null shows every kind.
|
|
RoutineKind? _kind;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ViewModelBuilder<ViewRoutines>.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() {
|
|
_model?.loadRoutines();
|
|
}
|
|
|
|
// ── Derived ───────────────────────────────────────────────────────────────
|
|
|
|
List<RoutineChain> get _filtered {
|
|
if (_kind == null) {
|
|
return _routines;
|
|
}
|
|
return _routines.where((item) => item.kind == _kind).toList();
|
|
}
|
|
|
|
List<RoutineChain> get _today {
|
|
final DateTime now = DateTime.now();
|
|
return _filtered.where((item) => item.scheduledOn(now)).toList();
|
|
}
|
|
|
|
List<RoutineChain> 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<RoutineChain> get _upNext {
|
|
final List<RoutineChain> open =
|
|
_today.where((item) => !item.complete).toList();
|
|
final List<RoutineChain> 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 Sheet(
|
|
eyebrow: "${_routines.length} routine${_routines.length == 1 ? "" : "s"}",
|
|
title: "Routines",
|
|
onBack: _onBack,
|
|
action: chromeAction(CupertinoIcons.add, _onBuild),
|
|
banner: _routines.isEmpty ? null : _todayBanner(),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
text(
|
|
"${weekdayName(DateTime.now().weekday).toUpperCase()} · ${_today.length} DUE",
|
|
11,
|
|
TextType.Bold,
|
|
color: colorGrey2,
|
|
letterSpacing: 1.2,
|
|
),
|
|
const SizedBox(height: 8),
|
|
displayTitle(_headline()),
|
|
const SizedBox(height: 24),
|
|
_kindRail(),
|
|
const SizedBox(height: 28),
|
|
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(
|
|
"Up next",
|
|
caption: _today.isEmpty ? "from your library" : "today",
|
|
),
|
|
_featuredRail(),
|
|
const SizedBox(height: 28),
|
|
if (_today.isNotEmpty) ...[
|
|
sectionBreak(
|
|
"Scheduled today",
|
|
caption: "${_today.where((item) => item.complete).length} of ${_today.length} done",
|
|
),
|
|
..._today.map(_routineRow),
|
|
const SizedBox(height: 20),
|
|
],
|
|
if (_rest.isNotEmpty) ...[
|
|
sectionBreak("Library", caption: "${_rest.length} more"),
|
|
..._rest.map(_routineRow),
|
|
],
|
|
],
|
|
const SizedBox(height: 24),
|
|
roundedCornerButton(
|
|
"Build a routine",
|
|
_onBuild,
|
|
icon: CupertinoIcons.add,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _headline() {
|
|
if (_routines.isEmpty) {
|
|
return "Build the things you do every time.";
|
|
}
|
|
final int open = _today.where((item) => !item.complete).length;
|
|
if (_today.isEmpty) {
|
|
return "Nothing scheduled. Run one anyway.";
|
|
}
|
|
if (open == 0) {
|
|
return "Every chain held today.";
|
|
}
|
|
return "Here's what to hold today.";
|
|
}
|
|
|
|
/// The strip in the black chrome: today's load at a glance.
|
|
Widget _todayBanner() {
|
|
final DateTime now = DateTime.now();
|
|
final List<RoutineChain> 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<RoutineChain> ahead = due
|
|
.where((item) => !item.complete && item.startMinute >= nowMinute)
|
|
.toList()
|
|
..sort((a, b) => a.startMinute.compareTo(b.startMinute));
|
|
|
|
final Color label = colorWhite.withValues(alpha: 0.45);
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
|
decoration: BoxDecoration(
|
|
color: colorWhite.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: labelled("Due today", "${due.length}",
|
|
valueSize: 22,
|
|
valueType: TextType.Light,
|
|
valueColor: colorWhite,
|
|
labelColor: label),
|
|
),
|
|
Expanded(
|
|
child: labelled("Planned", formatMinutes(seconds / 60),
|
|
valueSize: 22,
|
|
valueType: TextType.Light,
|
|
valueColor: colorWhite,
|
|
labelColor: label),
|
|
),
|
|
Expanded(
|
|
child: labelled(
|
|
"Next",
|
|
ahead.isEmpty ? "—" : formatStartMinute(ahead.first.startMinute),
|
|
valueSize: 22,
|
|
valueType: TextType.Light,
|
|
valueColor: colorWhite,
|
|
labelColor: label),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Round category tiles — tap to filter, tap again to clear.
|
|
Widget _kindRail() {
|
|
final List<RoutineKind?> options = <RoutineKind?>[null, ...RoutineKind.values];
|
|
|
|
return SizedBox(
|
|
height: 96,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: options.length,
|
|
separatorBuilder: (context, index) => const SizedBox(width: 14),
|
|
itemBuilder: (context, index) => _kindTile(options[index]),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _kindTile(RoutineKind? kind) {
|
|
final bool selected = _kind == kind;
|
|
final Color tone = kind == null ? colorPrimaryDark : kindColor(kind);
|
|
|
|
return GestureDetector(
|
|
onTap: () => _onSelectKind(kind),
|
|
behavior: HitTestBehavior.opaque,
|
|
child: SizedBox(
|
|
width: 70,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOut,
|
|
width: 60,
|
|
height: 60,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: selected
|
|
? LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: <Color>[
|
|
tone,
|
|
kind == null ? colorPrimaryDark2 : kindDeep(kind),
|
|
],
|
|
)
|
|
: null,
|
|
color: selected ? null : colorWhite,
|
|
border: Border.all(
|
|
color: selected ? tone : colorBorder,
|
|
width: selected ? 2 : 1,
|
|
),
|
|
),
|
|
child: Icon(
|
|
kind == null ? CupertinoIcons.square_grid_2x2_fill : kindIcon(kind),
|
|
size: 22,
|
|
color: selected ? colorWhite : tone,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
text(
|
|
kind == null ? "All" : kindLabel(kind),
|
|
11,
|
|
selected ? TextType.Bold : TextType.Regular,
|
|
color: selected ? colorPrimaryDark : colorGrey2,
|
|
maxLines: 1,
|
|
),
|
|
const SizedBox(height: 2),
|
|
text("${_countFor(kind)}", 9, TextType.Bold,
|
|
color: colorGrey, letterSpacing: 0.6),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _featuredRail() {
|
|
final List<RoutineChain> items = _upNext;
|
|
|
|
return SizedBox(
|
|
height: 214,
|
|
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 large gradient card. The kind's icon sits oversized in the corner as
|
|
/// the card's picture — routines have no stock photography.
|
|
Widget _featuredCard(RoutineChain routine) {
|
|
final Color tone = kindColor(routine.kind);
|
|
final Color label = colorWhite.withValues(alpha: 0.55);
|
|
|
|
return GestureDetector(
|
|
onTap: () => _onPreview(routine),
|
|
child: Container(
|
|
width: 248,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(20),
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: <Color>[tone, kindDeep(routine.kind)],
|
|
),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Color(0x08000000),
|
|
blurRadius: 18,
|
|
offset: Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
right: -26,
|
|
bottom: 34,
|
|
child: Icon(
|
|
kindIcon(routine.kind),
|
|
size: 150,
|
|
color: colorWhite.withValues(alpha: 0.08),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
pill(kindLabel(routine.kind), colorWhite,
|
|
colorWhite.withValues(alpha: 0.14),
|
|
textSize: 9),
|
|
if (routine.commitmentClass ==
|
|
CommitmentClass.NonNegotiable) ...[
|
|
const SizedBox(width: 6),
|
|
pill("Must hold", colorWhite,
|
|
colorWhite.withValues(alpha: 0.14),
|
|
textSize: 9),
|
|
],
|
|
const Spacer(),
|
|
Container(
|
|
width: 34,
|
|
height: 34,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: colorWhite,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(CupertinoIcons.play_fill,
|
|
size: 14, color: kindDeep(routine.kind)),
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
if (routine.startMinute >= 0) ...[
|
|
text(
|
|
"${formatStartMinute(routine.startMinute)} · ${formatDays(routine.days)}"
|
|
.toUpperCase(),
|
|
9,
|
|
TextType.Bold,
|
|
color: label,
|
|
letterSpacing: 1.0,
|
|
),
|
|
const SizedBox(height: 6),
|
|
],
|
|
text(routine.title, 21, TextType.Light,
|
|
color: colorWhite,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
height: 1.15),
|
|
const SizedBox(height: 14),
|
|
Container(
|
|
height: 1,
|
|
color: colorWhite.withValues(alpha: 0.12),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: labelled(
|
|
"Minutes",
|
|
"${(routine.totalSeconds / 60).round()}",
|
|
valueSize: 17,
|
|
valueColor: colorWhite,
|
|
labelColor: label,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: labelled(
|
|
"Steps",
|
|
"${routine.steps.length}",
|
|
valueSize: 17,
|
|
valueColor: colorWhite,
|
|
labelColor: label,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: labelled(
|
|
routine.kind == RoutineKind.Workout ? "Sets" : "Starts",
|
|
routine.kind == RoutineKind.Workout
|
|
? "${routine.totalSets}"
|
|
: routine.startMinute < 0
|
|
? "Any"
|
|
: formatStartMinute(routine.startMinute),
|
|
valueSize: 17,
|
|
valueColor: colorWhite,
|
|
labelColor: label,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _routineRow(RoutineChain routine) {
|
|
final Color tone = kindColor(routine.kind);
|
|
final bool weighted = routine.commitmentClass != CommitmentClass.Standard;
|
|
|
|
final List<String> eyebrow = <String>[
|
|
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(10, 10, 14, 10),
|
|
onTap: () => _onPreview(routine),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 58,
|
|
height: 58,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(14),
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: <Color>[kindBackground(routine.kind), colorWhite],
|
|
),
|
|
border: Border.all(color: tone.withValues(alpha: 0.15)),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Icon(kindIcon(routine.kind), size: 24, color: tone),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
text(
|
|
eyebrow.join(" · ").toUpperCase(),
|
|
9,
|
|
TextType.Bold,
|
|
color: weighted
|
|
? classColor(routine.commitmentClass)
|
|
: colorGrey2,
|
|
letterSpacing: 0.9,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 5),
|
|
text(routine.title, 15, TextType.Medium,
|
|
color: colorPrimaryDark,
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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: colorGrey2),
|
|
const SizedBox(width: 4),
|
|
text(value, 11, TextType.Regular, color: colorGrey2),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// The preview: 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.86,
|
|
),
|
|
decoration: const BoxDecoration(
|
|
color: colorSheetBackground,
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: Radius.circular(28),
|
|
topRight: Radius.circular(28),
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 38,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(top: 10),
|
|
decoration: BoxDecoration(
|
|
color: colorGrey.withValues(alpha: 0.5),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
),
|
|
),
|
|
Flexible(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(24, 20, 24, 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
pill(kindLabel(routine.kind), tone,
|
|
kindBackground(routine.kind),
|
|
icon: kindIcon(routine.kind), textSize: 9),
|
|
const SizedBox(width: 6),
|
|
pill(classLabel(routine.commitmentClass),
|
|
classColor(routine.commitmentClass),
|
|
classBackground(routine.commitmentClass),
|
|
textSize: 9),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
displayTitle(routine.title, size: 30),
|
|
if (routine.note.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
text(routine.note, 13, TextType.Regular,
|
|
color: colorGrey2, height: 1.5),
|
|
],
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: statTile("Minutes",
|
|
"${(routine.totalSeconds / 60).round()}")),
|
|
Expanded(
|
|
child: statTile("Steps", "${routine.steps.length}")),
|
|
Expanded(
|
|
child: statTile(
|
|
"When",
|
|
routine.startMinute < 0
|
|
? "Any"
|
|
: formatStartMinute(routine.startMinute),
|
|
caption: formatDays(routine.days),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
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 Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: 28,
|
|
height: 28,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: tone.withValues(alpha: 0.10),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: text("${index + 1}", 11, TextType.Bold, color: tone),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const SizedBox(height: 4),
|
|
text(step.title, 14, TextType.Medium, color: colorPrimaryDark),
|
|
const SizedBox(height: 3),
|
|
text(stepSummary(step), 11, TextType.Regular,
|
|
color: colorGrey2),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 5),
|
|
child: Icon(stepModeIcon(step.mode), size: 15, color: colorGrey),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── ConnectRoutines ───────────────────────────────────────────────────────
|
|
|
|
@override
|
|
void onRoutinesLoaded(List<RoutineChain> routines) {
|
|
setState(() {
|
|
_routines = routines;
|
|
});
|
|
}
|
|
}
|