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:
@@ -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(() {
|
||||
|
||||
Reference in New Issue
Block a user