219 lines
7.8 KiB
Dart
219 lines
7.8 KiB
Dart
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),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|