mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +01:00
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
133 lines
3.9 KiB
Dart
133 lines
3.9 KiB
Dart
/// 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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|