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
687 lines
21 KiB
Dart
687 lines
21 KiB
Dart
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:stacked/stacked.dart';
|
|
|
|
import '../../about/external/data/Goal.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 '../../designs/Checkbox.dart';
|
|
import '../../designs/Component.dart';
|
|
import '../../designs/GoalPicker.dart';
|
|
import '../../designs/Responsive.dart';
|
|
import '../../designs/Shell.dart';
|
|
import '../../designs/buttons/Buttons.dart';
|
|
import '../../designs/input/InputFields.dart';
|
|
import '../../designs/text/Text.dart';
|
|
import '../../utils/Colors.dart';
|
|
import '../../utils/CommonUtils.dart';
|
|
import '../../utils/RoutineTemplates.dart';
|
|
import '../../utils/Validators.dart';
|
|
import 'ConnectRoutineBuilder.dart';
|
|
import 'RoutineBuilder.dart';
|
|
import 'StepEditor.dart';
|
|
import 'ViewRoutineBuilder.dart';
|
|
|
|
class RoutineBuilderState extends State<RoutineBuilder>
|
|
implements ConnectRoutineBuilder {
|
|
ViewRoutineBuilder? _model;
|
|
|
|
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
|
|
|
final TextEditingController _title = TextEditingController();
|
|
|
|
final TextEditingController _note = TextEditingController();
|
|
|
|
late RoutineChain _routine;
|
|
|
|
String _stepsError = "";
|
|
|
|
/// The goal this routine serves, when its title is known.
|
|
Goal? _goal;
|
|
|
|
bool get _editing {
|
|
return widget.routine != null;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// Edit a copy, so backing out leaves the library exactly as it was.
|
|
_routine = widget.routine?.copy() ??
|
|
RoutineChain(kind: widget.kind ?? RoutineKind.Workout);
|
|
|
|
_title.text = _routine.title;
|
|
_note.text = _routine.note;
|
|
|
|
final Goal? goal = widget.goal;
|
|
if (goal != null && (widget.routine == null || goal.id == _routine.goalId)) {
|
|
_goal = goal;
|
|
_routine.goalId = goal.id;
|
|
if (widget.routine == null) {
|
|
_routine.commitmentClass = goal.defaultClass;
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ViewModelBuilder<ViewRoutineBuilder>.reactive(
|
|
viewModelBuilder: () => ViewRoutineBuilder(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() {}
|
|
|
|
// ── Handlers ──────────────────────────────────────────────────────────────
|
|
|
|
void _onBack() {
|
|
Navigator.pop(context, false);
|
|
}
|
|
|
|
void _onKindSelected(RoutineKind kind) {
|
|
if (kind == _routine.kind) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_routine.kind = kind;
|
|
});
|
|
}
|
|
|
|
void _onTemplateSelected(RoutineChain template) {
|
|
setState(() {
|
|
_routine.title = template.title;
|
|
_routine.commitmentClass = template.commitmentClass;
|
|
_routine.days = List<int>.from(template.days);
|
|
_routine.startMinute = template.startMinute;
|
|
_routine.steps = template.copy().steps;
|
|
_title.text = template.title;
|
|
_stepsError = "";
|
|
});
|
|
}
|
|
|
|
void _onPickGoal() {
|
|
_model?.loadGoals();
|
|
}
|
|
|
|
void _onClassSelected(CommitmentClass value) {
|
|
setState(() {
|
|
_routine.commitmentClass = value;
|
|
});
|
|
}
|
|
|
|
void _onToggleDay(int day) {
|
|
setState(() {
|
|
if (_routine.days.contains(day)) {
|
|
_routine.days.remove(day);
|
|
} else {
|
|
_routine.days.add(day);
|
|
_routine.days.sort();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onWeekdays() {
|
|
setState(() {
|
|
_routine.days = <int>[1, 2, 3, 4, 5];
|
|
});
|
|
}
|
|
|
|
void _onEveryDay() {
|
|
setState(() {
|
|
_routine.days = <int>[1, 2, 3, 4, 5, 6, 7];
|
|
});
|
|
}
|
|
|
|
void _onClearDays() {
|
|
setState(() {
|
|
_routine.days = <int>[];
|
|
});
|
|
}
|
|
|
|
void _onPickStart() async {
|
|
final int current = _routine.startMinute < 0 ? 7 * 60 : _routine.startMinute;
|
|
|
|
final TimeOfDay? time = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay(hour: current ~/ 60, minute: current % 60),
|
|
);
|
|
|
|
if (time == null) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_routine.startMinute = time.hour * 60 + time.minute;
|
|
});
|
|
}
|
|
|
|
void _onAddStep() {
|
|
_openStepEditor(null);
|
|
}
|
|
|
|
void _openStepEditor(int? index) async {
|
|
final dynamic result = await showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
barrierColor: colorBlack.withValues(alpha: 0.55),
|
|
builder: (BuildContext sheetContext) {
|
|
return StepEditor(
|
|
step: index == null ? null : _routine.steps[index],
|
|
kind: _routine.kind,
|
|
);
|
|
},
|
|
);
|
|
|
|
if (result == null || !mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
if (result == false && index != null) {
|
|
_routine.steps.removeAt(index);
|
|
} else if (result is RoutineStep) {
|
|
if (index == null) {
|
|
_routine.steps.add(result);
|
|
} else {
|
|
_routine.steps[index] = result;
|
|
}
|
|
_stepsError = "";
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onDuplicateStep(int index) {
|
|
setState(() {
|
|
_routine.steps.insert(
|
|
index + 1, RoutineStep.fromJson(_routine.steps[index].toJson())..id = null);
|
|
});
|
|
}
|
|
|
|
void _onReorder(int oldIndex, int newIndex) {
|
|
setState(() {
|
|
final RoutineStep moved = _routine.steps.removeAt(oldIndex);
|
|
_routine.steps.insert(newIndex, moved);
|
|
});
|
|
}
|
|
|
|
void _onSave() {
|
|
final bool fieldsValid = _formKey.currentState?.validate() == true;
|
|
|
|
if (_routine.steps.isEmpty) {
|
|
setState(() {
|
|
_stepsError = "A routine needs at least one step.";
|
|
});
|
|
}
|
|
|
|
if (!fieldsValid || _routine.steps.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
_routine.title = _title.text.trim();
|
|
_routine.note = _note.text.trim();
|
|
|
|
_model?.save(_routine);
|
|
}
|
|
|
|
// ── Views ─────────────────────────────────────────────────────────────────
|
|
|
|
Widget _mobileView(BoxConstraints constraints) {
|
|
return Sheet(
|
|
eyebrow: kindLabel(_routine.kind),
|
|
title: _editing ? "Edit routine" : "New routine",
|
|
onBack: _onBack,
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
displayTitle(_editing ? "Adjust the chain." : "What are you\nbuilding?"),
|
|
const SizedBox(height: 24),
|
|
_kindPicker(),
|
|
if (!_editing) ...[
|
|
const SizedBox(height: 24),
|
|
text("START FROM", 10, TextType.Bold,
|
|
color: colorGrey2, letterSpacing: 0.8),
|
|
const SizedBox(height: 10),
|
|
_templateRail(),
|
|
],
|
|
const SizedBox(height: 28),
|
|
sectionBreak("Details"),
|
|
inputField(
|
|
"Title",
|
|
_title,
|
|
hint: "Morning strength, box breathing…",
|
|
validator: Validators.title,
|
|
),
|
|
const SizedBox(height: 16),
|
|
selectField(
|
|
"Goal",
|
|
_goalLabel(),
|
|
_onPickGoal,
|
|
hint: "No goal — stands on its own",
|
|
icon: CupertinoIcons.flag,
|
|
),
|
|
const SizedBox(height: 16),
|
|
inputField(
|
|
"Note",
|
|
_note,
|
|
hint: "Why this routine exists (optional)",
|
|
maxLines: 2,
|
|
),
|
|
const SizedBox(height: 28),
|
|
sectionBreak("Weight", caption: "decides the debt when it breaks"),
|
|
segmentedSelector<CommitmentClass>(
|
|
options: CommitmentClass.values,
|
|
selected: _routine.commitmentClass,
|
|
label: classLabel,
|
|
onSelected: _onClassSelected,
|
|
activeColor: classColor(_routine.commitmentClass),
|
|
),
|
|
const SizedBox(height: 10),
|
|
text(_classDescription(_routine.commitmentClass), 12,
|
|
TextType.Regular,
|
|
color: colorGrey2, height: 1.5),
|
|
const SizedBox(height: 28),
|
|
sectionBreak("Schedule", caption: formatDays(_routine.days)),
|
|
_dayPicker(),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
textButton("Weekdays", _onWeekdays, textSize: 12),
|
|
textButton("Every day", _onEveryDay, textSize: 12),
|
|
textButton("On demand", _onClearDays, textSize: 12),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
selectField(
|
|
"Starts at",
|
|
formatStartMinute(_routine.startMinute),
|
|
_onPickStart,
|
|
hint: "Pick a time",
|
|
icon: CupertinoIcons.clock,
|
|
),
|
|
const SizedBox(height: 28),
|
|
sectionBreak(
|
|
"Steps",
|
|
caption:
|
|
"${_routine.steps.length} step${_routine.steps.length == 1 ? "" : "s"}",
|
|
trailing: _routine.steps.isEmpty
|
|
? null
|
|
: text("~${formatMinutes(_routine.totalSeconds / 60)}", 12,
|
|
TextType.Bold,
|
|
color: colorGrey2),
|
|
),
|
|
if (_stepsError.isNotEmpty) ...[
|
|
text(_stepsError, 11, TextType.Regular, color: colorNegative),
|
|
const SizedBox(height: 10),
|
|
],
|
|
if (_routine.steps.isNotEmpty)
|
|
text("Hold and drag the handle to reorder.", 11,
|
|
TextType.Regular,
|
|
color: colorGrey),
|
|
if (_routine.steps.isNotEmpty) const SizedBox(height: 10),
|
|
_stepList(),
|
|
addTaskAffordance(_onAddStep, label: "Add step"),
|
|
if (_routine.steps.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
_chainSummary(),
|
|
],
|
|
const SizedBox(height: 24),
|
|
roundedCornerButton(
|
|
_editing ? "Save changes" : "Save routine",
|
|
_onSave,
|
|
icon: CupertinoIcons.checkmark,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// A routine linked from the library may not have its goal's title to
|
|
/// hand until the picker loads the goals.
|
|
String _goalLabel() {
|
|
if (_goal != null) {
|
|
return _goal!.title;
|
|
}
|
|
return _routine.goalId == null ? "" : "Linked to a goal";
|
|
}
|
|
|
|
String _classDescription(CommitmentClass value) {
|
|
switch (value) {
|
|
case CommitmentClass.NonNegotiable:
|
|
return "Breaking this chain carries the heaviest debt and cannot be deferred. Use it for the few routines your day is built on.";
|
|
case CommitmentClass.Standard:
|
|
return "A broken chain is logged as partial and costs normal debt.";
|
|
case CommitmentClass.Elective:
|
|
return "Tracked, never punished. Good for routines you are still trying out.";
|
|
}
|
|
}
|
|
|
|
Widget _kindPicker() {
|
|
return Row(
|
|
children: RoutineKind.values.map((kind) {
|
|
final bool last = kind == RoutineKind.values.last;
|
|
return Expanded(
|
|
child: Padding(
|
|
padding: EdgeInsets.only(right: last ? 0 : 8),
|
|
child: _kindOption(kind),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
Widget _kindOption(RoutineKind kind) {
|
|
final bool selected = _routine.kind == kind;
|
|
final Color tone = kindColor(kind);
|
|
|
|
return GestureDetector(
|
|
onTap: () => _onKindSelected(kind),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 180),
|
|
height: 124,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
gradient: selected
|
|
? LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: <Color>[tone, kindDeep(kind)],
|
|
)
|
|
: null,
|
|
color: selected ? null : colorWhite,
|
|
border: Border.all(color: selected ? tone : colorBorder),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: 34,
|
|
height: 34,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? colorWhite.withValues(alpha: 0.16)
|
|
: kindBackground(kind),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(kindIcon(kind),
|
|
size: 17, color: selected ? colorWhite : tone),
|
|
),
|
|
const Spacer(),
|
|
text(kindLabel(kind), 13, TextType.Bold,
|
|
color: selected ? colorWhite : colorPrimaryDark),
|
|
const SizedBox(height: 3),
|
|
text(kindDescription(kind), 10, TextType.Regular,
|
|
color: selected
|
|
? colorWhite.withValues(alpha: 0.7)
|
|
: colorGrey2,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
height: 1.3),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _templateRail() {
|
|
final List<RoutineChain> templates = RoutineTemplates.forKind(_routine.kind);
|
|
|
|
return SizedBox(
|
|
height: 76,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: templates.length,
|
|
separatorBuilder: (context, index) => const SizedBox(width: 10),
|
|
itemBuilder: (context, index) => _templateChip(templates[index]),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _templateChip(RoutineChain template) {
|
|
final bool applied = _routine.title == template.title;
|
|
|
|
return GestureDetector(
|
|
onTap: () => _onTemplateSelected(template),
|
|
child: Container(
|
|
width: 176,
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
|
decoration: BoxDecoration(
|
|
color: applied ? kindBackground(_routine.kind) : colorWhite,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(
|
|
color: applied
|
|
? kindColor(_routine.kind).withValues(alpha: 0.4)
|
|
: colorBorder,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
text(template.title, 13, TextType.Medium,
|
|
color: colorPrimaryDark,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis),
|
|
const SizedBox(height: 5),
|
|
text(
|
|
"${template.steps.length} steps · ${formatMinutes(template.totalSeconds / 60)}",
|
|
11,
|
|
TextType.Regular,
|
|
color: colorGrey2,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _dayPicker() {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: List<Widget>.generate(7, (index) {
|
|
final int day = index + 1;
|
|
final bool on = _routine.days.contains(day);
|
|
final Color tone = kindColor(_routine.kind);
|
|
|
|
return GestureDetector(
|
|
onTap: () => _onToggleDay(day),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
width: 40,
|
|
height: 40,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: on ? tone : colorWhite,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: on ? tone : colorBorder),
|
|
),
|
|
child: text(weekdayInitial(day), 13,
|
|
on ? TextType.Bold : TextType.Regular,
|
|
color: on ? colorWhite : colorGrey2),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
Widget _stepList() {
|
|
return ReorderableListView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
buildDefaultDragHandles: false,
|
|
itemCount: _routine.steps.length,
|
|
onReorderItem: _onReorder,
|
|
proxyDecorator: _dragDecorator,
|
|
itemBuilder: (context, index) => _stepCard(index),
|
|
);
|
|
}
|
|
|
|
Widget _dragDecorator(Widget child, int index, Animation<double> animation) {
|
|
return Material(
|
|
color: Colors.transparent,
|
|
elevation: 0,
|
|
child: ScaleTransition(
|
|
scale: Tween<double>(begin: 1, end: 1.03).animate(animation),
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _stepCard(int index) {
|
|
final RoutineStep step = _routine.steps[index];
|
|
final Color tone = kindColor(_routine.kind);
|
|
|
|
return Container(
|
|
key: ObjectKey(step),
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: card(
|
|
padding: const EdgeInsets.fromLTRB(8, 10, 6, 10),
|
|
onTap: () => _openStepEditor(index),
|
|
child: Row(
|
|
children: [
|
|
ReorderableDragStartListener(
|
|
index: index,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
|
child: Icon(Icons.drag_indicator_rounded,
|
|
size: 18, color: colorGrey),
|
|
),
|
|
),
|
|
Container(
|
|
width: 30,
|
|
height: 30,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: kindBackground(_routine.kind),
|
|
borderRadius: BorderRadius.circular(9),
|
|
),
|
|
child: text("${index + 1}", 12, TextType.Bold, color: tone),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
text(step.title, 14, TextType.Medium,
|
|
color: colorPrimaryDark,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
Icon(stepModeIcon(step.mode), size: 11, color: colorGrey2),
|
|
const SizedBox(width: 5),
|
|
Expanded(
|
|
child: text(stepSummary(step), 11, TextType.Regular,
|
|
color: colorGrey2,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
iconButton(
|
|
Icon(CupertinoIcons.plus_square_on_square,
|
|
size: 16, color: colorGrey2),
|
|
() => _onDuplicateStep(index),
|
|
size: 34,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// The dark totals card under the steps — the whole chain in three numbers.
|
|
Widget _chainSummary() {
|
|
final Color label = colorWhite.withValues(alpha: 0.45);
|
|
|
|
return card(
|
|
background: kindDeep(_routine.kind),
|
|
borderColor: kindDeep(_routine.kind),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: labelled("Planned", formatMinutes(_routine.totalSeconds / 60),
|
|
valueSize: 20,
|
|
valueType: TextType.Light,
|
|
valueColor: colorWhite,
|
|
labelColor: label),
|
|
),
|
|
Expanded(
|
|
child: labelled("Steps", "${_routine.steps.length}",
|
|
valueSize: 20,
|
|
valueType: TextType.Light,
|
|
valueColor: colorWhite,
|
|
labelColor: label),
|
|
),
|
|
Expanded(
|
|
child: labelled(
|
|
_routine.kind == RoutineKind.MustDo ? "Tasks" : "Sets",
|
|
"${_routine.kind == RoutineKind.MustDo ? _routine.steps.length : _routine.totalSets}",
|
|
valueSize: 20,
|
|
valueType: TextType.Light,
|
|
valueColor: colorWhite,
|
|
labelColor: label),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── ConnectRoutineBuilder ─────────────────────────────────────────────────
|
|
|
|
@override
|
|
void onSaved() {
|
|
Navigator.pop(context, true);
|
|
}
|
|
|
|
@override
|
|
void onGoalsReady(List<Goal> goals) async {
|
|
final Goal? picked =
|
|
await showGoalPicker(context, goals, selectedId: _routine.goalId);
|
|
|
|
if (picked == null || !mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_goal = picked.id == null ? null : picked;
|
|
_routine.goalId = picked.id;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_title.dispose();
|
|
_note.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|