screen-locker/stronglift_replacement/workout_app/lib/widgets/rep_circle.dart
Krzysztof kuhy Rudnicki 83d77884a0 Workout screen never moves when a rest starts; a sandbox flavor to prove it on the phone
The rest banner used to be inserted into the column only while a break ran,
so its arrival took height from the exercise list, shifted every tile down
and pushed the last one into scroll range. The strip is now a permanently
reserved 64 dp slot (muted "Rest / 00:00" while idle, Skip keeps its space),
the list is a non-scrolling column that scales down as a whole if it would
not fit (0.96 on the Pixel 6a with four tiles, pinned by a widget test at
the phone's logical size with the status bar simulated), and the activity is
locked to portrait. Verified on the real phone: only the tapped circle's
pixels change when a rest starts.

Verifying that needed a workout screen that today's already-logged workout
did not offer, so the app gained a `sandbox` product flavor beside `daily`:
com.kuhy.workout_app.sandbox installs side by side with its own data, refuses
every network connection at the socket layer, starts no LAN server for the
PC to pull from, keeps its /sdcard files beside the daily build's, wears a
red SANDBOX ribbon, and offers a SANDBOX section in Settings (wipe, mark
today not done, rest length in seconds). MainActivity answers the flavor and
carries a `WorkoutSandbox` logcat trace of every tap, break, save and
navigation. On Linux the same mode is WORKOUT_SANDBOX=1, driven under Xvfb by
scripts/run_sandbox_xvfb.sh. CI builds --flavor daily; .phone-deploy-flavor
tells phone_deploy.sh the same.

Set circles and the home icon buttons carry semantics labels so they can be
addressed by element (and by screen readers).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YbBZqUaNWyhHYfzz6zGC7q
2026-09-20 18:55:08 +02:00

133 lines
3.9 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.

/// Tappable circle widget representing one set of an exercise.
///
/// States:
/// neutral – white, shows target reps, not yet acted upon
/// success – green, shows target reps (full set done)
/// partial – orange, shows how many reps were actually done (< target)
/// failed – red, shows 0 (all reps deducted)
///
/// Interaction:
/// single tap → neutral→success, success→partial(-1 rep),
/// partial→partial(-1 rep), failed stays failed
/// long press → reset to neutral
library;
import 'package:flutter/material.dart';
import 'package:workout_app/ui/theme.dart';
/// Visual state of a [RepCircle].
enum RepCircleState {
/// Not yet tapped; shows target reps.
neutral,
/// All reps completed; green.
success,
/// Some reps completed; orange, shows actual count.
partial,
/// All reps deducted; red.
failed,
}
/// Tappable circle representing one working set of an exercise.
class RepCircle extends StatelessWidget {
/// Creates a [RepCircle].
const RepCircle({
required this.targetReps,
required this.doneReps,
required this.tapped,
required this.onTap,
required this.onLongPress,
this.semanticLabel,
super.key,
});
/// Accessibility name, e.g. "Dumbbell Lunge set 2"; the digit inside the
/// circle is the same on every set, so without this a screen reader (or a
/// UI driver) cannot tell the circles apart.
final String? semanticLabel;
/// Number of reps the user is aiming for this set.
final int targetReps;
/// Reps currently registered (may be < targetReps after repeated taps).
final int doneReps;
/// Whether this circle has been tapped at all (neutral vs success).
final bool tapped;
/// Called on a single tap.
final VoidCallback onTap;
/// Called on a long press (resets to neutral).
final VoidCallback onLongPress;
RepCircleState get _state {
if (!tapped) return RepCircleState.neutral;
if (doneReps >= targetReps) return RepCircleState.success;
if (doneReps > 0) return RepCircleState.partial;
return RepCircleState.failed;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final state = _state;
// on-fill: this circle is a solid filled surface — text on it always
// needs dark (ink) text, never the page's near-white text-on-dark.
final fg = colorScheme.onPrimary;
final Color bg;
final String label;
switch (state) {
case RepCircleState.neutral:
// Not yet acted upon — a near-white fill (not pure Colors.white)
// still reads as "the bright/blank one" against the dark page.
bg = colorScheme.onSurface;
label = '$targetReps';
case RepCircleState.success:
bg = status.success;
label = '$targetReps';
case RepCircleState.partial:
bg = status.warning;
label = '$doneReps';
case RepCircleState.failed:
bg = colorScheme.error;
label = '0';
}
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: bg,
border: Border.all(
color: state == RepCircleState.neutral ? colorScheme.outline : bg,
width: 2,
),
// No shadow (rule 26: dark surfaces never shadow) — the fill
// color already differentiates this circle from the page.
),
alignment: Alignment.center,
child: Semantics(
label: semanticLabel,
child: Text(
label,
style: TextStyle(
color: fg,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.body,
),
),
),
),
);
}
}