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

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