Add routines module and full goal management
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
This commit is contained in:
787
frontend/lib/Grounded/see/routine/RoutinesState.dart
Normal file
787
frontend/lib/Grounded/see/routine/RoutinesState.dart
Normal file
@@ -0,0 +1,787 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user