Files
GroundedHelper/frontend/lib/Grounded/see/goal/GoalDetailState.dart
alvocool 1a5867a98f 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
2026-11-02 08:45:41 +03:00

521 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
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';
import '../../designs/Component.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 '../../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>
implements ConnectGoalDetail {
ViewGoalDetail? _model;
Goal _goal = Goal();
List<Commitment> _tasks = <Commitment>[];
List<RoutineChain> _routines = <RoutineChain>[];
bool _changed = false;
/// Ticked locally, awaiting the server.
final Set<String> _ticked = <String>{};
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGoalDetail>.reactive(
viewModelBuilder: () => ViewGoalDetail(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() {
setState(() {
_goal = widget.goal;
});
_model?.loadTasks(widget.goal);
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, _changed);
}
void _onStartTask(Commitment task) {
_model?.startTask(task);
}
/// Ticking completes the task without opening the runner — the runner is for
/// work you actually want timed.
void _onTick(Commitment task) {
if (task.id == null || _ticked.contains(task.id)) {
return;
}
setState(() {
_ticked.add(task.id!);
});
_model?.tick(task);
}
void _onAddTask() async {
final result = await GroundedNavigation()
.navigateToPageWithData(NewCommitment(goal: _goal), context);
if (result == true) {
_changed = true;
_model?.loadTasks(_goal);
}
}
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) {
final List<Commitment> open = _tasks
.where((task) =>
task.status != CommitmentStatus.Completed &&
task.status != CommitmentStatus.LateCompleted &&
task.status != CommitmentStatus.Abandoned)
.toList();
final List<Commitment> done = _tasks
.where((task) =>
task.status == CommitmentStatus.Completed ||
task.status == CommitmentStatus.LateCompleted)
.toList();
return Sheet(
eyebrow: _goal.category.isEmpty ? "Goal" : _goal.category,
title: _goal.title,
onBack: _onBack,
chrome: _goal.colourHex.isEmpty ? null : goalColor(_goal.colourHex),
action: chromeAction(CupertinoIcons.pencil, _onEditGoal),
banner: _progressBanner(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_goal.description.isNotEmpty) ...[
text(_goal.description, 15, TextType.Regular,
color: colorGrey2, height: 1.6),
const SizedBox(height: 28),
],
sectionBreak("To do", caption: "${open.length} open"),
if (open.isEmpty)
emptyState(
CupertinoIcons.square_list,
"Nothing scheduled",
"Add the actual sessions — Monday shoulders, Wednesday legs — and they start counting.",
)
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}"),
...done.map(_doneRow),
],
const SizedBox(height: 24),
roundedCornerButton("Add a task", _onAddTask,
icon: CupertinoIcons.add),
],
),
);
}
Widget _progressBanner() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text("PROGRESS", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.2),
text("${(_goal.progress * 100).round()}%", 13, TextType.Bold,
color: colorWhite),
],
),
const SizedBox(height: 12),
meter(
_goal.progress,
fill: colorWhite,
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),
),
),
],
),
],
),
);
}
/// A task row leads with the action: the point of opening a goal is to start
/// something, not to admire the list.
Widget _taskRow(Commitment task) {
final Color accent = classColor(task.commitmentClass);
final bool late = task.windowClosed;
return Container(
margin: const EdgeInsets.only(bottom: 10),
child: card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
TickRow(
checked: _ticked.contains(task.id),
title: task.title,
accent: accent,
onTick: () => _onTick(task),
detail: Row(
children: [
text(formatWindow(task), 11, TextType.Regular,
color: colorGrey2),
const SizedBox(width: 9),
Container(
width: 3,
height: 3,
decoration: BoxDecoration(
color: colorGrey, shape: BoxShape.circle),
),
const SizedBox(width: 9),
text(formatMinutes(task.estMinutes), 11, TextType.Regular,
color: colorGrey2),
],
),
trailing: pill(proofLabel(task.proofType), colorGrey2, colorMuted,
textSize: 9),
),
if (late) ...[
const SizedBox(height: 12),
Row(
children: [
pill(overdueLabel(task), colorStandingGrounded,
colorStandingGroundedBg, textSize: 9),
const SizedBox(width: 6),
pill("−${formatDebt(DebtEngine.commitmentDebt(task))}",
colorGrey2, colorMuted, textSize: 9),
],
),
],
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: roundedCornerButton(
"Start",
() => _onStartTask(task),
icon: CupertinoIcons.play_fill,
verticalPadding: 13,
),
),
],
),
),
);
}
Widget _doneRow(Commitment task) {
final bool late = task.status == CommitmentStatus.LateCompleted;
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: card(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: TickRow(
checked: true,
locked: true,
title: task.title,
accent: late ? colorStandingWarned : colorPositive,
trailing: late
? pill("Late", colorStandingWarned, colorStandingWarnedBg,
textSize: 9)
: null,
),
),
);
}
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(() {
_ticked.removeWhere((id) => tasks.every((task) => task.id != id));
_goal = goal;
_tasks = tasks;
});
}
@override
void onTaskTicked(Commitment task, bool late) {
_changed = true;
_model?.loadTasks(_goal);
if (!late) {
return;
}
_model?.showApplicationNotification(
NotificationType.warning,
"Late complete",
"The window had already closed. This is recorded as a late complete — it reduces the debt but does not clear it.",
true,
true,
null,
);
}
@override
void onTickFailed(Commitment task) {
setState(() {
_ticked.remove(task.id);
});
}
@override
void onTaskReady(Commitment task) async {
// The runner takes over the whole screen — a task you are running is the
// thing you are doing, not a row in a list.
final result = await GroundedNavigation().navigateToPageWithData(
LiveTask(commitment: task, goalTitle: _goal.title),
context,
);
if (result == true) {
_changed = true;
_model?.loadTasks(_goal);
}
}
}