screen-locker/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart
Krzysztof kuhy Rudnicki 38b56e1e2b workout_app: progression modes, inline warmup, injury pause, no app bar
- Per exercise: weight / reps / double progression (n=12, m=6), one pure
  rule shared by Finish and every preview; streaks and next step shown on
  the tile. Warmup is the first circle of the set row and can be turned off.
- Pause an exercise for n days (default 14): shown as paused, recorded as
  FAILED every workout so it steps down; skipped by the notification.
- App bar removed: Reset/Finish live in the rest strip, no rest labels.
- Schema v5 (progression_mode, reps_high, reps_low, has_warmup, paused_until),
  synced through the Firebase progression record; NULL warmup = plan default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF
2026-09-27 17:36:17 +02:00

184 lines
6.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Card widget for a single exercise: header with the progression-mode chip,
/// one row of warmup + set circles, and the progress line.
library;
import 'package:flutter/material.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/exercise_state.dart';
import 'package:workout_app/models/progression.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/exercise_settings_sheet.dart';
import 'package:workout_app/widgets/rep_circle.dart';
part 'exercise_tile_rows.dart';
/// Card widget displaying warmup and working-set rep circles for one exercise.
///
/// Its height is the same in every state — warmup on or off, any mode, any
/// streak, done or not — so the workout column never rescales mid-workout.
/// A disabled warmup leaves its slot empty rather than closing it up.
class ExerciseTile extends StatelessWidget {
/// Creates an [ExerciseTile].
const ExerciseTile({
required this.exercise,
required this.state,
required this.tapped,
required this.doneReps,
required this.warmupTapped,
required this.onTapCircle,
required this.onLongPressCircle,
required this.onTapWarmup,
required this.onSettingsChanged,
super.key,
});
/// The exercise definition to display (this session's targets).
final Exercise exercise;
/// Progression state: mode, streaks, thresholds, warmup toggle.
final ExerciseState state;
/// Per-set tap state; true when a set circle has been tapped.
final List<bool> tapped;
/// Per-set rep count; may be less than target after repeated taps.
final List<int> doneReps;
/// Whether the warmup circle has been tapped.
final bool warmupTapped;
/// Called when a working-set circle is tapped.
final void Function(int setIdx) onTapCircle;
/// Called when a working-set circle is long-pressed (resets to neutral).
final void Function(int setIdx) onLongPressCircle;
/// Called when the warmup circle is tapped.
final VoidCallback onTapWarmup;
/// Called with the edited state after every change in the settings sheet.
final ValueChanged<ExerciseState> onSettingsChanged;
/// Gap between circles in the set row.
static const double circleGap = 8;
/// Height of the set row; the paused placeholder takes exactly this.
static const double setRowHeight = 52;
bool get _allCompleted => tapped.every((t) => t);
bool get _allSucceeded =>
_allCompleted && doneReps.every((r) => r >= exercise.reps);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final paused = state.isPausedAt(DateTime.now());
var headerColor = colorScheme.surfaceContainerHigh;
// A paused card never turns green/red: its taps no longer count.
if (_allCompleted && !paused) {
headerColor = _allSucceeded ? status.success : colorScheme.error;
}
// A filled success/danger card needs on-fill text throughout (tokens.md:
// one on-fill value for all four fills, never a per-fill judgment call).
final filled = _allCompleted && !paused;
final onHeaderMuted = filled
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant;
// Paused: the whole header recedes to the muted ink.
final onHeader = filled
? colorScheme.onPrimary
: paused
? onHeaderMuted
: colorScheme.onSurface;
return Card(
color: headerColor,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
exercise.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: onHeader,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.body,
),
),
),
const SizedBox(width: 6),
_ModeChip(
state: state,
color: onHeaderMuted,
onTap: () => showExerciseSettings(context),
),
const SizedBox(width: 8),
Text(
'${exercise.sets}×${exercise.reps}×${exercise.weight}kg',
style: TextStyle(
color: onHeaderMuted,
fontSize: AppTextSize.label,
),
),
],
),
const SizedBox(height: 8),
// A Row, not a Wrap: a set row that spilled onto a second line
// would change the tile's height, and a Row overflowing fails
// loudly in tests instead.
if (paused)
_PausedRow(until: state.pausedUntil!, color: onHeaderMuted)
else
Row(
children: [
Visibility(
visible: state.hasWarmup,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: _WarmupCircle(
semanticLabel: '${exercise.name} warmup',
warmupWeight: exercise.warmupWeight,
tapped: warmupTapped,
onTap: onTapWarmup,
),
),
for (var s = 0; s < exercise.sets; s++) ...[
const SizedBox(width: circleGap),
RepCircle(
semanticLabel: '${exercise.name} set ${s + 1}',
targetReps: exercise.reps,
doneReps: doneReps[s],
tapped: tapped[s],
onTap: () => onTapCircle(s),
onLongPress: () => onLongPressCircle(s),
),
],
],
),
// Color inherited from the shared dividerTheme (line-dark).
const Divider(height: 20),
_ProgressRow(state: state, onFill: filled),
],
),
),
);
}
/// Opens the settings sheet for this exercise.
Future<void> showExerciseSettings(BuildContext context) =>
showExerciseSettingsSheet(
context,
state: state,
onChanged: onSettingsChanged,
);
}