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:
362
frontend/lib/Grounded/see/routine/StepEditorState.dart
Normal file
362
frontend/lib/Grounded/see/routine/StepEditorState.dart
Normal file
@@ -0,0 +1,362 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/external/data/RoutineStep.dart';
|
||||
import '../../about/internal/application/RoutineKind.dart';
|
||||
import '../../about/internal/application/StepMode.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/CountStepper.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'StepEditor.dart';
|
||||
|
||||
class StepEditorState extends State<StepEditor> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _title = TextEditingController();
|
||||
|
||||
final TextEditingController _cue = TextEditingController();
|
||||
|
||||
late RoutineStep _step;
|
||||
|
||||
bool get _editing {
|
||||
return widget.step != null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_step = widget.step == null
|
||||
? _defaultsFor(widget.kind)
|
||||
: RoutineStep.fromJson(widget.step!.toJson());
|
||||
|
||||
_title.text = _step.title;
|
||||
_cue.text = _step.cue;
|
||||
}
|
||||
|
||||
/// A new step starts as the kind's most common shape, so most edits are
|
||||
/// just a title.
|
||||
RoutineStep _defaultsFor(RoutineKind kind) {
|
||||
switch (kind) {
|
||||
case RoutineKind.Workout:
|
||||
return RoutineStep(
|
||||
mode: StepMode.Reps,
|
||||
sets: 3,
|
||||
reps: 10,
|
||||
timerSeconds: 40,
|
||||
restSeconds: 60);
|
||||
case RoutineKind.Meditation:
|
||||
return RoutineStep(mode: StepMode.Timed, timerSeconds: 300);
|
||||
case RoutineKind.MustDo:
|
||||
return RoutineStep(mode: StepMode.Check, timerSeconds: 120);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 28),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
text(_editing ? "EDIT STEP" : "NEW STEP", 9, TextType.Bold,
|
||||
color: kindColor(widget.kind), letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
text(_title.text.isEmpty ? "What happens here?" : _title.text,
|
||||
24, TextType.Light,
|
||||
color: colorPrimaryDark, height: 1.2),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Title",
|
||||
_title,
|
||||
hint: _titleHint(),
|
||||
validator: Validators.title,
|
||||
onChanged: _onTitleChanged,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
text("MODE", 10, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 0.8),
|
||||
const SizedBox(height: 8),
|
||||
segmentedSelector<StepMode>(
|
||||
options: StepMode.values,
|
||||
selected: _step.mode,
|
||||
label: stepModeLabel,
|
||||
onSelected: _onModeSelected,
|
||||
activeColor: kindColor(widget.kind),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
text(_modeDescription(_step.mode), 12, TextType.Regular,
|
||||
color: colorGrey2, height: 1.5),
|
||||
const SizedBox(height: 20),
|
||||
..._numbers(),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Cue",
|
||||
_cue,
|
||||
hint: widget.kind == RoutineKind.Meditation
|
||||
? "In 4 · hold 4 · out 4"
|
||||
: "One line to read mid-step (optional)",
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_summaryLine(),
|
||||
const SizedBox(height: 20),
|
||||
roundedCornerButton(
|
||||
_editing ? "Update step" : "Add step",
|
||||
_onSave,
|
||||
background: colorPrimaryDark,
|
||||
icon: _editing ? CupertinoIcons.checkmark : CupertinoIcons.add,
|
||||
),
|
||||
if (_editing) ...[
|
||||
const SizedBox(height: 6),
|
||||
Center(
|
||||
child: textButton("Remove step", _onRemove,
|
||||
color: colorDestructive, textSize: 13),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _titleHint() {
|
||||
switch (widget.kind) {
|
||||
case RoutineKind.Workout:
|
||||
return "Deadlifts, plank, warm up…";
|
||||
case RoutineKind.Meditation:
|
||||
return "Settle, breathe, body scan…";
|
||||
case RoutineKind.MustDo:
|
||||
return "Make the bed, plan tomorrow…";
|
||||
}
|
||||
}
|
||||
|
||||
String _modeDescription(StepMode mode) {
|
||||
switch (mode) {
|
||||
case StepMode.Timed:
|
||||
return "The clock runs down and the next beat starts on its own.";
|
||||
case StepMode.Reps:
|
||||
return "You end each set when the reps are done. Rest is timed.";
|
||||
case StepMode.Check:
|
||||
return "A task. You tick it; the clock records how long it took.";
|
||||
}
|
||||
}
|
||||
|
||||
/// The steppers that apply to the chosen mode.
|
||||
List<Widget> _numbers() {
|
||||
final List<Widget> rows = <Widget>[];
|
||||
|
||||
switch (_step.mode) {
|
||||
case StepMode.Timed:
|
||||
rows.add(_pair(
|
||||
countStepper("Duration", formatSeconds(_step.timerSeconds),
|
||||
onMinus: _onDurationMinus,
|
||||
onPlus: _onDurationPlus,
|
||||
canMinus: _step.timerSeconds > 5),
|
||||
countStepper("Sets", "${_step.sets}",
|
||||
onMinus: _onSetsMinus,
|
||||
onPlus: _onSetsPlus,
|
||||
canMinus: _step.sets > 1),
|
||||
));
|
||||
break;
|
||||
case StepMode.Reps:
|
||||
rows.add(_pair(
|
||||
countStepper("Reps", "${_step.reps}",
|
||||
onMinus: _onRepsMinus,
|
||||
onPlus: _onRepsPlus,
|
||||
canMinus: _step.reps > 1),
|
||||
countStepper("Sets", "${_step.sets}",
|
||||
onMinus: _onSetsMinus,
|
||||
onPlus: _onSetsPlus,
|
||||
canMinus: _step.sets > 1),
|
||||
));
|
||||
rows.add(const SizedBox(height: 10));
|
||||
rows.add(countStepper("Est. per set", formatSeconds(_step.timerSeconds),
|
||||
onMinus: _onDurationMinus,
|
||||
onPlus: _onDurationPlus,
|
||||
canMinus: _step.timerSeconds > 5));
|
||||
break;
|
||||
case StepMode.Check:
|
||||
rows.add(countStepper("Estimate", formatSeconds(_step.timerSeconds),
|
||||
onMinus: _onDurationMinus,
|
||||
onPlus: _onDurationPlus,
|
||||
canMinus: _step.timerSeconds > 5));
|
||||
break;
|
||||
}
|
||||
|
||||
if (_step.mode != StepMode.Check) {
|
||||
rows.add(const SizedBox(height: 10));
|
||||
rows.add(countStepper(
|
||||
"Rest after each set",
|
||||
_step.restSeconds == 0 ? "None" : formatSeconds(_step.restSeconds),
|
||||
onMinus: _onRestMinus,
|
||||
onPlus: _onRestPlus,
|
||||
canMinus: _step.restSeconds > 0,
|
||||
));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
Widget _pair(Widget left, Widget right) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: left),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: right),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryLine() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: kindBackground(widget.kind),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(stepModeIcon(_step.mode),
|
||||
size: 15, color: kindColor(widget.kind)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(stepSummary(_step), 12, TextType.Medium,
|
||||
color: colorPrimaryDark),
|
||||
),
|
||||
text("~${formatSeconds(_step.plannedSeconds)}", 12, TextType.Bold,
|
||||
color: kindColor(widget.kind)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onTitleChanged(String value) {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onModeSelected(StepMode mode) {
|
||||
setState(() {
|
||||
_step.mode = mode;
|
||||
if (mode == StepMode.Reps && _step.reps == 0) {
|
||||
_step.reps = 10;
|
||||
}
|
||||
if (mode == StepMode.Check) {
|
||||
_step.sets = 1;
|
||||
_step.restSeconds = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Steps get coarser as the duration grows: 5s near the bottom, a minute
|
||||
/// once you are into meditation lengths.
|
||||
int _durationIncrement(int seconds) {
|
||||
if (seconds < 60) {
|
||||
return 5;
|
||||
}
|
||||
if (seconds < 180) {
|
||||
return 15;
|
||||
}
|
||||
if (seconds < 600) {
|
||||
return 30;
|
||||
}
|
||||
return 60;
|
||||
}
|
||||
|
||||
void _onDurationMinus() {
|
||||
setState(() {
|
||||
final int next =
|
||||
_step.timerSeconds - _durationIncrement(_step.timerSeconds - 1);
|
||||
_step.timerSeconds = next < 5 ? 5 : next;
|
||||
});
|
||||
}
|
||||
|
||||
void _onDurationPlus() {
|
||||
setState(() {
|
||||
_step.timerSeconds =
|
||||
_step.timerSeconds + _durationIncrement(_step.timerSeconds);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSetsMinus() {
|
||||
setState(() {
|
||||
_step.sets = _step.sets > 1 ? _step.sets - 1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSetsPlus() {
|
||||
setState(() {
|
||||
_step.sets = _step.sets + 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRepsMinus() {
|
||||
setState(() {
|
||||
_step.reps = _step.reps > 1 ? _step.reps - 1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRepsPlus() {
|
||||
setState(() {
|
||||
_step.reps = _step.reps + 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRestMinus() {
|
||||
setState(() {
|
||||
final int next = _step.restSeconds - 15;
|
||||
_step.restSeconds = next < 0 ? 0 : next;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRestPlus() {
|
||||
setState(() {
|
||||
_step.restSeconds = _step.restSeconds + 15;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
_step.title = _title.text.trim();
|
||||
_step.cue = _cue.text.trim();
|
||||
|
||||
Navigator.pop(context, _step);
|
||||
}
|
||||
|
||||
void _onRemove() {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_cue.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user