Stage 1: project setup, Exercise Library, both Mode designs and the Mode switch

- Flutter Android app on CookIT's stack (provider, shared_preferences, gen-l10n)
- tools/exercise-import turns free-exercise-db into 876 bundled Exercises
  with 1,746 WebP images (20 MB); stretching -> Stretching, rest -> Workout
- ZenStyle tokens per Mode: calm rounded sage/teal with breath ripples vs.
  dark ember-to-red with floor-tape slashes; the theme morphs at each
  Mode's own pace
- Remembered global Mode switch, Plans / Library / History tabs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-28 23:18:03 +01:00
1 parent 5dfa995d55
commit 1289fadb98
1822 files changed
+2819

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'src/app.dart';
import 'src/exercises/exercise_library.dart';
import 'src/settings/settings_controller.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
final library = await ExerciseLibrary.load(rootBundle);
final settings = SettingsController(SettingsService());
await settings.loadSettings();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: settings),
Provider.value(value: library),
],
child: const ZenXerciseApp(),
),
);
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
import 'design/app_theme.dart';
import 'design/zen_style.dart';
import 'home/home_view.dart';
import 'localization/app_localizations.dart';
import 'settings/settings_controller.dart';
class ZenXerciseApp extends StatelessWidget {
const ZenXerciseApp({super.key});
@override
Widget build(BuildContext context) {
final mode = context.watch<SettingsController>().mode;
// The whole app morphs into the new Mode at that Mode's own pace.
final style = ZenStyle.forMode(mode);
return MaterialApp(
theme: AppTheme.forMode(mode),
themeAnimationDuration: style.motion,
themeAnimationCurve: style.curve,
restorationScopeId: 'app',
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
onGenerateRoute: (settings) =>
MaterialPageRoute<Object?>(settings: settings, builder: (context) => const HomeView()),
);
}
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import '../modes/mode.dart';
import 'zen_style.dart';
/// One fixed theme per Mode, built from its [ZenStyle]: there is no separate
/// light/dark setting.
class AppTheme {
static ThemeData forMode(Mode mode) => _build(ZenStyle.forMode(mode));
static ThemeData _build(ZenStyle s) {
final workout = s.mode == Mode.workout;
final scheme = ColorScheme.fromSeed(seedColor: s.accent, brightness: s.brightness).copyWith(
primary: s.accent,
onPrimary: s.onAccent,
secondaryContainer: s.surfaceSoft,
onSecondaryContainer: s.ink,
surface: s.background,
onSurface: s.ink,
onSurfaceVariant: s.inkMuted,
surfaceContainerLow: s.surface,
surfaceContainerHighest: s.surfaceSoft,
outline: s.line,
outlineVariant: s.line,
);
final base = ThemeData(colorScheme: scheme, useMaterial3: true, fontFamily: s.bodyFont);
final text = base.textTheme.apply(bodyColor: s.ink, displayColor: s.ink);
// Barlow Condensed is narrow, so Workout headings run larger and tighter.
TextStyle? heading(TextStyle? t, FontWeight w, {double scale = 1}) => t?.copyWith(
fontFamily: s.headingFont,
fontWeight: w,
fontSize: t.fontSize == null ? null : t.fontSize! * (workout ? scale : 1),
letterSpacing: workout ? -0.2 : 0,
height: workout ? 1.0 : null,
);
final round = BorderRadius.circular(s.radius);
final control = BorderRadius.circular(s.controlRadius);
return base.copyWith(
extensions: [s],
scaffoldBackgroundColor: s.background,
textTheme: text.copyWith(
displaySmall: heading(text.displaySmall, workout ? FontWeight.w800 : FontWeight.w900, scale: 1.25),
headlineLarge: heading(text.headlineLarge, workout ? FontWeight.w800 : FontWeight.w900, scale: 1.3),
headlineMedium: heading(text.headlineMedium, workout ? FontWeight.w800 : FontWeight.w900, scale: 1.3),
headlineSmall: heading(text.headlineSmall, workout ? FontWeight.w700 : FontWeight.w800, scale: 1.2),
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),
labelLarge: text.labelLarge?.copyWith(fontWeight: FontWeight.w700),
),
appBarTheme: AppBarTheme(
backgroundColor: s.background,
surfaceTintColor: Colors.transparent,
foregroundColor: s.ink,
centerTitle: false,
),
cardTheme: CardThemeData(
elevation: 0,
color: s.surface,
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: round),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: s.surface,
border: OutlineInputBorder(borderRadius: control, borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: control,
borderSide: BorderSide(color: s.accent, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: s.accent,
foregroundColor: s.onAccent,
shape: RoundedRectangleBorder(borderRadius: control),
textStyle: TextStyle(fontFamily: s.bodyFont, fontWeight: FontWeight.w700),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: s.ink,
side: BorderSide(color: s.line, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: control),
),
),
chipTheme: ChipThemeData(
backgroundColor: s.surfaceSoft,
side: BorderSide.none,
shape: RoundedRectangleBorder(borderRadius: control),
labelStyle: TextStyle(color: s.ink, fontWeight: FontWeight.w600),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: s.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(s.radius))),
),
dialogTheme: DialogThemeData(
backgroundColor: s.surface,
shape: RoundedRectangleBorder(borderRadius: round),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
insetPadding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
shape: RoundedRectangleBorder(borderRadius: control),
),
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../modes/mode.dart';
import 'zen_style.dart';
/// Each Mode's one signature mark, drawn behind the top of every page:
/// breath ripples for Stretching, floor-tape slashes for Workout.
class ModeBackdrop extends StatelessWidget {
const ModeBackdrop({super.key});
static const height = 260.0;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
return IgnorePointer(
child: AnimatedSwitcher(
duration: s.motion,
switchInCurve: s.curve,
switchOutCurve: s.curve,
child: SizedBox(
key: ValueKey(s.mode),
height: height,
width: double.infinity,
child: CustomPaint(
painter: switch (s.mode) {
Mode.stretching => _RipplePainter(s.line, s.accent),
Mode.workout => _SlashPainter(s.accent, s.accentEnd),
},
),
),
),
);
}
}
/// Concentric rings spreading from beyond the top-right corner, fading as they
/// widen, like a slow exhale.
class _RipplePainter extends CustomPainter {
_RipplePainter(this.line, this.accent);
final Color line;
final Color accent;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width * 0.92, size.height * 0.08);
const rings = 6;
for (var i = 0; i < rings; i++) {
final t = i / (rings - 1);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.6 - t * 0.8
..color = Color.lerp(accent, line, t)!.withValues(alpha: 0.55 - t * 0.4);
canvas.drawCircle(center, 36 + i * 30 + i * i * 3.5, paint);
}
canvas.drawCircle(center, 24, Paint()..color = accent.withValues(alpha: 0.12));
}
@override
bool shouldRepaint(_RipplePainter old) => old.line != line || old.accent != accent;
}
/// Three parallel bars cut diagonally across the top-right, like tape on a gym
/// floor, in the ember-to-red gradient.
class _SlashPainter extends CustomPainter {
_SlashPainter(this.accent, this.accentEnd);
final Color accent;
final Color accentEnd;
@override
void paint(Canvas canvas, Size size) {
canvas.save();
canvas.translate(size.width * 0.78, -size.height * 0.05);
canvas.rotate(math.pi / 5);
const widths = [22.0, 10.0, 4.0];
var x = 0.0;
for (final (i, w) in widths.indexed) {
final rect = Rect.fromLTWH(x, -40, w, size.height * 1.1);
final paint = Paint()
..shader = LinearGradient(
colors: [accent, accentEnd.withValues(alpha: 0)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
).createShader(rect)
..color = Colors.white.withValues(alpha: 1 - i * 0.25);
canvas.drawRect(rect, paint);
x += w + 12;
}
canvas.restore();
}
@override
bool shouldRepaint(_SlashPainter old) => old.accent != accent || old.accentEnd != accentEnd;
}
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../localization/app_localizations.dart';
import '../modes/mode.dart';
import 'zen_style.dart';
/// The global Mode switch at the top of the app: a track with a thumb that
/// slides under the current Mode, at that Mode's pace.
class ModeSwitch extends StatelessWidget {
const ModeSwitch({super.key, required this.mode, required this.onChanged});
static const height = 44.0;
final Mode mode;
final ValueChanged<Mode> onChanged;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final l10n = AppLocalizations.of(context);
final radius = BorderRadius.circular(s.controlRadius.clamp(0, height / 2));
return Semantics(
label: l10n.modeSwitchLabel,
child: Container(
height: height,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: s.surfaceSoft, borderRadius: radius),
child: Stack(
children: [
AnimatedAlign(
alignment: mode == Mode.stretching ? Alignment.centerLeft : Alignment.centerRight,
duration: s.motion,
curve: s.curve,
child: FractionallySizedBox(
widthFactor: 0.5,
heightFactor: 1,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: s.accentGradient,
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, (height - 8) / 2)),
),
),
),
),
Row(
children: [
_Segment(
icon: Icons.self_improvement_rounded,
label: l10n.modeStretching,
selected: mode == Mode.stretching,
onTap: () => onChanged(Mode.stretching),
),
_Segment(
icon: Icons.fitness_center_rounded,
label: l10n.modeWorkout,
selected: mode == Mode.workout,
onTap: () => onChanged(Mode.workout),
),
],
),
],
),
),
);
}
}
class _Segment extends StatelessWidget {
const _Segment({required this.icon, required this.label, required this.selected, required this.onTap});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final color = selected ? s.onAccent : s.inkMuted;
return Expanded(
child: Semantics(
button: true,
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: selected ? null : onTap,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: s.headingFont,
fontWeight: FontWeight.w800,
fontSize: s.mode == Mode.workout ? 17 : 15,
color: color,
),
),
),
],
),
),
),
);
}
}
+69
View File
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'zen_style.dart';
/// A tab's big title and an optional line under it.
class PageHeader extends StatelessWidget {
const PageHeader({super.key, required this.title, this.subtitle});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: text.headlineLarge),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(subtitle!, style: text.bodyLarge?.copyWith(color: s.inkMuted)),
],
],
),
);
}
}
/// What an empty tab shows: what will appear here and how to get it there.
class EmptyState extends StatelessWidget {
const EmptyState({super.key, required this.icon, required this.title, required this.body});
final IconData icon;
final String title;
final String body;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: s.surfaceSoft,
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 28)),
),
child: Icon(icon, color: s.accent),
),
const SizedBox(height: 16),
Text(title, style: text.titleLarge),
const SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 340),
child: Text(body, style: text.bodyMedium?.copyWith(color: s.inkMuted)),
),
],
),
);
}
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'zen_style.dart';
class NavDestination {
const NavDestination({required this.icon, required this.label, required this.onTap});
final IconData icon;
final String label;
final VoidCallback onTap;
}
/// The floating bar at the bottom: the selected destination is a filled pill
/// with its label, the others are icons. Its corners and fill follow the Mode.
class ZenNavBar extends StatelessWidget {
const ZenNavBar({super.key, required this.destinations, required this.selected});
static const height = 64.0;
final List<NavDestination> destinations;
final int selected;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final outer = BorderRadius.circular(s.controlRadius.clamp(0, height / 2));
final inner = BorderRadius.circular(s.controlRadius.clamp(0, 22));
return AnimatedContainer(
duration: s.motion,
curve: s.curve,
height: height,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: s.navBar,
borderRadius: outer,
// On the dark Workout ground a hairline separates the bar; a light shadow would glow.
border: s.brightness == Brightness.dark ? Border.all(color: s.line) : null,
boxShadow: [
BoxShadow(
color: s.brightness == Brightness.dark
? Colors.black.withValues(alpha: 0.4)
: s.ink.withValues(alpha: 0.07),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
for (final (i, d) in destinations.indexed)
Expanded(
// The selected pill carries a label, so it gets more of the width.
flex: i == selected ? 2 : 1,
child: Center(
child: i == selected
? Semantics(
selected: true,
button: true,
child: DecoratedBox(
decoration: BoxDecoration(gradient: s.accentGradient, borderRadius: inner),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(d.icon, size: 20, color: s.onAccent),
const SizedBox(width: 8),
Flexible(
child: Text(
d.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: s.headingFont,
color: s.onAccent,
fontWeight: FontWeight.w800,
fontSize: 15,
),
),
),
],
),
),
),
)
: IconButton(
tooltip: d.label,
onPressed: d.onTap,
icon: Icon(d.icon, color: s.inkMuted),
),
),
),
],
),
);
}
}
+150
View File
@@ -0,0 +1,150 @@
import 'dart:ui' show lerpDouble;
import 'package:flutter/material.dart';
import '../modes/mode.dart';
/// Everything that differs between the two Modes' designs: palette, shape,
/// motion and type. Read it with `ZenStyle.of(context)`.
@immutable
class ZenStyle extends ThemeExtension<ZenStyle> {
const ZenStyle({
required this.mode,
required this.brightness,
required this.background,
required this.surface,
required this.surfaceSoft,
required this.ink,
required this.inkMuted,
required this.accent,
required this.accentEnd,
required this.onAccent,
required this.line,
required this.navBar,
required this.radius,
required this.controlRadius,
required this.motion,
required this.curve,
required this.headingFont,
required this.bodyFont,
});
final Mode mode;
final Brightness brightness;
/// Page ground behind the Mode's backdrop.
final Color background;
/// Cards, rows and the search field.
final Color surface;
/// Quiet fills: the Mode switch track, image wells, chips.
final Color surfaceSoft;
final Color ink;
final Color inkMuted;
/// The Mode's signal colour, and where its gradient runs to (the same colour
/// for Stretching, which has none).
final Color accent;
final Color accentEnd;
final Color onAccent;
/// Hairlines and the backdrop's strokes.
final Color line;
final Color navBar;
/// Corners of cards and sheets; [controlRadius] for buttons and the Mode switch.
final double radius;
final double controlRadius;
/// How long the Mode's transitions take, and how they ease: slow and soft
/// for Stretching, short and sharp for Workout.
final Duration motion;
final Curve curve;
final String headingFont;
final String bodyFont;
Gradient get accentGradient =>
LinearGradient(colors: [accent, accentEnd], begin: Alignment.topLeft, end: Alignment.bottomRight);
static const stretching = ZenStyle(
mode: Mode.stretching,
brightness: Brightness.light,
background: Color(0xFFEDF3EF),
surface: Color(0xFFFFFFFF),
surfaceSoft: Color(0xFFDCEAE2),
ink: Color(0xFF1E3832),
inkMuted: Color(0xFF587069),
accent: Color(0xFF2E8B80),
accentEnd: Color(0xFF2E8B80),
onAccent: Color(0xFFFFFFFF),
line: Color(0xFFA8C9B4),
navBar: Color(0xFFD3E5DA),
radius: 28,
controlRadius: 999,
motion: Duration(milliseconds: 700),
curve: Curves.easeInOutSine,
headingFont: 'Nunito',
bodyFont: 'NunitoSans',
);
static const workout = ZenStyle(
mode: Mode.workout,
brightness: Brightness.dark,
background: Color(0xFF15171B),
surface: Color(0xFF1F2228),
surfaceSoft: Color(0xFF2A2E35),
ink: Color(0xFFF3F0EA),
inkMuted: Color(0xFF9BA1AA),
accent: Color(0xFFFF6A2B),
accentEnd: Color(0xFFE8322A),
onAccent: Color(0xFF1A0E08),
line: Color(0xFF3A3F47),
navBar: Color(0xFF23262C),
radius: 6,
controlRadius: 4,
motion: Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
headingFont: 'BarlowCondensed',
bodyFont: 'Barlow',
);
static ZenStyle forMode(Mode mode) => switch (mode) {
Mode.stretching => stretching,
Mode.workout => workout,
};
static ZenStyle of(BuildContext context) => Theme.of(context).extension<ZenStyle>() ?? stretching;
@override
ZenStyle copyWith() => this;
/// Colours and corners blend while the Mode changes; type, motion and the
/// Mode itself switch at the halfway point.
@override
ZenStyle lerp(ZenStyle? other, double t) {
if (other == null) return this;
final discrete = t < 0.5 ? this : other;
Color c(Color a, Color b) => Color.lerp(a, b, t)!;
return ZenStyle(
mode: discrete.mode,
brightness: discrete.brightness,
background: c(background, other.background),
surface: c(surface, other.surface),
surfaceSoft: c(surfaceSoft, other.surfaceSoft),
ink: c(ink, other.ink),
inkMuted: c(inkMuted, other.inkMuted),
accent: c(accent, other.accent),
accentEnd: c(accentEnd, other.accentEnd),
onAccent: c(onAccent, other.onAccent),
line: c(line, other.line),
navBar: c(navBar, other.navBar),
radius: lerpDouble(radius, other.radius, t)!,
controlRadius: lerpDouble(controlRadius.clamp(0, 40), other.controlRadius.clamp(0, 40), t)!,
motion: discrete.motion,
curve: discrete.curve,
headingFont: discrete.headingFont,
bodyFont: discrete.bodyFont,
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import '../modes/mode.dart';
/// A single movement from the bundled Exercise Library. Belongs to exactly one Mode.
class Exercise {
const Exercise({
required this.id,
required this.name,
required this.mode,
required this.category,
required this.level,
this.equipment,
this.force,
this.mechanic,
required this.primaryMuscles,
required this.secondaryMuscles,
required this.instructions,
required this.imageCount,
});
factory Exercise.fromJson(Map<String, Object?> json) => Exercise(
id: json['id']! as String,
name: json['name']! as String,
mode: Mode.fromName(json['mode'] as String?),
category: json['category']! as String,
level: json['level']! as String,
equipment: json['equipment'] as String?,
force: json['force'] as String?,
mechanic: json['mechanic'] as String?,
primaryMuscles: (json['primaryMuscles']! as List).cast<String>(),
secondaryMuscles: (json['secondaryMuscles']! as List).cast<String>(),
instructions: (json['instructions']! as List).cast<String>(),
imageCount: json['imageCount']! as int,
);
final String id;
final String name;
final Mode mode;
/// The free-exercise-db category, such as `strength` or `plyometrics`.
final String category;
/// `beginner`, `intermediate` or `expert`.
final String level;
final String? equipment;
final String? force;
final String? mechanic;
final List<String> primaryMuscles;
final List<String> secondaryMuscles;
final List<String> instructions;
final int imageCount;
/// Asset paths of the start and end image (none for a few Exercises).
List<String> get images => [for (var i = 0; i < imageCount; i++) 'assets/exercises/images/${id}_$i.webp'];
}
+2
View File
@@ -0,0 +1,2 @@
/// Library values such as `lower back` or `body only` as shown to the user.
String sentenceCase(String value) => value.isEmpty ? value : value[0].toUpperCase() + value.substring(1);
+29
View File
@@ -0,0 +1,29 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../modes/mode.dart';
import 'exercise.dart';
/// The read-only catalogue of Exercises bundled with the app, sorted by name.
class ExerciseLibrary {
ExerciseLibrary(List<Exercise> exercises)
: _byMode = {for (final mode in Mode.values) mode: exercises.where((e) => e.mode == mode).toList()},
_byId = {for (final e in exercises) e.id: e};
factory ExerciseLibrary.fromJson(String json) => ExerciseLibrary([
for (final e in jsonDecode(json) as List) Exercise.fromJson((e as Map).cast<String, Object?>()),
]);
static const assetPath = 'assets/exercises/exercises.json';
static Future<ExerciseLibrary> load(AssetBundle bundle) async =>
ExerciseLibrary.fromJson(await bundle.loadString(assetPath));
final Map<Mode, List<Exercise>> _byMode;
final Map<String, Exercise> _byId;
List<Exercise> forMode(Mode mode) => _byMode[mode]!;
Exercise? byId(String id) => _byId[id];
}
+108
View File
@@ -0,0 +1,108 @@
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';
import '../../settings/settings_controller.dart';
import '../exercise.dart';
import '../exercise_labels.dart';
import '../exercise_library.dart';
/// The current Mode's Exercises, by name.
class LibraryView extends StatelessWidget {
const LibraryView({super.key});
@override
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]),
),
);
}
}
class ExerciseTile extends StatelessWidget {
const ExerciseTile({super.key, required this.exercise, this.onTap});
final Exercise exercise;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
final muscles = exercise.primaryMuscles.map(sentenceCase).join(', ');
return Card(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
ExerciseThumbnail(exercise: exercise, size: 64),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(exercise.name, style: text.titleMedium, maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Text(
'$muscles · ${sentenceCase(exercise.level)}',
style: text.bodySmall?.copyWith(color: s.inkMuted),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
);
}
}
/// The start image in a soft well, or an icon for the few Exercises without images.
class ExerciseThumbnail extends StatelessWidget {
const ExerciseThumbnail({super.key, required this.exercise, required this.size});
final Exercise exercise;
final double size;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final ratio = MediaQuery.devicePixelRatioOf(context);
return ClipRRect(
borderRadius: BorderRadius.circular((s.radius * 0.6).clamp(0, size / 2)),
child: Container(
width: size,
height: size,
color: s.surfaceSoft,
child: exercise.images.isEmpty
? Icon(Icons.image_not_supported_outlined, color: s.inkMuted)
: Image.asset(
exercise.images.first,
fit: BoxFit.cover,
cacheWidth: (size * ratio).round(),
errorBuilder: (_, _, _) => Icon(Icons.image_not_supported_outlined, color: s.inkMuted),
),
),
);
}
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import '../../design/page_header.dart';
import '../../localization/app_localizations.dart';
/// The current Mode's finished Sessions.
class HistoryView extends StatelessWidget {
const HistoryView({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return ListView(
children: [
PageHeader(title: l10n.historyTitle),
const SizedBox(height: 24),
EmptyState(icon: Icons.history_rounded, title: l10n.historyEmptyTitle, body: l10n.historyEmptyBody),
],
);
}
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../design/mode_backdrop.dart';
import '../design/mode_switch.dart';
import '../design/zen_nav_bar.dart';
import '../exercises/views/library_view.dart';
import '../history/views/history_view.dart';
import '../localization/app_localizations.dart';
import '../plans/views/plans_view.dart';
import '../settings/settings_controller.dart';
/// The Mode switch on top, the current Mode's Plans, Library and History tabs
/// below, and the floating navigation bar.
class HomeView extends StatefulWidget {
const HomeView({super.key});
static const routeName = '/';
@override
State<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends State<HomeView> {
var _tab = 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final settings = context.watch<SettingsController>();
final bottomInset = MediaQuery.paddingOf(context).bottom;
return PopScope(
// Back on another tab returns to the Plans first.
canPop: _tab == 0,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) setState(() => _tab = 0);
},
child: Scaffold(
body: Stack(
children: [
const ModeBackdrop(),
SafeArea(
bottom: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: ModeSwitch(mode: settings.mode, onChanged: settings.updateMode),
),
),
],
),
),
Expanded(
child: IndexedStack(index: _tab, children: const [PlansView(), LibraryView(), HistoryView()]),
),
],
),
),
Positioned(
left: 16,
right: 16,
bottom: 16 + bottomInset,
child: ZenNavBar(
selected: _tab,
destinations: [
NavDestination(
icon: Icons.format_list_bulleted_rounded,
label: l10n.navPlans,
onTap: () => setState(() => _tab = 0),
),
NavDestination(
icon: Icons.grid_view_rounded,
label: l10n.navLibrary,
onTap: () => setState(() => _tab = 1),
),
NavDestination(
icon: Icons.history_rounded,
label: l10n.navHistory,
onTap: () => setState(() => _tab = 2),
),
],
),
),
],
),
),
);
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"@@locale": "en",
"appTitle": "ZenXercise",
"modeStretching": "Stretching",
"modeWorkout": "Workout",
"modeSwitchLabel": "Mode",
"navPlans": "Plans",
"navLibrary": "Library",
"navHistory": "History",
"plansTitle": "Your plans",
"plansEmptyTitle": "No plans yet",
"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"}
}
},
"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"}
}
},
"historyTitle": "History",
"historyEmptyTitle": "Nothing logged yet",
"historyEmptyBody": "Finished sessions show up here with their date and duration."
}
+218
View File
@@ -0,0 +1,218 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'localization/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, you’ll need to edit this
/// file.
///
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// project’s Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
}
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[Locale('en')];
/// No description provided for @appTitle.
///
/// In en, this message translates to:
/// **'ZenXercise'**
String get appTitle;
/// No description provided for @modeStretching.
///
/// In en, this message translates to:
/// **'Stretching'**
String get modeStretching;
/// No description provided for @modeWorkout.
///
/// In en, this message translates to:
/// **'Workout'**
String get modeWorkout;
/// No description provided for @modeSwitchLabel.
///
/// In en, this message translates to:
/// **'Mode'**
String get modeSwitchLabel;
/// No description provided for @navPlans.
///
/// In en, this message translates to:
/// **'Plans'**
String get navPlans;
/// No description provided for @navLibrary.
///
/// In en, this message translates to:
/// **'Library'**
String get navLibrary;
/// No description provided for @navHistory.
///
/// In en, this message translates to:
/// **'History'**
String get navHistory;
/// No description provided for @plansTitle.
///
/// In en, this message translates to:
/// **'Your plans'**
String get plansTitle;
/// No description provided for @plansEmptyTitle.
///
/// In en, this message translates to:
/// **'No plans yet'**
String get plansEmptyTitle;
/// No description provided for @plansEmptyBody.
///
/// In en, this message translates to:
/// **'{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.}}'**
String plansEmptyBody(String mode);
/// No description provided for @libraryTitle.
///
/// In en, this message translates to:
/// **'Library'**
String get libraryTitle;
/// No description provided for @librarySubtitle.
///
/// In en, this message translates to:
/// **'{mode, select, stretching{{count, plural, =1{1 stretch} other{{count} stretches}}} other{{count, plural, =1{1 exercise} other{{count} exercises}}}}'**
String librarySubtitle(String mode, int count);
/// No description provided for @historyTitle.
///
/// In en, this message translates to:
/// **'History'**
String get historyTitle;
/// No description provided for @historyEmptyTitle.
///
/// In en, this message translates to:
/// **'Nothing logged yet'**
String get historyEmptyTitle;
/// No description provided for @historyEmptyBody.
///
/// In en, this message translates to:
/// **'Finished sessions show up here with their date and duration.'**
String get historyEmptyBody;
}
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['en'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}
@@ -0,0 +1,82 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'ZenXercise';
@override
String get modeStretching => 'Stretching';
@override
String get modeWorkout => 'Workout';
@override
String get modeSwitchLabel => 'Mode';
@override
String get navPlans => 'Plans';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@override
String get plansTitle => 'Your plans';
@override
String get plansEmptyTitle => 'No plans yet';
@override
String plansEmptyBody(String mode) {
String _temp0 = intl.Intl.selectLogic(mode, {
'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.',
});
return '$_temp0';
}
@override
String get libraryTitle => 'Library';
@override
String librarySubtitle(String mode, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count stretches',
one: '1 stretch',
);
String _temp1 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count exercises',
one: '1 exercise',
);
String _temp2 = intl.Intl.selectLogic(mode, {
'stretching': '$_temp0',
'other': '$_temp1',
});
return '$_temp2';
}
@override
String get historyTitle => 'History';
@override
String get historyEmptyTitle => 'Nothing logged yet';
@override
String get historyEmptyBody =>
'Finished sessions show up here with their date and duration.';
}
+9
View File
@@ -0,0 +1,9 @@
/// The two top-level app states. Switching Mode changes the whole app: design,
/// Exercise Library, Plans and History.
enum Mode {
stretching,
workout;
/// The stored name, or Stretching for anything unknown.
static Mode fromName(String? name) => Mode.values.firstWhere((m) => m.name == name, orElse: () => Mode.stretching);
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/page_header.dart';
import '../../localization/app_localizations.dart';
import '../../settings/settings_controller.dart';
/// The current Mode's Plans. Users build every Plan themselves.
class PlansView extends StatelessWidget {
const PlansView({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final mode = context.watch<SettingsController>().mode;
return ListView(
children: [
PageHeader(title: l10n.plansTitle),
const SizedBox(height: 24),
EmptyState(icon: Icons.playlist_add_rounded, title: l10n.plansEmptyTitle, body: l10n.plansEmptyBody(mode.name)),
],
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../modes/mode.dart';
/// Stores the user's app preferences on the device.
class SettingsService {
static const _modeKey = 'mode';
/// The last-used Mode; Stretching on first launch.
Future<Mode> mode() async => Mode.fromName((await SharedPreferences.getInstance()).getString(_modeKey));
Future<void> updateMode(Mode mode) async => (await SharedPreferences.getInstance()).setString(_modeKey, mode.name);
}
class SettingsController extends ChangeNotifier {
SettingsController(this._service);
final SettingsService _service;
Mode _mode = Mode.stretching;
Mode get mode => _mode;
Future<void> loadSettings() async {
_mode = await _service.mode();
notifyListeners();
}
Future<void> updateMode(Mode mode) async {
if (mode == _mode) return;
_mode = mode;
notifyListeners();
await _service.updateMode(mode);
}
}