Stage 3: Plan editor with Steps, Supersets and reordering

- Plan model with immutable edits: add with Mode defaults, move, remove,
  Superset with next, take a member out, ungroup; Stretching length
  estimate from holds, sides, rounds and Transitions
- drift database (plans table, items as JSON, ADR 0005) and
  PlanRepository; Plans list per Mode with rename/duplicate/delete
- Editor: drag handles, step editor sheet (sets, reps or time, optional
  kg; hold, both sides, rounds; Superset sets and rest between),
  pause between holds for Stretching Plans
- Exercise picker reusing the Library browser; Add to plan from the
  Exercise detail page
- Theme: Mode-styled snackbar, FAB, menus, segmented buttons, switches

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-28 23:48:07 +01:00
1 parent 786dd52e30
commit bbdca95541
28 files changed
+3654 -11

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import '../../design/number_stepper.dart';
import '../../design/zen_style.dart';
import '../../localization/app_localizations.dart';
import '../models/plan.dart';
import '../plan_labels.dart';
/// Edits a Step, a Superset or a Superset member in a bottom sheet. Every
/// change is handed to [onChanged] right away; there is no separate save.
Future<void> showStepEditor<T>(
BuildContext context, {
required String title,
required T value,
required ValueChanged<T> onChanged,
}) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _StepEditorSheet<T>(title: title, initial: value, onChanged: onChanged),
);
class _StepEditorSheet<T> extends StatefulWidget {
const _StepEditorSheet({required this.title, required this.initial, required this.onChanged});
final String title;
final T initial;
final ValueChanged<T> onChanged;
@override
State<_StepEditorSheet<T>> createState() => _StepEditorSheetState<T>();
}
class _StepEditorSheetState<T> extends State<_StepEditorSheet<T>> {
late T _value = widget.initial;
void _set(Object value) {
setState(() => _value = value as T);
widget.onChanged(_value);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final text = Theme.of(context).textTheme;
final value = _value;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.title, style: text.titleLarge),
const SizedBox(height: 12),
...switch (value) {
WorkoutStep() => [
_setsStepper(l10n, value.sets, (n) => _set(value.copyWith(sets: n))),
_TargetEditor(
target: value.target,
onChanged: (t) => _set(value.copyWith(target: t)),
),
_WeightEditor(
weightKg: value.weightKg,
onChanged: (kg) => _set(value.copyWith(weightKg: () => kg)),
),
],
SupersetMember() => [
_TargetEditor(
target: value.target,
onChanged: (t) => _set(value.copyWith(target: t)),
),
_WeightEditor(
weightKg: value.weightKg,
onChanged: (kg) => _set(value.copyWith(weightKg: () => kg)),
),
],
Superset() => [
_setsStepper(l10n, value.sets, (n) => _set(value.copyWith(sets: n))),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.restBetweenLabel, style: text.titleSmall),
subtitle: Text(l10n.restBetweenHint),
value: value.restBetween,
onChanged: (on) => _set(value.copyWith(restBetween: on)),
),
],
StretchingStep() => [
NumberStepper(
label: l10n.stepHold,
value: l10n.secondsShort(value.holdSeconds),
onDecrement: value.holdSeconds > 5
? () => _set(value.copyWith(holdSeconds: value.holdSeconds - 5))
: null,
onIncrement: value.holdSeconds < 300
? () => _set(value.copyWith(holdSeconds: value.holdSeconds + 5))
: null,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.stepBothSides, style: text.titleSmall),
subtitle: Text(l10n.stepBothSidesHint),
value: value.bothSides,
onChanged: (on) => _set(value.copyWith(bothSides: on)),
),
NumberStepper(
label: l10n.stepRounds,
value: '${value.rounds}',
onDecrement: value.rounds > 1 ? () => _set(value.copyWith(rounds: value.rounds - 1)) : null,
onIncrement: value.rounds < 10 ? () => _set(value.copyWith(rounds: value.rounds + 1)) : null,
),
],
_ => const <Widget>[],
},
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(onPressed: () => Navigator.of(context).pop(), child: Text(l10n.done)),
),
],
),
),
);
}
Widget _setsStepper(AppLocalizations l10n, int sets, ValueChanged<int> onChanged) => NumberStepper(
label: l10n.stepSets,
value: '$sets',
onDecrement: sets > 1 ? () => onChanged(sets - 1) : null,
onIncrement: sets < 20 ? () => onChanged(sets + 1) : null,
);
}
/// Reps or time per set, switching between the two with sensible starting values.
class _TargetEditor extends StatelessWidget {
const _TargetEditor({required this.target, required this.onChanged});
final WorkoutTarget target;
final ValueChanged<WorkoutTarget> onChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
SegmentedButton<bool>(
showSelectedIcon: false,
segments: [
ButtonSegment(value: false, label: Text(l10n.stepReps)),
ButtonSegment(value: true, label: Text(l10n.stepTime)),
],
selected: {target.isTimed},
onSelectionChanged: (timed) =>
onChanged(timed.single ? const WorkoutTarget.seconds(30) : const WorkoutTarget.reps(10)),
),
if (target.isTimed)
NumberStepper(
label: l10n.stepTime,
value: l10n.secondsShort(target.seconds!),
onDecrement: target.seconds! > 5 ? () => onChanged(WorkoutTarget.seconds(target.seconds! - 5)) : null,
onIncrement: target.seconds! < 600 ? () => onChanged(WorkoutTarget.seconds(target.seconds! + 5)) : null,
)
else
NumberStepper(
label: l10n.stepReps,
value: '${target.reps}',
onDecrement: target.reps! > 1 ? () => onChanged(WorkoutTarget.reps(target.reps! - 1)) : null,
onIncrement: target.reps! < 100 ? () => onChanged(WorkoutTarget.reps(target.reps! + 1)) : null,
),
],
);
}
}
/// An optional target weight in 2.5 kg steps.
class _WeightEditor extends StatelessWidget {
const _WeightEditor({required this.weightKg, required this.onChanged});
static const step = 2.5;
final double? weightKg;
final ValueChanged<double?> onChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final s = ZenStyle.of(context);
final kg = weightKg;
if (kg == null) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: OutlinedButton.icon(
onPressed: () => onChanged(20),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addWeight),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NumberStepper(
label: l10n.stepWeight,
value: l10n.weightKg(kgNumber(kg)),
onDecrement: kg >= step ? () => onChanged(kg - step) : null,
onIncrement: kg < 500 ? () => onChanged(kg + step) : null,
),
TextButton(
onPressed: () => onChanged(null),
style: TextButton.styleFrom(foregroundColor: s.inkMuted, padding: EdgeInsets.zero),
child: Text(l10n.removeWeight),
),
],
);
}
}