Files
ZenXercise/lib/src/plans/views/plan_editor_view.dart
T

340 lines
12 KiB
Dart
Raw Normal View History

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))],
);
}