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:
1 parent
5dfa995d55
commit
1289fadb98
1822 files changed
+2819
No files matched your search
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user