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

@@ -4,9 +4,11 @@ import 'package:stacked/stacked.dart';
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/Goal.dart';
import '../../about/external/data/RoutineChain.dart';
import '../../about/internal/application/CommitmentStatus.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/ProofType.dart';
import '../../about/internal/application/RoutineKind.dart';
import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart';
import '../../designs/Checkbox.dart';
@@ -20,8 +22,11 @@ import '../../utils/CommonUtils.dart';
import '../../utils/DebtEngine.dart';
import '../commitment/NewCommitment.dart';
import '../live/LiveTask.dart';
import '../routine/RoutineBuilder.dart';
import '../routine/RoutineRunner.dart';
import 'ConnectGoalDetail.dart';
import 'GoalDetail.dart';
import 'GoalEditor.dart';
import 'ViewGoalDetail.dart';
class GoalDetailState extends State<GoalDetail>
@@ -32,6 +37,8 @@ class GoalDetailState extends State<GoalDetail>
List<Commitment> _tasks = <Commitment>[];
List<RoutineChain> _routines = <RoutineChain>[];
bool _changed = false;
/// Ticked locally, awaiting the server.
@@ -90,7 +97,7 @@ class GoalDetailState extends State<GoalDetail>
void _onAddTask() async {
final result = await GroundedNavigation()
.navigateToPageWithData(const NewCommitment(), context);
.navigateToPageWithData(NewCommitment(goal: _goal), context);
if (result == true) {
_changed = true;
@@ -98,6 +105,61 @@ class GoalDetailState extends State<GoalDetail>
}
}
void _onAddRoutine() async {
final result = await GroundedNavigation()
.navigateToPageWithData(RoutineBuilder(goal: _goal), context);
if (result == true) {
_model?.loadRoutines(_goal);
}
}
void _onEditRoutine(RoutineChain routine) async {
final result = await GroundedNavigation()
.navigateToPageWithData(
RoutineBuilder(routine: routine, goal: _goal), context);
if (result == true) {
_model?.loadRoutines(_goal);
}
}
void _onStartRoutine(RoutineChain routine) async {
if (routine.steps.isEmpty) {
_onEditRoutine(routine);
return;
}
final result = await GroundedNavigation().navigateToPageWithData(
RoutineRunner(routine: routine.copy()), context);
if (result == true) {
_changed = true;
_model?.loadRoutines(_goal);
}
}
void _onEditGoal() async {
final result = await GroundedNavigation()
.navigateToPageWithData(GoalEditor(goal: _goal), context);
if (!mounted) {
return;
}
if (result == "archived") {
Navigator.pop(context, true);
return;
}
if (result is Goal) {
_changed = true;
setState(() {
_goal = result;
});
}
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
@@ -115,10 +177,11 @@ class GoalDetailState extends State<GoalDetail>
.toList();
return Sheet(
eyebrow: "Goal",
eyebrow: _goal.category.isEmpty ? "Goal" : _goal.category,
title: _goal.title,
onBack: _onBack,
action: chromeAction(CupertinoIcons.add, _onAddTask),
chrome: _goal.colourHex.isEmpty ? null : goalColor(_goal.colourHex),
action: chromeAction(CupertinoIcons.pencil, _onEditGoal),
banner: _progressBanner(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -138,6 +201,26 @@ class GoalDetailState extends State<GoalDetail>
)
else
...open.map(_taskRow),
const SizedBox(height: 28),
sectionBreak(
"Routines",
caption: _routines.isEmpty
? "none yet"
: "${_routines.length} serving this goal",
),
if (_routines.isEmpty)
text(
"Routines are the repeatable part of a goal — the Monday session, the morning sit. Add one and it runs from here.",
13,
TextType.Regular,
color: colorGrey2,
height: 1.5,
)
else
..._routines.map(_routineRow),
const SizedBox(height: 12),
outlinedActionButton("Add a routine", _onAddRoutine,
icon: CupertinoIcons.square_stack_3d_up),
if (done.isNotEmpty) ...[
const SizedBox(height: 28),
sectionBreak("Done", caption: "${done.length}"),
@@ -179,6 +262,40 @@ class GoalDetailState extends State<GoalDetail>
track: colorWhite.withValues(alpha: 0.14),
height: 5,
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: labelled(
"Tasks",
"${_goal.completedTasks}/${_goal.totalTasks}",
valueSize: 15,
valueColor: colorWhite,
labelColor: colorWhite.withValues(alpha: 0.45),
),
),
Expanded(
child: labelled(
"Routines",
"${_routines.length}",
valueSize: 15,
valueColor: colorWhite,
labelColor: colorWhite.withValues(alpha: 0.45),
),
),
Expanded(
child: labelled(
"Deadline",
_goal.targetDate == null
? "None"
: targetLabel(_goal.targetDate),
valueSize: 15,
valueColor: colorWhite,
labelColor: colorWhite.withValues(alpha: 0.45),
),
),
],
),
],
),
);
@@ -235,11 +352,14 @@ class GoalDetailState extends State<GoalDetail>
),
],
const SizedBox(height: 14),
roundedCornerButton(
"Start",
() => _onStartTask(task),
icon: CupertinoIcons.play_fill,
verticalPadding: 13,
SizedBox(
width: double.infinity,
child: roundedCornerButton(
"Start",
() => _onStartTask(task),
icon: CupertinoIcons.play_fill,
verticalPadding: 13,
),
),
],
),
@@ -268,8 +388,86 @@ class GoalDetailState extends State<GoalDetail>
);
}
Widget _routineRow(RoutineChain routine) {
final Color tone = kindColor(routine.kind);
return Container(
margin: const EdgeInsets.only(bottom: 10),
child: card(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
onTap: () => _onEditRoutine(routine),
child: Row(
children: [
Container(
width: 48,
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: kindBackground(routine.kind),
borderRadius: BorderRadius.circular(13),
),
child: Icon(kindIcon(routine.kind), size: 21, color: tone),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(routine.title, 14, TextType.Medium,
color: colorPrimaryDark,
maxLines: 1,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
text(
[
kindLabel(routine.kind),
formatMinutes(routine.totalSeconds / 60),
if (routine.startMinute >= 0)
formatStartMinute(routine.startMinute),
formatDays(routine.days),
].join(" · "),
11,
TextType.Regular,
color: colorGrey2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () => _onStartRoutine(routine),
child: Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: routine.kind == RoutineKind.MustDo
? colorPrimaryDark
: tone,
shape: BoxShape.circle,
),
child: Icon(CupertinoIcons.play_fill,
size: 15, color: colorWhite),
),
),
],
),
),
);
}
// ── ConnectGoalDetail ─────────────────────────────────────────────────────
@override
void onRoutinesLoaded(List<RoutineChain> routines) {
setState(() {
_routines = routines;
});
}
@override
void onGoalLoaded(Goal goal, List<Commitment> tasks) {
setState(() {