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:
alvocool
2026-11-02 08:45:41 +03:00
parent c88887b639
commit 1a5867a98f
49 changed files with 5384 additions and 220 deletions

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