Files
GroundedHelper/frontend/lib/Grounded/see/routine/StepEditorState.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

363 lines
10 KiB
Dart

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();
}
}