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
363 lines
10 KiB
Dart
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();
|
|
}
|
|
}
|