Stage 2: Library search, filters and Exercise detail with Flip Animation
- LibraryFilter: word search on names; muscle (primary), equipment and level choices that widen within a filter and narrow across filters - ExerciseBrowser (reusable for the Plan picker) with search field, filter sheets showing live result counts, a clear button and an empty state - Exercise detail: start/end Flip Animation at each Mode's pace (still with reduced motion), facts, main/secondary muscles, numbered steps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1289fadb98
commit
786dd52e30
16 files changed
+1272
-32
No files matched your search
+8
-2
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import 'design/app_theme.dart';
|
||||
import 'design/zen_style.dart';
|
||||
import 'exercises/views/exercise_detail_view.dart';
|
||||
import 'home/home_view.dart';
|
||||
import 'localization/app_localizations.dart';
|
||||
import 'settings/settings_controller.dart';
|
||||
@@ -29,8 +30,13 @@ class ZenXerciseApp extends StatelessWidget {
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||
onGenerateRoute: (settings) =>
|
||||
MaterialPageRoute<Object?>(settings: settings, builder: (context) => const HomeView()),
|
||||
onGenerateRoute: (settings) => MaterialPageRoute<Object?>(
|
||||
settings: settings,
|
||||
builder: (context) => switch (settings.name) {
|
||||
ExerciseDetailView.routeName => ExerciseDetailView(exerciseId: settings.arguments! as String),
|
||||
_ => const HomeView(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,9 @@ class AppTheme {
|
||||
titleLarge: heading(text.titleLarge, workout ? FontWeight.w700 : FontWeight.w800, scale: 1.15),
|
||||
titleMedium: heading(text.titleMedium, workout ? FontWeight.w700 : FontWeight.w800, scale: 1.15),
|
||||
titleSmall: heading(text.titleSmall, workout ? FontWeight.w700 : FontWeight.w800, scale: 1.1),
|
||||
bodyMedium: text.bodyMedium?.copyWith(color: s.ink, height: 1.4),
|
||||
// Barlow's default tracking reads loose at body sizes.
|
||||
bodyLarge: text.bodyLarge?.copyWith(letterSpacing: workout ? 0.15 : null),
|
||||
bodyMedium: text.bodyMedium?.copyWith(color: s.ink, height: 1.4, letterSpacing: workout ? 0.1 : null),
|
||||
labelLarge: text.labelLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
|
||||
@@ -25,6 +25,7 @@ class ZenStyle extends ThemeExtension<ZenStyle> {
|
||||
required this.controlRadius,
|
||||
required this.motion,
|
||||
required this.curve,
|
||||
required this.flipInterval,
|
||||
required this.headingFont,
|
||||
required this.bodyFont,
|
||||
});
|
||||
@@ -61,6 +62,9 @@ class ZenStyle extends ThemeExtension<ZenStyle> {
|
||||
/// for Stretching, short and sharp for Workout.
|
||||
final Duration motion;
|
||||
final Curve curve;
|
||||
|
||||
/// How long each image of an Exercise's Flip Animation stays up.
|
||||
final Duration flipInterval;
|
||||
final String headingFont;
|
||||
final String bodyFont;
|
||||
|
||||
@@ -84,6 +88,7 @@ class ZenStyle extends ThemeExtension<ZenStyle> {
|
||||
controlRadius: 999,
|
||||
motion: Duration(milliseconds: 700),
|
||||
curve: Curves.easeInOutSine,
|
||||
flipInterval: Duration(milliseconds: 1400),
|
||||
headingFont: 'Nunito',
|
||||
bodyFont: 'NunitoSans',
|
||||
);
|
||||
@@ -105,6 +110,7 @@ class ZenStyle extends ThemeExtension<ZenStyle> {
|
||||
controlRadius: 4,
|
||||
motion: Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
flipInterval: Duration(milliseconds: 900),
|
||||
headingFont: 'BarlowCondensed',
|
||||
bodyFont: 'Barlow',
|
||||
);
|
||||
@@ -143,6 +149,7 @@ class ZenStyle extends ThemeExtension<ZenStyle> {
|
||||
controlRadius: lerpDouble(controlRadius.clamp(0, 40), other.controlRadius.clamp(0, 40), t)!,
|
||||
motion: discrete.motion,
|
||||
curve: discrete.curve,
|
||||
flipInterval: discrete.flipInterval,
|
||||
headingFont: discrete.headingFont,
|
||||
bodyFont: discrete.bodyFont,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'exercise.dart';
|
||||
|
||||
/// What the user is looking for in the Exercise Library: a search query plus
|
||||
/// muscle, equipment and level choices. Choices within one filter widen the
|
||||
/// result, filters together narrow it.
|
||||
@immutable
|
||||
class LibraryFilter {
|
||||
const LibraryFilter({this.query = '', this.muscles = const {}, this.equipment = const {}, this.levels = const {}});
|
||||
|
||||
final String query;
|
||||
|
||||
/// Matched against an Exercise's primary muscles only.
|
||||
final Set<String> muscles;
|
||||
final Set<String> equipment;
|
||||
final Set<String> levels;
|
||||
|
||||
int get activeChoices => muscles.length + equipment.length + levels.length;
|
||||
|
||||
bool get isActive => query.trim().isNotEmpty || activeChoices > 0;
|
||||
|
||||
List<Exercise> apply(List<Exercise> exercises) {
|
||||
final words = query.toLowerCase().split(' ').where((w) => w.isNotEmpty).toList();
|
||||
return [
|
||||
for (final e in exercises)
|
||||
if (_matchesWords(e, words) &&
|
||||
(muscles.isEmpty || e.primaryMuscles.any(muscles.contains)) &&
|
||||
(equipment.isEmpty || equipment.contains(e.equipment)) &&
|
||||
(levels.isEmpty || levels.contains(e.level)))
|
||||
e,
|
||||
];
|
||||
}
|
||||
|
||||
static bool _matchesWords(Exercise e, List<String> words) {
|
||||
final name = e.name.toLowerCase();
|
||||
return words.every(name.contains);
|
||||
}
|
||||
|
||||
LibraryFilter withQuery(String query) => _copy(query: query);
|
||||
|
||||
LibraryFilter toggleMuscle(String muscle) => _copy(muscles: _toggle(muscles, muscle));
|
||||
|
||||
LibraryFilter toggleEquipment(String item) => _copy(equipment: _toggle(equipment, item));
|
||||
|
||||
LibraryFilter toggleLevel(String level) => _copy(levels: _toggle(levels, level));
|
||||
|
||||
/// Drops the muscle, equipment and level choices but keeps the search.
|
||||
LibraryFilter clearChoices() => LibraryFilter(query: query);
|
||||
|
||||
static Set<String> _toggle(Set<String> set, String value) =>
|
||||
set.contains(value) ? ({...set}..remove(value)) : {...set, value};
|
||||
|
||||
LibraryFilter _copy({String? query, Set<String>? muscles, Set<String>? equipment, Set<String>? levels}) =>
|
||||
LibraryFilter(
|
||||
query: query ?? this.query,
|
||||
muscles: muscles ?? this.muscles,
|
||||
equipment: equipment ?? this.equipment,
|
||||
levels: levels ?? this.levels,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is LibraryFilter &&
|
||||
other.query == query &&
|
||||
setEquals(other.muscles, muscles) &&
|
||||
setEquals(other.equipment, equipment) &&
|
||||
setEquals(other.levels, levels);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
query,
|
||||
Object.hashAllUnordered(muscles),
|
||||
Object.hashAllUnordered(equipment),
|
||||
Object.hashAllUnordered(levels),
|
||||
);
|
||||
}
|
||||
|
||||
/// The choices each filter offers: the values present among a Mode's Exercises.
|
||||
class FilterOptions {
|
||||
const FilterOptions({required this.muscles, required this.equipment, required this.levels});
|
||||
|
||||
factory FilterOptions.of(List<Exercise> exercises) => FilterOptions(
|
||||
muscles: ({for (final e in exercises) ...e.primaryMuscles}).toList()..sort(),
|
||||
equipment: ({for (final e in exercises) ?e.equipment}).toList()..sort(),
|
||||
levels: [
|
||||
for (final level in _levelOrder)
|
||||
if (exercises.any((e) => e.level == level)) level,
|
||||
],
|
||||
);
|
||||
|
||||
static const _levelOrder = ['beginner', 'intermediate', 'expert'];
|
||||
|
||||
final List<String> muscles;
|
||||
final List<String> equipment;
|
||||
final List<String> levels;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../design/page_header.dart';
|
||||
import '../../design/zen_style.dart';
|
||||
import '../../localization/app_localizations.dart';
|
||||
import '../../modes/mode.dart';
|
||||
import '../exercise.dart';
|
||||
import '../library_filter.dart';
|
||||
import 'filter_sheet.dart';
|
||||
import 'library_view.dart';
|
||||
|
||||
/// Search, the three filters and the matching Exercises of one Mode. The
|
||||
/// Library shows it; the Plan editor reuses it to pick Exercises.
|
||||
class ExerciseBrowser extends StatefulWidget {
|
||||
const ExerciseBrowser({
|
||||
super.key,
|
||||
required this.mode,
|
||||
required this.exercises,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
this.bottomPadding = 24,
|
||||
});
|
||||
|
||||
final Mode mode;
|
||||
final List<Exercise> exercises;
|
||||
final String title;
|
||||
final ValueChanged<Exercise> onTap;
|
||||
|
||||
/// Room below the list, e.g. for the floating navigation bar.
|
||||
final double bottomPadding;
|
||||
|
||||
@override
|
||||
State<ExerciseBrowser> createState() => _ExerciseBrowserState();
|
||||
}
|
||||
|
||||
class _ExerciseBrowserState extends State<ExerciseBrowser> {
|
||||
final _filter = ValueNotifier(const LibraryFilter());
|
||||
final _search = TextEditingController();
|
||||
late FilterOptions _options = FilterOptions.of(widget.exercises);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ExerciseBrowser old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.exercises != widget.exercises) _options = FilterOptions.of(widget.exercises);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_filter.dispose();
|
||||
_search.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _clearAll() {
|
||||
_search.clear();
|
||||
_filter.value = const LibraryFilter();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _filter,
|
||||
builder: (context, filter, _) {
|
||||
final shown = filter.apply(widget.exercises);
|
||||
final total = widget.exercises.length;
|
||||
return CustomScrollView(
|
||||
key: PageStorageKey('browser-${widget.mode.name}'),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: PageHeader(
|
||||
title: widget.title,
|
||||
subtitle: filter.isActive
|
||||
? l10n.librarySubtitleFiltered(widget.mode.name, shown.length, total)
|
||||
: l10n.librarySubtitle(widget.mode.name, total),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: _SearchField(
|
||||
controller: _search,
|
||||
hint: l10n.searchHint(widget.mode.name),
|
||||
onChanged: (q) => _filter.value = filter.withQuery(q),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _FilterRow(
|
||||
filter: filter,
|
||||
onOpen: (kind) => showFilterSheet(
|
||||
context,
|
||||
kind: kind,
|
||||
options: _options,
|
||||
exercises: widget.exercises,
|
||||
filter: _filter,
|
||||
),
|
||||
onClear: () => _filter.value = filter.clearChoices(),
|
||||
),
|
||||
),
|
||||
if (shown.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 32, bottom: widget.bottomPadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
EmptyState(icon: Icons.search_off_rounded, title: l10n.noMatchesTitle, body: l10n.noMatchesBody),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||
child: OutlinedButton(onPressed: _clearAll, child: Text(l10n.clearFilters)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(16, 4, 16, widget.bottomPadding),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (context, i) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: ExerciseTile(exercise: shown[i], onTap: () => widget.onTap(shown[i])),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchField extends StatelessWidget {
|
||||
const _SearchField({required this.controller, required this.hint, required this.onChanged});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String hint;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = ZenStyle.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) => TextField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(color: s.inkMuted),
|
||||
prefixIcon: Icon(Icons.search_rounded, color: s.inkMuted),
|
||||
suffixIcon: controller.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: l10n.searchClear,
|
||||
icon: Icon(Icons.close_rounded, color: s.inkMuted),
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
onChanged('');
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterRow extends StatelessWidget {
|
||||
const _FilterRow({required this.filter, required this.onOpen, required this.onClear});
|
||||
|
||||
final LibraryFilter filter;
|
||||
final ValueChanged<FilterKind> onOpen;
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final s = ZenStyle.of(context);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Up front, so it never scrolls out of reach behind the filters.
|
||||
if (filter.activeChoices > 0) ...[
|
||||
Material(
|
||||
color: s.surfaceSoft,
|
||||
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 20)),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 20)),
|
||||
onTap: onClear,
|
||||
child: Tooltip(
|
||||
message: l10n.clearFilters,
|
||||
child: SizedBox.square(dimension: 40, child: Icon(Icons.close_rounded, size: 20, color: s.ink)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
_FilterButton(label: l10n.filterMuscle, count: filter.muscles.length, onTap: () => onOpen(FilterKind.muscle)),
|
||||
const SizedBox(width: 8),
|
||||
_FilterButton(
|
||||
label: l10n.filterEquipment,
|
||||
count: filter.equipment.length,
|
||||
onTap: () => onOpen(FilterKind.equipment),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterButton(label: l10n.filterLevel, count: filter.levels.length, onTap: () => onOpen(FilterKind.level)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A filter's button: quiet when unused, filled with the Mode's accent and a
|
||||
/// count once something is chosen.
|
||||
class _FilterButton extends StatelessWidget {
|
||||
const _FilterButton({required this.label, required this.count, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final int count;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = ZenStyle.of(context);
|
||||
final active = count > 0;
|
||||
final fg = active ? s.onAccent : s.ink;
|
||||
final radius = BorderRadius.circular(s.controlRadius.clamp(0, 20));
|
||||
return Material(
|
||||
color: active ? s.accent : s.surface,
|
||||
borderRadius: radius,
|
||||
child: InkWell(
|
||||
borderRadius: radius,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 8, 0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: fg, fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (active) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: s.onAccent.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(color: fg, fontWeight: FontWeight.w800, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
Icon(Icons.expand_more_rounded, size: 20, color: fg),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../design/zen_style.dart';
|
||||
import '../../localization/app_localizations.dart';
|
||||
import '../exercise.dart';
|
||||
import '../exercise_labels.dart';
|
||||
import '../exercise_library.dart';
|
||||
import 'flip_animation.dart';
|
||||
|
||||
/// One Exercise: its Flip Animation, facts, muscles and numbered instructions.
|
||||
class ExerciseDetailView extends StatelessWidget {
|
||||
const ExerciseDetailView({super.key, required this.exerciseId});
|
||||
|
||||
static const routeName = '/exercise';
|
||||
|
||||
final String exerciseId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final exercise = context.read<ExerciseLibrary>().byId(exerciseId)!;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final s = ZenStyle.of(context);
|
||||
final text = Theme.of(context).textTheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 32 + MediaQuery.paddingOf(context).bottom),
|
||||
children: [
|
||||
FlipAnimation(exercise: exercise),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(exercise.name, style: text.headlineMedium),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Facts(exercise: exercise),
|
||||
const SizedBox(height: 24),
|
||||
_Section(
|
||||
title: l10n.detailMainMuscles,
|
||||
child: _MuscleChips(muscles: exercise.primaryMuscles, strong: true),
|
||||
),
|
||||
if (exercise.secondaryMuscles.isNotEmpty)
|
||||
_Section(
|
||||
title: l10n.detailAlsoWorks,
|
||||
child: _MuscleChips(muscles: exercise.secondaryMuscles, strong: false),
|
||||
),
|
||||
_Section(
|
||||
title: l10n.detailHowTo,
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, step) in exercise.instructions.indexed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: s.surfaceSoft,
|
||||
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 14)),
|
||||
),
|
||||
child: Text(
|
||||
'${i + 1}',
|
||||
style: TextStyle(fontFamily: s.headingFont, fontWeight: FontWeight.w800, color: s.accent),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Text(step, style: text.bodyLarge?.copyWith(height: 1.45)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Level, equipment and (when known) compound or isolation, side by side.
|
||||
class _Facts extends StatelessWidget {
|
||||
const _Facts({required this.exercise});
|
||||
|
||||
final Exercise exercise;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final facts = [
|
||||
(l10n.detailLevel, sentenceCase(exercise.level)),
|
||||
(l10n.detailEquipment, exercise.equipment == null ? l10n.equipmentNone : sentenceCase(exercise.equipment!)),
|
||||
if (exercise.mechanic != null) (l10n.detailType, sentenceCase(exercise.mechanic!)),
|
||||
];
|
||||
return Row(
|
||||
children: [
|
||||
for (final (i, (label, value)) in facts.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _Fact(label: label, value: value),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Fact extends StatelessWidget {
|
||||
const _Fact({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = ZenStyle.of(context);
|
||||
final text = Theme.of(context).textTheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(s.radius * 0.7)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: text.bodySmall?.copyWith(color: s.inkMuted)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value, style: text.titleSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
const _Section({required this.title, required this.child});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = Theme.of(context).textTheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: text.titleMedium),
|
||||
const SizedBox(height: 10),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Main muscles in the Mode's accent, the others quiet.
|
||||
class _MuscleChips extends StatelessWidget {
|
||||
const _MuscleChips({required this.muscles, required this.strong});
|
||||
|
||||
final List<String> muscles;
|
||||
final bool strong;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = ZenStyle.of(context);
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final m in muscles)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: strong ? s.accent.withValues(alpha: 0.16) : s.surfaceSoft,
|
||||
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 16)),
|
||||
),
|
||||
child: Text(
|
||||
sentenceCase(m),
|
||||
style: TextStyle(color: strong ? s.accent : s.ink, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../design/zen_style.dart';
|
||||
import '../../localization/app_localizations.dart';
|
||||
import '../exercise.dart';
|
||||
import '../exercise_labels.dart';
|
||||
import '../library_filter.dart';
|
||||
|
||||
/// Which of the three filters a sheet edits.
|
||||
enum FilterKind { muscle, equipment, level }
|
||||
|
||||
/// A bottom sheet of choices for one filter. Changes apply as they are made;
|
||||
/// the button shows how many Exercises match and closes the sheet.
|
||||
Future<void> showFilterSheet(
|
||||
BuildContext context, {
|
||||
required FilterKind kind,
|
||||
required FilterOptions options,
|
||||
required List<Exercise> exercises,
|
||||
required ValueNotifier<LibraryFilter> filter,
|
||||
}) => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _FilterSheet(kind: kind, options: options, exercises: exercises, filter: filter),
|
||||
);
|
||||
|
||||
class _FilterSheet extends StatelessWidget {
|
||||
const _FilterSheet({required this.kind, required this.options, required this.exercises, required this.filter});
|
||||
|
||||
final FilterKind kind;
|
||||
final FilterOptions options;
|
||||
final List<Exercise> exercises;
|
||||
final ValueNotifier<LibraryFilter> filter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final s = ZenStyle.of(context);
|
||||
final text = Theme.of(context).textTheme;
|
||||
final (title, values) = switch (kind) {
|
||||
FilterKind.muscle => (l10n.filterMuscle, options.muscles),
|
||||
FilterKind.equipment => (l10n.filterEquipment, options.equipment),
|
||||
FilterKind.level => (l10n.filterLevel, options.levels),
|
||||
};
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: filter,
|
||||
builder: (context, current, _) {
|
||||
final selected = switch (kind) {
|
||||
FilterKind.muscle => current.muscles,
|
||||
FilterKind.equipment => current.equipment,
|
||||
FilterKind.level => current.levels,
|
||||
};
|
||||
void toggle(String value) => filter.value = switch (kind) {
|
||||
FilterKind.muscle => current.toggleMuscle(value),
|
||||
FilterKind.equipment => current.toggleEquipment(value),
|
||||
FilterKind.level => current.toggleLevel(value),
|
||||
};
|
||||
final count = current.apply(exercises).length;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: text.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final value in values)
|
||||
FilterChip(
|
||||
label: Text(sentenceCase(value)),
|
||||
selected: selected.contains(value),
|
||||
onSelected: (_) => toggle(value),
|
||||
selectedColor: s.accent,
|
||||
checkmarkColor: s.onAccent,
|
||||
labelStyle: TextStyle(
|
||||
color: selected.contains(value) ? s.onAccent : s.ink,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.showResults(count)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../design/zen_style.dart';
|
||||
import '../../localization/app_localizations.dart';
|
||||
import '../exercise.dart';
|
||||
|
||||
/// An Exercise's start and end image, alternating at the Mode's pace. With
|
||||
/// reduced motion, or a single image, it stays on the start image.
|
||||
class FlipAnimation extends StatefulWidget {
|
||||
const FlipAnimation({super.key, required this.exercise});
|
||||
|
||||
final Exercise exercise;
|
||||
|
||||
@override
|
||||
State<FlipAnimation> createState() => FlipAnimationState();
|
||||
}
|
||||
|
||||
class FlipAnimationState extends State<FlipAnimation> {
|
||||
Timer? _timer;
|
||||
Duration? _interval;
|
||||
var _frame = 0;
|
||||
|
||||
/// The image showing: 0 for the start position, 1 for the end.
|
||||
int get frame => _frame;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final images = widget.exercise.images;
|
||||
for (final path in images) {
|
||||
precacheImage(AssetImage(path), context);
|
||||
}
|
||||
final interval = ZenStyle.of(context).flipInterval;
|
||||
final still = images.length < 2 || MediaQuery.disableAnimationsOf(context);
|
||||
if (still) {
|
||||
_stop();
|
||||
_frame = 0;
|
||||
} else if (_timer == null || interval != _interval) {
|
||||
_stop();
|
||||
_interval = interval;
|
||||
_timer = Timer.periodic(interval, (_) => setState(() => _frame = 1 - _frame));
|
||||
}
|
||||
}
|
||||
|
||||
void _stop() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = ZenStyle.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final images = widget.exercise.images;
|
||||
return Semantics(
|
||||
image: true,
|
||||
label: '${widget.exercise.name}, ${_frame == 0 ? l10n.flipStart : l10n.flipEnd}',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(s.radius),
|
||||
child: ColoredBox(
|
||||
// The photos are shot on white; a white well keeps their edges invisible.
|
||||
color: Colors.white,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 2,
|
||||
child: images.isEmpty
|
||||
? Icon(Icons.image_not_supported_outlined, color: s.inkMuted, size: 40)
|
||||
: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: s.motion,
|
||||
switchInCurve: s.curve,
|
||||
switchOutCurve: s.curve,
|
||||
layoutBuilder: (current, previous) =>
|
||||
Stack(fit: StackFit.expand, children: [...previous, ?current]),
|
||||
child: Image.asset(
|
||||
images[_frame],
|
||||
key: ValueKey(_frame),
|
||||
fit: BoxFit.contain,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
),
|
||||
if (images.length > 1) Positioned(right: 12, bottom: 12, child: _FrameDots(frame: _frame)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two small marks showing whether the start or end position is up.
|
||||
class _FrameDots extends StatelessWidget {
|
||||
const _FrameDots({required this.frame});
|
||||
|
||||
final int frame;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = ZenStyle.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
for (var i = 0; i < 2; i++)
|
||||
AnimatedContainer(
|
||||
duration: s.motion,
|
||||
curve: s.curve,
|
||||
margin: const EdgeInsets.only(left: 6),
|
||||
width: i == frame ? 18 : 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: i == frame ? s.accent : s.line,
|
||||
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 4)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../design/page_header.dart';
|
||||
import '../../design/zen_nav_bar.dart';
|
||||
import '../../design/zen_style.dart';
|
||||
import '../../localization/app_localizations.dart';
|
||||
@@ -9,8 +8,10 @@ import '../../settings/settings_controller.dart';
|
||||
import '../exercise.dart';
|
||||
import '../exercise_labels.dart';
|
||||
import '../exercise_library.dart';
|
||||
import 'exercise_browser.dart';
|
||||
import 'exercise_detail_view.dart';
|
||||
|
||||
/// The current Mode's Exercises, by name.
|
||||
/// The current Mode's Exercise Library; an Exercise opens its detail page.
|
||||
class LibraryView extends StatelessWidget {
|
||||
const LibraryView({super.key});
|
||||
|
||||
@@ -18,18 +19,14 @@ class LibraryView extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final mode = context.watch<SettingsController>().mode;
|
||||
final exercises = context.read<ExerciseLibrary>().forMode(mode);
|
||||
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||
return ListView.builder(
|
||||
key: PageStorageKey('library-${mode.name}'),
|
||||
padding: EdgeInsets.only(bottom: ZenNavBar.height + 32 + bottomInset),
|
||||
itemCount: exercises.length + 1,
|
||||
itemBuilder: (context, i) => i == 0
|
||||
? PageHeader(title: l10n.libraryTitle, subtitle: l10n.librarySubtitle(mode.name, exercises.length))
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||
child: ExerciseTile(exercise: exercises[i - 1]),
|
||||
),
|
||||
return ExerciseBrowser(
|
||||
// Each Mode has its own choices, so switching Mode starts a fresh search.
|
||||
key: ValueKey(mode),
|
||||
mode: mode,
|
||||
exercises: context.read<ExerciseLibrary>().forMode(mode),
|
||||
title: l10n.libraryTitle,
|
||||
bottomPadding: ZenNavBar.height + 32 + MediaQuery.paddingOf(context).bottom,
|
||||
onTap: (e) => Navigator.of(context).pushNamed(ExerciseDetailView.routeName, arguments: e.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,70 @@
|
||||
"plansEmptyBody": "{mode, select, stretching{Put together a stretch routine from the library and it will wait for you here.} other{Put together a workout from the library and it will wait for you here.}}",
|
||||
"@plansEmptyBody": {
|
||||
"placeholders": {
|
||||
"mode": {"type": "String"}
|
||||
"mode": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryTitle": "Library",
|
||||
"librarySubtitle": "{mode, select, stretching{{count, plural, =1{1 stretch} other{{count} stretches}}} other{{count, plural, =1{1 exercise} other{{count} exercises}}}}",
|
||||
"@librarySubtitle": {
|
||||
"placeholders": {
|
||||
"mode": {"type": "String"},
|
||||
"count": {"type": "int"}
|
||||
"mode": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyTitle": "History",
|
||||
"historyEmptyTitle": "Nothing logged yet",
|
||||
"historyEmptyBody": "Finished sessions show up here with their date and duration."
|
||||
"historyEmptyBody": "Finished sessions show up here with their date and duration.",
|
||||
"librarySubtitleFiltered": "{mode, select, stretching{{shown} of {total} stretches} other{{shown} of {total} exercises}}",
|
||||
"@librarySubtitleFiltered": {
|
||||
"placeholders": {
|
||||
"mode": {
|
||||
"type": "String"
|
||||
},
|
||||
"shown": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"searchHint": "{mode, select, stretching{Search stretches} other{Search exercises}}",
|
||||
"@searchHint": {
|
||||
"placeholders": {
|
||||
"mode": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"searchClear": "Clear search",
|
||||
"filterMuscle": "Muscle",
|
||||
"filterEquipment": "Equipment",
|
||||
"filterLevel": "Level",
|
||||
"clearFilters": "Clear filters",
|
||||
"showResults": "{count, plural, =0{No results} =1{Show 1 result} other{Show {count} results}}",
|
||||
"@showResults": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"noMatchesTitle": "No matches",
|
||||
"noMatchesBody": "Try fewer words, or clear the filters to see everything again.",
|
||||
"detailMainMuscles": "Main muscles",
|
||||
"detailAlsoWorks": "Also works",
|
||||
"detailHowTo": "How to do it",
|
||||
"detailLevel": "Level",
|
||||
"detailEquipment": "Equipment",
|
||||
"detailType": "Type",
|
||||
"equipmentNone": "None listed",
|
||||
"flipStart": "Start position",
|
||||
"flipEnd": "End position"
|
||||
}
|
||||
@@ -183,6 +183,120 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Finished sessions show up here with their date and duration.'**
|
||||
String get historyEmptyBody;
|
||||
|
||||
/// No description provided for @librarySubtitleFiltered.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{mode, select, stretching{{shown} of {total} stretches} other{{shown} of {total} exercises}}'**
|
||||
String librarySubtitleFiltered(String mode, int shown, int total);
|
||||
|
||||
/// No description provided for @searchHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{mode, select, stretching{Search stretches} other{Search exercises}}'**
|
||||
String searchHint(String mode);
|
||||
|
||||
/// No description provided for @searchClear.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear search'**
|
||||
String get searchClear;
|
||||
|
||||
/// No description provided for @filterMuscle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Muscle'**
|
||||
String get filterMuscle;
|
||||
|
||||
/// No description provided for @filterEquipment.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Equipment'**
|
||||
String get filterEquipment;
|
||||
|
||||
/// No description provided for @filterLevel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Level'**
|
||||
String get filterLevel;
|
||||
|
||||
/// No description provided for @clearFilters.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear filters'**
|
||||
String get clearFilters;
|
||||
|
||||
/// No description provided for @showResults.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =0{No results} =1{Show 1 result} other{Show {count} results}}'**
|
||||
String showResults(int count);
|
||||
|
||||
/// No description provided for @noMatchesTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No matches'**
|
||||
String get noMatchesTitle;
|
||||
|
||||
/// No description provided for @noMatchesBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Try fewer words, or clear the filters to see everything again.'**
|
||||
String get noMatchesBody;
|
||||
|
||||
/// No description provided for @detailMainMuscles.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Main muscles'**
|
||||
String get detailMainMuscles;
|
||||
|
||||
/// No description provided for @detailAlsoWorks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Also works'**
|
||||
String get detailAlsoWorks;
|
||||
|
||||
/// No description provided for @detailHowTo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How to do it'**
|
||||
String get detailHowTo;
|
||||
|
||||
/// No description provided for @detailLevel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Level'**
|
||||
String get detailLevel;
|
||||
|
||||
/// No description provided for @detailEquipment.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Equipment'**
|
||||
String get detailEquipment;
|
||||
|
||||
/// No description provided for @detailType.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Type'**
|
||||
String get detailType;
|
||||
|
||||
/// No description provided for @equipmentNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'None listed'**
|
||||
String get equipmentNone;
|
||||
|
||||
/// No description provided for @flipStart.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Start position'**
|
||||
String get flipStart;
|
||||
|
||||
/// No description provided for @flipEnd.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'End position'**
|
||||
String get flipEnd;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -79,4 +79,83 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get historyEmptyBody =>
|
||||
'Finished sessions show up here with their date and duration.';
|
||||
|
||||
@override
|
||||
String librarySubtitleFiltered(String mode, int shown, int total) {
|
||||
String _temp0 = intl.Intl.selectLogic(mode, {
|
||||
'stretching': '$shown of $total stretches',
|
||||
'other': '$shown of $total exercises',
|
||||
});
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String searchHint(String mode) {
|
||||
String _temp0 = intl.Intl.selectLogic(mode, {
|
||||
'stretching': 'Search stretches',
|
||||
'other': 'Search exercises',
|
||||
});
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchClear => 'Clear search';
|
||||
|
||||
@override
|
||||
String get filterMuscle => 'Muscle';
|
||||
|
||||
@override
|
||||
String get filterEquipment => 'Equipment';
|
||||
|
||||
@override
|
||||
String get filterLevel => 'Level';
|
||||
|
||||
@override
|
||||
String get clearFilters => 'Clear filters';
|
||||
|
||||
@override
|
||||
String showResults(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Show $count results',
|
||||
one: 'Show 1 result',
|
||||
zero: 'No results',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noMatchesTitle => 'No matches';
|
||||
|
||||
@override
|
||||
String get noMatchesBody =>
|
||||
'Try fewer words, or clear the filters to see everything again.';
|
||||
|
||||
@override
|
||||
String get detailMainMuscles => 'Main muscles';
|
||||
|
||||
@override
|
||||
String get detailAlsoWorks => 'Also works';
|
||||
|
||||
@override
|
||||
String get detailHowTo => 'How to do it';
|
||||
|
||||
@override
|
||||
String get detailLevel => 'Level';
|
||||
|
||||
@override
|
||||
String get detailEquipment => 'Equipment';
|
||||
|
||||
@override
|
||||
String get detailType => 'Type';
|
||||
|
||||
@override
|
||||
String get equipmentNone => 'None listed';
|
||||
|
||||
@override
|
||||
String get flipStart => 'Start position';
|
||||
|
||||
@override
|
||||
String get flipEnd => 'End position';
|
||||
}
|
||||
Reference in new issue
Block a user