340 lines
12 KiB
Dart
340 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:provider/provider.dart';
|
||
|
|
|
||
|
|
import '../../design/number_stepper.dart';
|
||
|
|
import '../../design/page_header.dart';
|
||
|
|
import '../../design/zen_style.dart';
|
||
|
|
import '../../exercises/exercise_library.dart';
|
||
|
|
import '../../exercises/views/library_view.dart';
|
||
|
|
import '../../localization/app_localizations.dart';
|
||
|
|
import '../../modes/mode.dart';
|
||
|
|
import '../models/plan.dart';
|
||
|
|
import '../plan_labels.dart';
|
||
|
|
import '../plan_repository.dart';
|
||
|
|
import 'exercise_picker_view.dart';
|
||
|
|
import 'plans_view.dart';
|
||
|
|
import 'step_editor_sheet.dart';
|
||
|
|
|
||
|
|
/// Builds one Plan: its Steps and Supersets in order, each editable, movable
|
||
|
|
/// by its handle and removable. Every change is saved at once.
|
||
|
|
class PlanEditorView extends StatelessWidget {
|
||
|
|
const PlanEditorView({super.key, required this.planId});
|
||
|
|
|
||
|
|
static const routeName = '/plan';
|
||
|
|
|
||
|
|
final String planId;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final plans = context.read<PlanRepository>();
|
||
|
|
return StreamBuilder(
|
||
|
|
stream: plans.watchPlan(planId),
|
||
|
|
builder: (context, snapshot) {
|
||
|
|
final plan = snapshot.data;
|
||
|
|
if (plan == null) return const Scaffold();
|
||
|
|
return _Editor(plan: plan, save: plans.save);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _Editor extends StatelessWidget {
|
||
|
|
const _Editor({required this.plan, required this.save});
|
||
|
|
|
||
|
|
final Plan plan;
|
||
|
|
final Future<void> Function(Plan) save;
|
||
|
|
|
||
|
|
void _addExercises(BuildContext context) =>
|
||
|
|
Navigator.of(context).pushNamed(ExercisePickerView.routeName, arguments: plan.id);
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final l10n = AppLocalizations.of(context);
|
||
|
|
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||
|
|
final addButton = FilledButton.icon(
|
||
|
|
onPressed: () => _addExercises(context),
|
||
|
|
icon: const Icon(Icons.add_rounded),
|
||
|
|
label: Text(l10n.addExercises),
|
||
|
|
);
|
||
|
|
return Scaffold(
|
||
|
|
appBar: AppBar(
|
||
|
|
actions: [PlanMenu(plan: plan, onDeleted: () => Navigator.of(context).pop())],
|
||
|
|
),
|
||
|
|
body: plan.items.isEmpty
|
||
|
|
? ListView(
|
||
|
|
children: [
|
||
|
|
PageHeader(title: plan.name),
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
EmptyState(
|
||
|
|
icon: Icons.add_task_rounded,
|
||
|
|
title: l10n.planEmptyTitle,
|
||
|
|
body: l10n.planEmptyBody(plan.mode.name),
|
||
|
|
),
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
|
||
|
|
child: Row(children: [addButton]),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
: ReorderableListView.builder(
|
||
|
|
buildDefaultDragHandles: false,
|
||
|
|
padding: EdgeInsets.only(bottom: 96 + bottomInset),
|
||
|
|
header: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
PageHeader(title: plan.name, subtitle: planSummary(l10n, plan)),
|
||
|
|
if (plan.mode == Mode.stretching)
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 16, 12),
|
||
|
|
child: NumberStepper(
|
||
|
|
label: l10n.transitionLabel,
|
||
|
|
value: l10n.secondsShort(plan.transitionSeconds),
|
||
|
|
onDecrement: plan.transitionSeconds > 0
|
||
|
|
? () => save(plan.copyWith(transitionSeconds: plan.transitionSeconds - 1))
|
||
|
|
: null,
|
||
|
|
onIncrement: plan.transitionSeconds < 30
|
||
|
|
? () => save(plan.copyWith(transitionSeconds: plan.transitionSeconds + 1))
|
||
|
|
: null,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
itemCount: plan.items.length,
|
||
|
|
onReorder: (from, to) => save(plan.withItemMoved(from, to)),
|
||
|
|
proxyDecorator: (child, _, _) => Material(color: Colors.transparent, elevation: 6, child: child),
|
||
|
|
itemBuilder: (context, i) => Padding(
|
||
|
|
key: ObjectKey(plan.items[i]),
|
||
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||
|
|
child: switch (plan.items[i]) {
|
||
|
|
final Superset superset => _SupersetCard(plan: plan, index: i, superset: superset, save: save),
|
||
|
|
final item => _StepTile(plan: plan, index: i, item: item, save: save),
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
floatingActionButton: plan.items.isEmpty
|
||
|
|
? null
|
||
|
|
: FloatingActionButton.extended(
|
||
|
|
onPressed: () => _addExercises(context),
|
||
|
|
icon: const Icon(Icons.add_rounded),
|
||
|
|
label: Text(l10n.addExercises),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A Workout or Stretching Step on its own.
|
||
|
|
class _StepTile extends StatelessWidget {
|
||
|
|
const _StepTile({required this.plan, required this.index, required this.item, required this.save});
|
||
|
|
|
||
|
|
final Plan plan;
|
||
|
|
final int index;
|
||
|
|
final PlanItem item;
|
||
|
|
final Future<void> Function(Plan) save;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final l10n = AppLocalizations.of(context);
|
||
|
|
final (exerciseId, summary) = switch (item) {
|
||
|
|
final WorkoutStep step => (step.exerciseId, workoutStepSummary(l10n, step)),
|
||
|
|
final StretchingStep step => (step.exerciseId, stretchingStepSummary(l10n, step)),
|
||
|
|
Superset() => throw StateError('Supersets have their own card'),
|
||
|
|
};
|
||
|
|
final name = _exerciseName(context, exerciseId);
|
||
|
|
return Card(
|
||
|
|
child: _ExerciseRow(
|
||
|
|
exerciseId: exerciseId,
|
||
|
|
name: name,
|
||
|
|
summary: summary,
|
||
|
|
dragIndex: index,
|
||
|
|
onTap: () => showStepEditor<PlanItem>(
|
||
|
|
context,
|
||
|
|
title: name,
|
||
|
|
value: item,
|
||
|
|
onChanged: (edited) => save(plan.withItemReplaced(index, edited)),
|
||
|
|
),
|
||
|
|
menu: [
|
||
|
|
if (plan.canSupersetWithNext(index))
|
||
|
|
_MenuAction(l10n.supersetWithNext, () => save(plan.withSupersetWithNext(index))),
|
||
|
|
_MenuAction(l10n.remove, () => save(plan.withItemRemoved(index))),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A Superset: a header with its set count and rest choice, its members
|
||
|
|
/// below, all held together by an accent bar on the left.
|
||
|
|
class _SupersetCard extends StatelessWidget {
|
||
|
|
const _SupersetCard({required this.plan, required this.index, required this.superset, required this.save});
|
||
|
|
|
||
|
|
final Plan plan;
|
||
|
|
final int index;
|
||
|
|
final Superset superset;
|
||
|
|
final Future<void> Function(Plan) save;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final l10n = AppLocalizations.of(context);
|
||
|
|
final s = ZenStyle.of(context);
|
||
|
|
final text = Theme.of(context).textTheme;
|
||
|
|
return Card(
|
||
|
|
child: IntrinsicHeight(
|
||
|
|
child: Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
Container(width: 4, decoration: BoxDecoration(gradient: s.accentGradient)),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
InkWell(
|
||
|
|
onTap: () => showStepEditor<Superset>(
|
||
|
|
context,
|
||
|
|
title: l10n.supersetTitle,
|
||
|
|
value: superset,
|
||
|
|
onChanged: (edited) => save(plan.withItemReplaced(index, edited)),
|
||
|
|
),
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(14, 10, 0, 4),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Icon(Icons.link_rounded, size: 20, color: s.accent),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(
|
||
|
|
child: Text(
|
||
|
|
'${l10n.supersetTitle} · ${l10n.setsCount(superset.sets)}',
|
||
|
|
style: text.titleSmall?.copyWith(color: s.accent),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
_Menu([
|
||
|
|
if (plan.canSupersetWithNext(index))
|
||
|
|
_MenuAction(l10n.supersetWithNext, () => save(plan.withSupersetWithNext(index))),
|
||
|
|
_MenuAction(l10n.ungroup, () => save(plan.withUngrouped(index))),
|
||
|
|
_MenuAction(l10n.remove, () => save(plan.withItemRemoved(index))),
|
||
|
|
]),
|
||
|
|
_DragHandle(index: index),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
for (final (m, member) in superset.members.indexed)
|
||
|
|
_ExerciseRow(
|
||
|
|
exerciseId: member.exerciseId,
|
||
|
|
name: _exerciseName(context, member.exerciseId),
|
||
|
|
summary: memberSummary(l10n, member),
|
||
|
|
onTap: () => showStepEditor<SupersetMember>(
|
||
|
|
context,
|
||
|
|
title: _exerciseName(context, member.exerciseId),
|
||
|
|
value: member,
|
||
|
|
onChanged: (edited) => save(
|
||
|
|
plan.withItemReplaced(index, superset.copyWith(members: [...superset.members]..[m] = edited)),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
menu: [
|
||
|
|
_MenuAction(l10n.takeOutOfSuperset, () => save(plan.withMemberRemovedFromSuperset(index, m))),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
String _exerciseName(BuildContext context, String exerciseId) =>
|
||
|
|
context.read<ExerciseLibrary>().byId(exerciseId)?.name ?? exerciseId;
|
||
|
|
|
||
|
|
class _ExerciseRow extends StatelessWidget {
|
||
|
|
const _ExerciseRow({
|
||
|
|
required this.exerciseId,
|
||
|
|
required this.name,
|
||
|
|
required this.summary,
|
||
|
|
required this.onTap,
|
||
|
|
required this.menu,
|
||
|
|
this.dragIndex,
|
||
|
|
});
|
||
|
|
|
||
|
|
final String exerciseId;
|
||
|
|
final String name;
|
||
|
|
final String summary;
|
||
|
|
final VoidCallback onTap;
|
||
|
|
final List<_MenuAction> menu;
|
||
|
|
|
||
|
|
/// Set for items of the Plan's own list, which can be dragged.
|
||
|
|
final int? dragIndex;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final s = ZenStyle.of(context);
|
||
|
|
final text = Theme.of(context).textTheme;
|
||
|
|
final exercise = context.read<ExerciseLibrary>().byId(exerciseId);
|
||
|
|
return InkWell(
|
||
|
|
onTap: onTap,
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(10, 8, 0, 8),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
if (exercise != null) ExerciseThumbnail(exercise: exercise, size: 52),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Text(name, style: text.titleSmall, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||
|
|
const SizedBox(height: 2),
|
||
|
|
Text(summary, style: text.bodySmall?.copyWith(color: s.inkMuted)),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
_Menu(menu),
|
||
|
|
if (dragIndex != null) _DragHandle(index: dragIndex!) else const SizedBox(width: 8),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _DragHandle extends StatelessWidget {
|
||
|
|
const _DragHandle({required this.index});
|
||
|
|
|
||
|
|
final int index;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final s = ZenStyle.of(context);
|
||
|
|
return ReorderableDragStartListener(
|
||
|
|
index: index,
|
||
|
|
child: Tooltip(
|
||
|
|
message: AppLocalizations.of(context).dragToReorder,
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||
|
|
child: Icon(Icons.drag_indicator_rounded, color: s.inkMuted),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _MenuAction {
|
||
|
|
const _MenuAction(this.label, this.onSelected);
|
||
|
|
|
||
|
|
final String label;
|
||
|
|
final VoidCallback onSelected;
|
||
|
|
}
|
||
|
|
|
||
|
|
class _Menu extends StatelessWidget {
|
||
|
|
const _Menu(this.actions);
|
||
|
|
|
||
|
|
final List<_MenuAction> actions;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) => PopupMenuButton<int>(
|
||
|
|
tooltip: AppLocalizations.of(context).moreActions,
|
||
|
|
icon: Icon(Icons.more_vert_rounded, color: ZenStyle.of(context).inkMuted),
|
||
|
|
onSelected: (i) => actions[i].onSelected(),
|
||
|
|
itemBuilder: (context) => [for (final (i, a) in actions.indexed) PopupMenuItem(value: i, child: Text(a.label))],
|
||
|
|
);
|
||
|
|
}
|