screen-locker/stronglift_replacement/workout_app/lib/widgets/break_banner.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

101 lines
3.3 KiB
Dart

/// Countdown strip permanently reserved at the top of the workout screen.
library;
import 'package:flutter/material.dart';
import 'package:workout_app/ui/theme.dart';
/// Fixed-height strip showing a rest countdown and a skip button.
///
/// The strip is always laid out, rest or no rest: an appearing banner used
/// to steal its height from the exercise list and shift every tile down
/// (2026-09-20). Between rests it renders the same shape muted, so starting
/// a rest changes colours and the number, never a position.
class BreakBanner extends StatelessWidget {
/// Creates a [BreakBanner].
const BreakBanner({
required this.active,
required this.breakRemaining,
required this.breakLabel,
required this.onSkip,
super.key,
});
/// Total strip height; a constant so the layout below never depends on
/// the text metrics of what the strip happens to show.
static const double height = 64;
/// Whether a rest is running. When false the strip is muted and Skip is
/// hidden (its space is kept).
final bool active;
/// Seconds remaining in the current break.
final int breakRemaining;
/// Display label for the break (e.g. 'Rest' or 'Warmup rest').
final String breakLabel;
/// Called when the user taps the Skip button.
final VoidCallback onSkip;
String _fmt(int secs) {
final m = (secs ~/ 60).toString().padLeft(2, '0');
final s = (secs % 60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
return Container(
height: height,
// Elevation via fill step (ink-raised-2), not a shadow — differentiates
// the banner from the page without a saturated attention-grabbing fill.
color: colorScheme.surfaceContainerHighest,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
breakLabel,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
Text(
_fmt(breakRemaining),
style: TextStyle(
// Warning (caution/pending) while the countdown runs;
// muted between rests so the idle strip recedes.
color: active
? status.warning
: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.title,
fontWeight: FontWeight.bold,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
),
Visibility(
visible: active,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: TextButton(
onPressed: onSkip,
child: Text('Skip', style: TextStyle(color: colorScheme.primary)),
),
),
],
),
);
}
}