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:
1 parent
786dd52e30
commit
bbdca95541
28 files changed
+3654
-11
No files matched your search
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user