mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
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
This commit is contained in:
parent
9ab7c09c79
commit
83d77884a0
6
.github/workflows/release-apk.yml
vendored
6
.github/workflows/release-apk.yml
vendored
@ -96,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Build signed APK
|
||||
run: |
|
||||
flutter build apk --release \
|
||||
flutter build apk --release --flavor daily \
|
||||
--build-name="${{ steps.ver.outputs.semver }}" \
|
||||
--build-number="${{ steps.vc.outputs.code }}"
|
||||
|
||||
@ -106,7 +106,7 @@ jobs:
|
||||
- name: Verify the APK is release-signed
|
||||
run: |
|
||||
set -euo pipefail
|
||||
apk=build/app/outputs/flutter-apk/app-release.apk
|
||||
apk=build/app/outputs/flutter-apk/app-daily-release.apk
|
||||
signer=$(ls -d "$ANDROID_SDK_ROOT"/build-tools/* | sort -V | tail -1)/apksigner
|
||||
"$signer" verify --print-certs "$apk" | tee /tmp/certs.txt
|
||||
if grep -qi 'CN=Android Debug' /tmp/certs.txt; then
|
||||
@ -127,7 +127,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out="workout-app-${{ steps.ver.outputs.semver }}-build.${{ steps.vc.outputs.code }}.apk"
|
||||
cp build/app/outputs/flutter-apk/app-release.apk "$out"
|
||||
cp build/app/outputs/flutter-apk/app-daily-release.apk "$out"
|
||||
echo "path=$out" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish the per-commit prerelease
|
||||
|
||||
1
.phone-deploy-flavor
Normal file
1
.phone-deploy-flavor
Normal file
@ -0,0 +1 @@
|
||||
daily
|
||||
@ -2,7 +2,14 @@ do NOT run tests unless specifically instructed to do so or before committing
|
||||
If tests fail on the same issue twice in a row, STOP and ask the user how to proceed instead of continuing to fix and retry.
|
||||
ALWAYS confirm that the feature you add / bug you fixed behaves as it should by running the program after your changes (not tests!) and inspecting output comparing it with what user wanted, after confirming by yourself ask user if the program behaves as they intended
|
||||
After running tests fix all coverage gaps and issues, do not ignore unless specifically instructed to do so
|
||||
You are NOT done until you install the new version on the phone itself (flutter install --debug from the workout_app directory, then adb shell monkey -p com.kuhy.workout_app to launch).
|
||||
You are NOT done until you install the new version on the phone itself:
|
||||
`bash ~/.claude/scripts/phone_deploy.sh stronglift_replacement/workout_app`
|
||||
(it picks a safe versionCode and launches; never a bare `flutter install`).
|
||||
To drive a whole workout on the phone without touching real data, deploy the
|
||||
sandbox flavor instead: `... --flavor sandbox`. That is a separate package
|
||||
(`com.kuhy.workout_app.sandbox`) with its own data, no network, no LAN server,
|
||||
a red SANDBOX ribbon, and a SANDBOX section in Settings (wipe, "mark today not
|
||||
done", rest length in seconds). Its trace is `adb logcat -s WorkoutSandbox`.
|
||||
|
||||
## NEVER fail silently
|
||||
|
||||
|
||||
@ -59,6 +59,31 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Two installs of the same code, side by side. The sandbox flavor has its
|
||||
// own package name, so Android gives it its own app data: it physically
|
||||
// cannot read or write the daily build's database, secure storage or
|
||||
// notification channels, which is what makes it safe to poke at during a
|
||||
// real training block. Everything else the sandbox must not touch (the
|
||||
// LAN server the PC pulls from, Firebase/GitHub, the /sdcard mirrors) is
|
||||
// switched off in Dart from BuildConfig.FLAVOR via MainActivity.
|
||||
flavorDimensions += "store"
|
||||
productFlavors {
|
||||
create("daily") {
|
||||
dimension = "store"
|
||||
manifestPlaceholders["appLabel"] = "workout_app"
|
||||
}
|
||||
create("sandbox") {
|
||||
dimension = "store"
|
||||
applicationIdSuffix = ".sandbox"
|
||||
versionNameSuffix = "-sandbox"
|
||||
manifestPlaceholders["appLabel"] = "Workout SANDBOX"
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Falls back to the debug key only when key.properties is absent
|
||||
|
||||
@ -31,13 +31,14 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<application
|
||||
android:label="workout_app"
|
||||
android:label="${appLabel}"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
|
||||
@ -1,5 +1,33 @@
|
||||
package com.kuhy.workout_app
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
// The sandbox flavor is a build-time fact; Dart asks for it once at
|
||||
// startup rather than carrying a second --dart-define switch that
|
||||
// could disagree with the package name. The same channel carries the
|
||||
// sandbox trace to logcat under its own tag, so
|
||||
// `adb logcat -s WorkoutSandbox` shows nothing but the trace.
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"isSandbox" -> result.success(BuildConfig.FLAVOR == "sandbox")
|
||||
"log" -> {
|
||||
Log.i(LOG_TAG, call.arguments as String)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "com.kuhy.workout_app/sandbox"
|
||||
const val LOG_TAG = "WorkoutSandbox"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:io' show HttpOverrides, Platform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/sandbox/sandbox_http.dart';
|
||||
import 'package:workout_app/sandbox/sandbox_log.dart';
|
||||
import 'package:workout_app/screens/home_screen.dart';
|
||||
import 'package:workout_app/services/backup_service.dart';
|
||||
import 'package:workout_app/services/break_service_controller.dart';
|
||||
@ -13,6 +15,7 @@ import 'package:workout_app/services/lock_mode.dart';
|
||||
import 'package:workout_app/services/progression_sync_service.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/sync_device_id.dart';
|
||||
import 'package:workout_app/services/sync_service.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
// coverage:ignore-start
|
||||
@ -29,6 +32,16 @@ void main(List<String> args) async {
|
||||
// Must precede runApp: the UI reads this to decide whether to offer
|
||||
// any way out of the workout.
|
||||
lockModeEnabled = parseLockMode(args);
|
||||
// Before any storage or network work: the sandbox flavor is offline by
|
||||
// construction and keeps its /sdcard files beside the daily build's, and
|
||||
// both facts have to hold before the first write, not after.
|
||||
await Sandbox.init();
|
||||
if (Sandbox.enabled) {
|
||||
HttpOverrides.global = SandboxHttpOverrides();
|
||||
BackupService.baseDir = Sandbox.backupDir;
|
||||
SyncService.filePath = Sandbox.syncFilePath;
|
||||
SandboxLog.event('startup', {'args': args});
|
||||
}
|
||||
// Linux desktop has no sqflite plugin. The FFI factory runs the same
|
||||
// schema and migrations against the same SQL, so this is a transport
|
||||
// swap, not a second storage implementation. Must precede any DB open.
|
||||
@ -58,6 +71,12 @@ void main(List<String> args) async {
|
||||
);
|
||||
}
|
||||
await StorageService.init();
|
||||
if (Sandbox.enabled) {
|
||||
Sandbox.restSecs = await StorageService.instance.getSandboxRestSecs(
|
||||
Sandbox.defaultRestSecs,
|
||||
);
|
||||
SandboxLog.event('rest override loaded', {'secs': Sandbox.restSecs});
|
||||
}
|
||||
// Before the UI exists: a service left over from a crashed or force-stopped
|
||||
// workout must not sit in the status bar counting down nothing.
|
||||
await stopStaleBreakService(
|
||||
@ -78,9 +97,7 @@ void main(List<String> args) async {
|
||||
// Timing out is safe — it leaves `progression_synced_at` unset, so the next
|
||||
// launch retries and, until it succeeds, pushProgression refuses to
|
||||
// overwrite the remote copy.
|
||||
final restored = await ProgressionSyncService()
|
||||
.pullProgression()
|
||||
.timeout(
|
||||
final restored = await ProgressionSyncService().pullProgression().timeout(
|
||||
const Duration(seconds: 20),
|
||||
onTimeout: () => const ProgressionSyncResult(
|
||||
changed: false,
|
||||
@ -93,7 +110,10 @@ void main(List<String> args) async {
|
||||
// Android-only transport: it exists so the PC can pull today's workout off
|
||||
// the phone over LAN. On the PC itself it would bind the very port the PC
|
||||
// scans, so it is skipped rather than serving the machine to itself.
|
||||
if (!Platform.isLinux) {
|
||||
//
|
||||
// Never in the sandbox: the PC scans the LAN for this port and would pull
|
||||
// a sandbox workout as if it were real.
|
||||
if (!Platform.isLinux && !Sandbox.enabled) {
|
||||
await HttpServerService.instance.start();
|
||||
}
|
||||
runApp(const WorkoutApp());
|
||||
@ -136,7 +156,7 @@ class _WorkoutAppState extends State<WorkoutApp> with WidgetsBindingObserver {
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// The socket is never started on Linux (see main()), so there is nothing
|
||||
// to stop or restart here either.
|
||||
if (Platform.isLinux) return;
|
||||
if (Platform.isLinux || Sandbox.enabled) return;
|
||||
switch (state) {
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
@ -157,6 +177,16 @@ class _WorkoutAppState extends State<WorkoutApp> with WidgetsBindingObserver {
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(),
|
||||
home: const HomeScreen(),
|
||||
// The one thing that must never be mistaken for the daily build: a
|
||||
// ribbon on every screen, in the error colour, on the sandbox only.
|
||||
builder: (context, child) => Sandbox.enabled
|
||||
? Banner(
|
||||
message: 'SANDBOX',
|
||||
location: BannerLocation.topEnd,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
child: child,
|
||||
)
|
||||
: child!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
66
stronglift_replacement/workout_app/lib/sandbox/sandbox.dart
Normal file
66
stronglift_replacement/workout_app/lib/sandbox/sandbox.dart
Normal file
@ -0,0 +1,66 @@
|
||||
/// The sandbox flavor: a second install that can be poked at without fear.
|
||||
///
|
||||
/// `com.kuhy.workout_app.sandbox` is a separate package, so Android already
|
||||
/// keeps its database, secure storage and notifications apart from the daily
|
||||
/// build. This class carries the rest of the isolation into Dart: no network,
|
||||
/// no LAN server for the PC to pull from, its own `/sdcard` paths, and a
|
||||
/// rest length short enough to watch a break start and end in one sitting.
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Build-time facts about the running flavor, resolved once at startup.
|
||||
abstract final class Sandbox {
|
||||
/// Channel MainActivity answers on; also carries the logcat trace.
|
||||
static const MethodChannel channel = MethodChannel(
|
||||
'com.kuhy.workout_app/sandbox',
|
||||
);
|
||||
|
||||
/// Where the sandbox mirrors its backups, beside — never inside — the
|
||||
/// daily build's `/sdcard/WorkoutTracker`.
|
||||
static const String backupDir = '/sdcard/WorkoutTrackerSandbox';
|
||||
|
||||
/// Where the sandbox writes finished workouts; the PC never reads this.
|
||||
static const String syncFilePath = '/sdcard/workout_result_sandbox.json';
|
||||
|
||||
/// Rest length used when no override has been saved yet.
|
||||
static const int defaultRestSecs = 5;
|
||||
|
||||
/// Whether this process is the sandbox flavor. Set once by [init]; tests
|
||||
/// assign it directly to exercise both flavors in one process.
|
||||
static bool enabled = false;
|
||||
|
||||
/// Rest length (seconds) every break uses while [enabled]; persisted by
|
||||
/// the Settings screen, loaded by `main`.
|
||||
static int restSecs = defaultRestSecs;
|
||||
|
||||
/// Environment variable that turns the sandbox on where there is no
|
||||
/// flavor to ask — the Linux desktop build, run under Xvfb to check a
|
||||
/// change on the PC before it goes anywhere near the phone.
|
||||
static const String envVar = 'WORKOUT_SANDBOX';
|
||||
|
||||
/// Asks the platform which flavor built this process, or, where there is
|
||||
/// no MainActivity to ask (Linux, tests), reads [envVar].
|
||||
static Future<void> init([Map<String, String>? environment]) async {
|
||||
try {
|
||||
enabled = await channel.invokeMethod<bool>('isSandbox') ?? false;
|
||||
} on MissingPluginException catch (error) {
|
||||
enabled = (environment ?? Platform.environment)[envVar] == '1';
|
||||
debugPrint(
|
||||
'WorkoutApp: no sandbox channel on this platform ($error) — '
|
||||
'$envVar=${enabled ? '1: sandbox' : 'unset: daily build'}.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [defaultSecs] for the daily build, the override for the sandbox.
|
||||
static int rest(int defaultSecs) => enabled ? restSecs : defaultSecs;
|
||||
|
||||
/// [defaultLabel] for the daily build; the sandbox names its real length
|
||||
/// so a 5 s countdown is never captioned "3 min".
|
||||
static String restLabel(String defaultLabel) =>
|
||||
enabled ? 'Rest ($restSecs s — sandbox)' : defaultLabel;
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
/// Network refusal for the sandbox flavor.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Makes every `dart:io` HTTP connection fail before a socket is opened.
|
||||
///
|
||||
/// Installed as `HttpOverrides.global` by `main` in the sandbox. Firebase and
|
||||
/// GitHub sync both go through `package:http`, whose IO client is built from
|
||||
/// `HttpClient()` and therefore from these overrides — one seam, not one
|
||||
/// guard per service. A refused connection surfaces as a normal sync failure
|
||||
/// in the UI, which is the honest answer: the sandbox is offline by design.
|
||||
class SandboxHttpOverrides extends HttpOverrides {
|
||||
@override
|
||||
HttpClient createHttpClient(SecurityContext? context) {
|
||||
return super.createHttpClient(context)
|
||||
..connectionFactory = (uri, proxyHost, proxyPort) {
|
||||
debugPrint(
|
||||
'WorkoutSandbox: refused network connection to ${uri.host} — the '
|
||||
'sandbox never talks to Firebase or GitHub.',
|
||||
);
|
||||
throw const SocketException(
|
||||
'sandbox flavor: network disabled by design',
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
/// The sandbox's trace: every tap, break, save and navigation, to logcat.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
|
||||
/// Writes one line per event under the `WorkoutSandbox` logcat tag.
|
||||
///
|
||||
/// A no-op in the daily build, so call sites can stay unconditional. Lines
|
||||
/// also go through [debugPrint], which is what `flutter run` shows.
|
||||
abstract final class SandboxLog {
|
||||
static bool _channelDown = false;
|
||||
|
||||
/// Records [event] with optional [details], e.g.
|
||||
/// `SandboxLog.event('tap set', {'exercise': 'Squat', 'set': 2})`.
|
||||
static void event(String event, [Map<String, Object?> details = const {}]) {
|
||||
if (!Sandbox.enabled) return;
|
||||
final line = details.isEmpty ? event : '$event $details';
|
||||
debugPrint('WorkoutSandbox: $line');
|
||||
unawaited(_toLogcat(line));
|
||||
}
|
||||
|
||||
static Future<void> _toLogcat(String line) async {
|
||||
if (_channelDown) return;
|
||||
try {
|
||||
await Sandbox.channel.invokeMethod<void>('log', line);
|
||||
} on MissingPluginException catch (error) {
|
||||
// Said once, then the trace lives in debugPrint alone: repeating this
|
||||
// on every event would drown the trace it is warning about.
|
||||
_channelDown = true;
|
||||
debugPrint(
|
||||
'WorkoutSandbox: logcat channel unavailable ($error) — the trace '
|
||||
'continues in flutter logs only.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets a failed channel so the next event retries it.
|
||||
@visibleForTesting
|
||||
static void resetForTesting() => _channelDown = false;
|
||||
}
|
||||
@ -4,6 +4,7 @@ library;
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:workout_app/models/exercise.dart';
|
||||
import 'package:workout_app/sandbox/sandbox_log.dart';
|
||||
import 'package:workout_app/screens/history_screen.dart';
|
||||
import 'package:workout_app/screens/manual_workout_screen.dart';
|
||||
import 'package:workout_app/screens/settings_screen.dart';
|
||||
@ -117,8 +118,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final sync = widget.syncService ?? WorkoutSyncService();
|
||||
final now = (widget.clock ?? DateTime.now)();
|
||||
try {
|
||||
final configured =
|
||||
await (widget.configuredProbe ?? sync.isConfigured)();
|
||||
final configured = await (widget.configuredProbe ?? sync.isConfigured)();
|
||||
final storedAt = await storage.getLastSyncedAt();
|
||||
|
||||
// Show what the PERSISTED state says before the tick resolves. Without
|
||||
@ -174,18 +174,18 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'History',
|
||||
icon: Icon(Icons.history, color: colorScheme.onSurface),
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const HistoryScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Settings',
|
||||
icon: Icon(Icons.settings, color: colorScheme.onSurface),
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const SettingsScreen(),
|
||||
),
|
||||
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
unawaited(_load());
|
||||
},
|
||||
|
||||
@ -8,9 +8,9 @@ part of 'home_screen.dart';
|
||||
/// The two routes the home screen pushes, and the reload each returns to.
|
||||
extension _HomeScreenNavigation on _HomeScreenState {
|
||||
Future<void> _openSyncSettings() async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => const SettingsScreen()));
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ extension _HomeScreenNavigation on _HomeScreenState {
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
SandboxLog.event('open workout', {'type': type, 'resume': resume});
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => WorkoutScreen(
|
||||
|
||||
@ -14,6 +14,8 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:sync_settings_ui/sync_settings_ui.dart';
|
||||
import 'package:workout_app/models/exercise.dart';
|
||||
import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/sandbox/sandbox_log.dart';
|
||||
import 'package:workout_app/screens/github_mirror_screen.dart';
|
||||
import 'package:workout_app/services/backup_service.dart';
|
||||
import 'package:workout_app/services/firebase_backend.dart';
|
||||
@ -25,6 +27,7 @@ import 'package:workout_app/ui/theme.dart';
|
||||
part 'settings_screen_actions.dart';
|
||||
part 'settings_screen_exercise_sections.dart';
|
||||
part 'settings_screen_rows.dart';
|
||||
part 'settings_screen_sandbox.dart';
|
||||
part 'settings_screen_sections.dart';
|
||||
part 'settings_screen_thresholds.dart';
|
||||
part 'settings_screen_widget.dart';
|
||||
@ -204,6 +207,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
storageGranted: _storageGranted,
|
||||
onGrantStorage: _grantStorage,
|
||||
),
|
||||
if (Sandbox.enabled) ...[
|
||||
const SizedBox(height: 20),
|
||||
const _SandboxSection(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -0,0 +1,133 @@
|
||||
// The SANDBOX section of the settings list, shown by the sandbox flavor only.
|
||||
//
|
||||
// A `part` like the other sections (see settings_screen_sections.dart). It
|
||||
// owns its own state: the three controls act on the sandbox's database
|
||||
// directly and report back in a status line, and none of it touches the
|
||||
// exercise state the surrounding screen is editing.
|
||||
part of 'settings_screen.dart';
|
||||
|
||||
/// Wipe, "not done today", and the rest-length override.
|
||||
class _SandboxSection extends StatefulWidget {
|
||||
const _SandboxSection();
|
||||
|
||||
@override
|
||||
State<_SandboxSection> createState() => _SandboxSectionState();
|
||||
}
|
||||
|
||||
class _SandboxSectionState extends State<_SandboxSection> {
|
||||
late final TextEditingController _rest = TextEditingController(
|
||||
text: '${Sandbox.restSecs}',
|
||||
);
|
||||
String? _status;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rest.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _report(String status) {
|
||||
SandboxLog.event('sandbox settings', {'status': status});
|
||||
setState(() => _status = status);
|
||||
}
|
||||
|
||||
Future<void> _wipe() async {
|
||||
await StorageService.instance.wipeAll();
|
||||
Sandbox.restSecs = Sandbox.defaultRestSecs;
|
||||
_rest.text = '${Sandbox.restSecs}';
|
||||
_report('Wiped — the sandbox is back to a fresh install.');
|
||||
}
|
||||
|
||||
Future<void> _markTodayNotDone() async {
|
||||
final removed = await StorageService.instance.deleteWorkoutsOn(
|
||||
DateTime.now(),
|
||||
);
|
||||
_report(
|
||||
removed == 0
|
||||
? 'Nothing logged today; the workout is already offered.'
|
||||
: 'Removed $removed workout(s) logged today.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveRest() async {
|
||||
final secs = int.tryParse(_rest.text.trim());
|
||||
if (secs == null || secs < 1) {
|
||||
_report('Rest length must be a whole number of seconds, 1 or more.');
|
||||
return;
|
||||
}
|
||||
await StorageService.instance.setSandboxRestSecs(secs);
|
||||
Sandbox.restSecs = secs;
|
||||
_report('Every rest now lasts $secs s.');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _SectionHeader('SANDBOX'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'This install is the sandbox: its own data, no network, no LAN '
|
||||
'server. Nothing here can reach the daily build.',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: AppTextSize.caption,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => unawaited(_wipe()),
|
||||
icon: const Icon(Icons.delete_sweep),
|
||||
label: const Text('Wipe sandbox data'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => unawaited(_markTodayNotDone()),
|
||||
icon: const Icon(Icons.undo),
|
||||
label: const Text('Mark today not done'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Text('Rest length (seconds)')),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: TextField(
|
||||
key: const Key('sandbox-rest-secs'),
|
||||
controller: _rest,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
hintText: 'seconds',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(_saveRest()),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_status != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_status!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: AppTextSize.caption,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,8 @@ import 'package:workout_app/models/exercise.dart';
|
||||
import 'package:workout_app/models/exercise_result.dart';
|
||||
import 'package:workout_app/models/set_result.dart';
|
||||
import 'package:workout_app/models/workout_session.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/sandbox/sandbox_log.dart';
|
||||
import 'package:workout_app/services/break_clock.dart';
|
||||
import 'package:workout_app/services/break_intent_queue.dart';
|
||||
import 'package:workout_app/services/break_intent_store.dart';
|
||||
@ -38,15 +40,6 @@ part 'workout_screen_intents.dart';
|
||||
part 'workout_screen_session.dart';
|
||||
part 'workout_screen_taps.dart';
|
||||
|
||||
const _successBreakSecs = 180; // 3 min after successful set
|
||||
const _failBreakSecs = 300; // 5 min after failed set
|
||||
const _warmupBreakSecs = 180; // 3 min after warmup
|
||||
|
||||
// How late a restored break's end cue may still be played. Long enough to
|
||||
// cover the app being killed and reopened mid-rest; short enough that
|
||||
// resuming yesterday's session does not blast the sound across the gym.
|
||||
const _expiredBreakGraceSecs = 120;
|
||||
|
||||
/// Screen that drives an active workout session with per-rep tracking.
|
||||
class WorkoutScreen extends StatefulWidget {
|
||||
/// Creates a [WorkoutScreen].
|
||||
@ -98,8 +91,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
||||
/// A break restored as already-expired, awaiting its cue after first frame.
|
||||
BreakClock? _expiredBreak;
|
||||
|
||||
int get _breakRemaining =>
|
||||
_breakClock?.remainingSecsAt(DateTime.now()) ?? 0;
|
||||
int get _breakRemaining => _breakClock?.remainingSecsAt(DateTime.now()) ?? 0;
|
||||
|
||||
int get _breakDurationSecs => _breakClock?.durationSecs ?? 0;
|
||||
|
||||
@ -200,6 +192,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
||||
/// Only the `setState` lives here — `setState` is `@protected` and cannot be
|
||||
/// called from an extension, so the rest is in [_persistFinishedWorkout].
|
||||
Future<void> _finishWorkout() async {
|
||||
SandboxLog.event('workout finish', {'type': widget.workoutType});
|
||||
_elapsedTimer.cancel();
|
||||
_breakTimer?.cancel();
|
||||
// Before the state flips: the service must not outlive the workout, and
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
// The active-workout body: optional break banner over the exercise list.
|
||||
// The active-workout body: a reserved break strip over the exercise column.
|
||||
//
|
||||
// See workout_screen_session.dart for why this is a `part`.
|
||||
part of 'workout_screen.dart';
|
||||
|
||||
/// Scrolling exercise list for [WorkoutScreen], with the rest banner above it.
|
||||
/// Non-scrolling exercise column for [WorkoutScreen] under a reserved rest
|
||||
/// strip.
|
||||
///
|
||||
/// Every tile stays visible and in place under all circumstances: the strip
|
||||
/// is always laid out (so a rest starting moves nothing), and the column is
|
||||
/// scaled down as a whole when it would not fit, instead of scrolling.
|
||||
///
|
||||
/// A widget rather than a method so the state class stays under the
|
||||
/// file-length cap. It holds no state of its own — every value and callback is
|
||||
@ -69,18 +74,40 @@ class _WorkoutBody extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (inBreak)
|
||||
BreakBanner(
|
||||
active: inBreak,
|
||||
breakRemaining: breakRemaining,
|
||||
breakLabel: breakLabel,
|
||||
breakLabel: inBreak ? breakLabel : 'Rest',
|
||||
onSkip: onSkipBreak,
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: exercises.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (_, i) {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < exercises.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 8),
|
||||
_tile(i),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tile(int i) {
|
||||
final exName = exercises[i].name;
|
||||
final state = exerciseStates[exName];
|
||||
return ExerciseTile(
|
||||
@ -96,10 +123,5 @@ class _WorkoutBody extends StatelessWidget {
|
||||
onThresholdChanged: (success, fail) =>
|
||||
onThresholdChanged(exName, success, fail),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,13 +5,33 @@
|
||||
// state class's `_applyBreakState` shim instead.
|
||||
part of 'workout_screen.dart';
|
||||
|
||||
// Rest lengths (the sandbox flavor overrides them all in _startBreak).
|
||||
const _successBreakSecs = 180; // 3 min after successful set
|
||||
const _failBreakSecs = 300; // 5 min after failed set
|
||||
const _warmupBreakSecs = 180; // 3 min after warmup
|
||||
|
||||
// How late a restored break's end cue may still be played. Long enough to
|
||||
// cover the app being killed and reopened mid-rest; short enough that
|
||||
// resuming yesterday's session does not blast the sound across the gym.
|
||||
const _expiredBreakGraceSecs = 120;
|
||||
|
||||
/// Starting, ticking, cancelling and finishing the rest period between sets.
|
||||
extension _WorkoutScreenBreaks on _WorkoutScreenState {
|
||||
void _startBreak(int secs, String label, int exIdx, int setIdx) {
|
||||
// The one place every rest is born, so the sandbox's short override
|
||||
// applies to warmup, success and fail rests alike.
|
||||
final restSecs = Sandbox.rest(secs);
|
||||
final restLabel = Sandbox.restLabel(label);
|
||||
SandboxLog.event('break start', {
|
||||
'secs': restSecs,
|
||||
'label': restLabel,
|
||||
'exercise': exIdx,
|
||||
'set': setIdx,
|
||||
});
|
||||
_breakTimer?.cancel();
|
||||
_applyBreakState(() {
|
||||
_breakClock = BreakClock.startingAt(DateTime.now(), secs);
|
||||
_breakLabel = label;
|
||||
_breakClock = BreakClock.startingAt(DateTime.now(), restSecs);
|
||||
_breakLabel = restLabel;
|
||||
_breakForExIdx = exIdx;
|
||||
_breakForSetIdx = setIdx;
|
||||
});
|
||||
@ -46,6 +66,7 @@ extension _WorkoutScreenBreaks on _WorkoutScreenState {
|
||||
}
|
||||
|
||||
void _cancelBreak() {
|
||||
SandboxLog.event('break cancel');
|
||||
_breakTimer?.cancel();
|
||||
_applyBreakState(() {
|
||||
_breakClock = null;
|
||||
@ -55,6 +76,7 @@ extension _WorkoutScreenBreaks on _WorkoutScreenState {
|
||||
}
|
||||
|
||||
Future<void> _onBreakFinished() async {
|
||||
SandboxLog.event('break end');
|
||||
await _playBreakEndCue();
|
||||
_applyBreakState(() {
|
||||
_breakClock = null;
|
||||
@ -64,11 +86,7 @@ extension _WorkoutScreenBreaks on _WorkoutScreenState {
|
||||
unawaited(_saveActiveSession());
|
||||
}
|
||||
|
||||
Future<void> _onThresholdChanged(
|
||||
String name,
|
||||
int success,
|
||||
int fail,
|
||||
) async {
|
||||
Future<void> _onThresholdChanged(String name, int success, int fail) async {
|
||||
await StorageService.instance.setExerciseThresholds(
|
||||
name,
|
||||
successThreshold: success,
|
||||
|
||||
@ -14,6 +14,7 @@ extension _WorkoutScreenSession on _WorkoutScreenState {
|
||||
/// parked in `_expiredBreak` so the screen can say so and, if the user only
|
||||
/// just missed it, play the cue they were waiting for.
|
||||
void _restoreFromSaved(Map<String, dynamic> s) {
|
||||
SandboxLog.event('session restore', {'keys': s.keys.toList()});
|
||||
_startTime = DateTime.fromMillisecondsSinceEpoch(s['startTimeMs'] as int);
|
||||
_tapped = (s['tapped'] as List)
|
||||
.map((row) => (row as List).cast<bool>())
|
||||
@ -84,6 +85,7 @@ extension _WorkoutScreenSession on _WorkoutScreenState {
|
||||
/// sync revision cache exists to avoid — so the remote copy is debounced to
|
||||
/// the events that actually change which set the user is standing on.
|
||||
Future<void> _saveActiveSession({bool toFirebase = false}) async {
|
||||
SandboxLog.event('session save', {'toFirebase': toFirebase});
|
||||
final data = _activeSessionData();
|
||||
await StorageService.instance.saveActiveSession(data);
|
||||
// The one seam the notification is fed from. Every event that moves the
|
||||
@ -132,13 +134,15 @@ extension _WorkoutScreenSession on _WorkoutScreenState {
|
||||
if (_breakForSetIdx == -1) return; // warmup break, never recompute
|
||||
|
||||
final succeeded = _doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
|
||||
final newDuration = succeeded ? _successBreakSecs : _failBreakSecs;
|
||||
final newDuration = Sandbox.rest(
|
||||
succeeded ? _successBreakSecs : _failBreakSecs,
|
||||
);
|
||||
if (newDuration == clock.durationSecs) return;
|
||||
|
||||
_breakClock = clock.withDuration(newDuration);
|
||||
_breakLabel = succeeded
|
||||
? 'Rest (3 min — well done!)'
|
||||
: 'Rest (5 min — keep going!)';
|
||||
_breakLabel = Sandbox.restLabel(
|
||||
succeeded ? 'Rest (3 min — well done!)' : 'Rest (5 min — keep going!)',
|
||||
);
|
||||
}
|
||||
|
||||
/// True when [setIdx] is the last untapped set of exercise [exIdx].
|
||||
@ -164,9 +168,9 @@ extension _WorkoutScreenSession on _WorkoutScreenState {
|
||||
|
||||
/// Plays the sound and haptic that tell the user the rest period is over.
|
||||
Future<void> _playBreakEndCue() async {
|
||||
await _audio
|
||||
.play(AssetSource('sounds/break_end.mp3'))
|
||||
.catchError((Object error) {
|
||||
await _audio.play(AssetSource('sounds/break_end.mp3')).catchError((
|
||||
Object error,
|
||||
) {
|
||||
// Never fatal: a missing audio route must not interrupt the workout.
|
||||
// But it is the break-end cue, so a silent failure looks like the
|
||||
// timer itself is broken.
|
||||
@ -203,6 +207,7 @@ extension _WorkoutScreenSession on _WorkoutScreenState {
|
||||
|
||||
/// Ends the break early at the user's request and re-persists the session.
|
||||
void _skipBreak() {
|
||||
SandboxLog.event('break skip');
|
||||
_cancelBreak();
|
||||
unawaited(_saveActiveSession());
|
||||
}
|
||||
|
||||
@ -17,6 +17,12 @@ extension _WorkoutScreenTaps on _WorkoutScreenState {
|
||||
if (_finished) return;
|
||||
|
||||
final wasNotTapped = !_tapped[exIdx][setIdx];
|
||||
SandboxLog.event('tap set', {
|
||||
'exercise': exIdx,
|
||||
'set': setIdx,
|
||||
'first': wasNotTapped,
|
||||
'ignored': wasNotTapped && _inBreak,
|
||||
});
|
||||
if (wasNotTapped && _inBreak) return;
|
||||
|
||||
_applyBreakState(() {
|
||||
@ -42,6 +48,7 @@ extension _WorkoutScreenTaps on _WorkoutScreenState {
|
||||
|
||||
/// Marks exercise [exIdx]'s warmup done and starts the warmup rest.
|
||||
void _tapWarmup(int exIdx) {
|
||||
SandboxLog.event('tap warmup', {'exercise': exIdx});
|
||||
if (_finished || _warmupTapped[exIdx]) return;
|
||||
_applyBreakState(() => _warmupTapped[exIdx] = true);
|
||||
if (!_inBreak) {
|
||||
@ -52,6 +59,7 @@ extension _WorkoutScreenTaps on _WorkoutScreenState {
|
||||
|
||||
/// Undoes set [setIdx] of [exIdx], restoring its full target reps.
|
||||
void _resetCircle(int exIdx, int setIdx) {
|
||||
SandboxLog.event('reset set', {'exercise': exIdx, 'set': setIdx});
|
||||
if (_finished) return;
|
||||
_applyBreakState(() {
|
||||
_tapped[exIdx][setIdx] = false;
|
||||
|
||||
@ -14,7 +14,7 @@ library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter/services.dart' show MissingPluginException;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
@ -46,25 +46,16 @@ class BackupService {
|
||||
/// The singleton instance.
|
||||
static final BackupService instance = BackupService._();
|
||||
|
||||
// Overridable for unit tests (set by resetForTesting) so round-trips can
|
||||
// be verified against a real temp directory instead of `/sdcard`, which
|
||||
// doesn't exist on the test host.
|
||||
static String _baseDir = kBackupDir;
|
||||
// Overridable: tests point it at a real temp directory instead of
|
||||
// `/sdcard`, which doesn't exist on the test host, and the sandbox flavor
|
||||
// at its own `/sdcard/WorkoutTrackerSandbox` beside the daily build's.
|
||||
/// The directory backup files are written to and read from.
|
||||
static String baseDir = kBackupDir;
|
||||
|
||||
/// The directory backup files are currently written to/read from.
|
||||
/// Test-only.
|
||||
@visibleForTesting
|
||||
static String get baseDirForTesting => _baseDir;
|
||||
String get _backupPath => '$baseDir/backup.json';
|
||||
String get _syncTokenPath => '$baseDir/sync_token';
|
||||
|
||||
/// Points the backup files at this directory instead of [kBackupDir].
|
||||
/// Test-only.
|
||||
@visibleForTesting
|
||||
static set baseDirForTesting(String dir) => _baseDir = dir;
|
||||
|
||||
String get _backupPath => '$_baseDir/backup.json';
|
||||
String get _syncTokenPath => '$_baseDir/sync_token';
|
||||
|
||||
String get _activeSessionPath => '$_baseDir/active_session.json';
|
||||
String get _activeSessionPath => '$baseDir/active_session.json';
|
||||
|
||||
// ── Permission ─────────────────────────────────────────────────────────────
|
||||
|
||||
@ -122,7 +113,7 @@ class BackupService {
|
||||
/// Returns true if the backup was written.
|
||||
Future<bool> export(Map<String, dynamic> data) async {
|
||||
try {
|
||||
final dir = Directory(_baseDir);
|
||||
final dir = Directory(baseDir);
|
||||
if (!dir.existsSync()) {
|
||||
dir.createSync(recursive: true);
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ extension BackupServiceSession on BackupService {
|
||||
if (f.existsSync()) await f.delete();
|
||||
return;
|
||||
}
|
||||
final dir = Directory(BackupService._baseDir);
|
||||
final dir = Directory(BackupService.baseDir);
|
||||
if (!dir.existsSync()) {
|
||||
dir.createSync(recursive: true);
|
||||
}
|
||||
@ -43,7 +43,7 @@ extension BackupServiceSession on BackupService {
|
||||
if (f.existsSync()) await f.delete();
|
||||
return;
|
||||
}
|
||||
final dir = Directory(BackupService._baseDir);
|
||||
final dir = Directory(BackupService.baseDir);
|
||||
if (!dir.existsSync()) {
|
||||
dir.createSync(recursive: true);
|
||||
}
|
||||
|
||||
@ -34,10 +34,10 @@ const kSyncUid = 'OvA2REQyLIhAHOEjzwS1o877rgG3';
|
||||
///
|
||||
/// A plain const rather than a `--dart-define`, deliberately. As a
|
||||
/// compile-time environment value it was empty in every build that matters:
|
||||
/// the phone-deploy skill and CI both run a bare `flutter build apk
|
||||
/// --release`, so released APKs would have shown a Google button that always
|
||||
/// reported "cancelled" -- a visible control that can never succeed, which is
|
||||
/// the exact thing the web platform gate exists to prevent.
|
||||
/// the phone-deploy skill and CI both run `flutter build apk --release`
|
||||
/// with no defines, so released APKs would have shown a Google button that
|
||||
/// always reported "cancelled" -- a visible control that can never succeed,
|
||||
/// which is the exact thing the web platform gate exists to prevent.
|
||||
///
|
||||
/// Fill this in from Firebase console -> Project settings -> the `syncs-rest`
|
||||
/// Web app's OAuth client (type "Web application"). Android must request a
|
||||
|
||||
@ -72,7 +72,7 @@ class HttpServerService {
|
||||
/// On startup, try to load the last saved workout JSON from disk so the
|
||||
/// HTTP endpoint is populated even before the next workout is completed.
|
||||
Future<void> _loadFromDisk() async {
|
||||
final candidates = <String>[kSyncFilePath];
|
||||
final candidates = <String>[SyncService.filePath];
|
||||
// External storage is Android-only; getExternalStorageDirectory() throws an
|
||||
// UnsupportedError (not an Exception) on other platforms (desktop / test
|
||||
// host), so guard the call rather than trying to catch it.
|
||||
@ -87,7 +87,7 @@ class HttpServerService {
|
||||
// Ignore; the /sdcard path is tried first.
|
||||
debugPrint(
|
||||
'WorkoutApp: could not resolve the external storage directory '
|
||||
'($error) — only $kSyncFilePath will be tried.',
|
||||
'($error) — only ${SyncService.filePath} will be tried.',
|
||||
);
|
||||
}
|
||||
// coverage:ignore-end
|
||||
|
||||
@ -15,6 +15,7 @@ import 'package:workout_app/services/progression_sync_service.dart';
|
||||
|
||||
part 'storage_service_backup.dart';
|
||||
part 'storage_service_exercises.dart';
|
||||
part 'storage_service_sandbox.dart';
|
||||
part 'storage_service_schema.dart';
|
||||
part 'storage_service_sessions.dart';
|
||||
|
||||
@ -131,11 +132,10 @@ class StorageService {
|
||||
}
|
||||
|
||||
Future<void> _setSetting(String key, String value) async {
|
||||
await _db.insert(
|
||||
'settings',
|
||||
{'key': key, 'value': value},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await _db.insert('settings', {
|
||||
'key': key,
|
||||
'value': value,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
/// Returns 'A' or 'B' — the type that should be done next.
|
||||
@ -158,11 +158,10 @@ class StorageService {
|
||||
/// uninstall or `pm clear` wipes it and the user loses the set they are
|
||||
/// standing on. The mirror survives both.
|
||||
Future<void> saveActiveSession(Map<String, dynamic> data) async {
|
||||
await _db.insert(
|
||||
'active_session',
|
||||
{'id': 1, 'json': jsonEncode(data)},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await _db.insert('active_session', {
|
||||
'id': 1,
|
||||
'json': jsonEncode(data),
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
unawaited(BackupService.instance.exportActiveSession(data));
|
||||
}
|
||||
|
||||
@ -186,11 +185,10 @@ class StorageService {
|
||||
await (remoteActiveSessionReader ??
|
||||
ProgressionSyncService().readActiveSession)();
|
||||
if (mirrored == null) return null;
|
||||
await _db.insert(
|
||||
'active_session',
|
||||
{'id': 1, 'json': jsonEncode(mirrored)},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await _db.insert('active_session', {
|
||||
'id': 1,
|
||||
'json': jsonEncode(mirrored),
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
return mirrored;
|
||||
}
|
||||
|
||||
@ -204,9 +202,7 @@ class StorageService {
|
||||
}
|
||||
|
||||
/// Returns up to [limit] past sessions, newest first.
|
||||
Future<List<Map<String, dynamic>>> getWorkoutHistory({
|
||||
int limit = 60,
|
||||
}) async {
|
||||
Future<List<Map<String, dynamic>>> getWorkoutHistory({int limit = 60}) async {
|
||||
return await _db.rawQuery(
|
||||
'SELECT date, workout_type, duration_seconds, succeeded, json '
|
||||
'FROM workout_history ORDER BY date DESC LIMIT ?',
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
// The sandbox flavor's controls: wipe, un-do today, and the rest override.
|
||||
//
|
||||
// A `part` of StorageService for the same reason as the other parts: they
|
||||
// need `_db` and `_getSetting`, and the sandbox must not grow a second
|
||||
// storage layer with its own opinions about the schema.
|
||||
part of 'storage_service.dart';
|
||||
|
||||
/// Settings key the sandbox's rest length is stored under.
|
||||
const String kSandboxRestSecsKey = 'sandbox_rest_secs';
|
||||
|
||||
/// Sandbox-only maintenance on the (sandbox's own) database.
|
||||
///
|
||||
/// Nothing here checks `Sandbox.enabled`: the Settings screen only offers
|
||||
/// these while it is, and a wipe of a database that belongs to the sandbox
|
||||
/// package cannot reach the daily build's data whatever flag is set.
|
||||
extension StorageServiceSandbox on StorageService {
|
||||
/// Empties every table and re-seeds the exercise defaults: a fresh install
|
||||
/// without reinstalling.
|
||||
Future<void> wipeAll() async {
|
||||
for (final table in const [
|
||||
'workout_history',
|
||||
'active_session',
|
||||
'settings',
|
||||
'exercise_state',
|
||||
]) {
|
||||
await _db.delete(table);
|
||||
}
|
||||
await _seedDefaultsIfNeeded();
|
||||
}
|
||||
|
||||
/// Deletes every history row dated [day] and returns how many went, so the
|
||||
/// home screen stops saying "Done for today" and offers the workout again.
|
||||
Future<int> deleteWorkoutsOn(DateTime day) {
|
||||
final prefix =
|
||||
'${day.year.toString().padLeft(4, '0')}-'
|
||||
'${day.month.toString().padLeft(2, '0')}-'
|
||||
'${day.day.toString().padLeft(2, '0')}';
|
||||
return _db.delete(
|
||||
'workout_history',
|
||||
where: 'date LIKE ?',
|
||||
whereArgs: ['$prefix%'],
|
||||
);
|
||||
}
|
||||
|
||||
/// The saved rest override, or [fallback] when none has been saved.
|
||||
Future<int> getSandboxRestSecs(int fallback) async {
|
||||
final raw = await _getSetting(kSandboxRestSecsKey);
|
||||
return raw == null ? fallback : int.parse(raw);
|
||||
}
|
||||
|
||||
/// Persists the rest override used by every sandbox break.
|
||||
Future<void> setSandboxRestSecs(int secs) =>
|
||||
_setSetting(kSandboxRestSecsKey, '$secs');
|
||||
}
|
||||
@ -12,6 +12,10 @@ const String kSyncFilePath = '/sdcard/workout_result.json';
|
||||
|
||||
/// Handles writing completed workout sessions to disk and the HTTP server.
|
||||
class SyncService {
|
||||
/// Where the result file is written; the sandbox flavor points this at
|
||||
/// its own file so the PC's ADB pull never sees a sandbox workout.
|
||||
static String filePath = kSyncFilePath;
|
||||
|
||||
/// Writes [session] as JSON to external storage and updates the HTTP server.
|
||||
///
|
||||
/// Falls back to app-external directory if /sdcard/ is not writable.
|
||||
@ -23,13 +27,13 @@ class SyncService {
|
||||
|
||||
// Try the primary path first (/sdcard/ — ADB-accessible without root).
|
||||
try {
|
||||
final file = File(kSyncFilePath);
|
||||
final file = File(filePath);
|
||||
await file.writeAsString(json);
|
||||
return const SyncResult(success: true, path: kSyncFilePath);
|
||||
return SyncResult(success: true, path: filePath);
|
||||
} on Exception catch (error) {
|
||||
// Fallback: app-specific external directory (still ADB accessible).
|
||||
debugPrint(
|
||||
'WorkoutApp: could not write $kSyncFilePath ($error) — trying the '
|
||||
'WorkoutApp: could not write $filePath ($error) — trying the '
|
||||
'app-specific external directory next.',
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,19 +1,33 @@
|
||||
/// Countdown banner displayed at the top of the workout screen during a rest.
|
||||
/// Countdown strip permanently reserved at the top of the workout screen.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
/// Banner widget showing a break countdown and a skip button.
|
||||
/// 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;
|
||||
|
||||
@ -34,16 +48,17 @@ class BreakBanner extends StatelessWidget {
|
||||
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, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
breakLabel,
|
||||
@ -55,8 +70,11 @@ class BreakBanner extends StatelessWidget {
|
||||
Text(
|
||||
_fmt(breakRemaining),
|
||||
style: TextStyle(
|
||||
// Warning (caution/pending) — this is a running countdown.
|
||||
color: status.warning,
|
||||
// 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()],
|
||||
@ -65,10 +83,16 @@ class BreakBanner extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
Visibility(
|
||||
visible: active,
|
||||
maintainSize: true,
|
||||
maintainAnimation: true,
|
||||
maintainState: true,
|
||||
child: TextButton(
|
||||
onPressed: onSkip,
|
||||
child: Text('Skip', style: TextStyle(color: colorScheme.primary)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -120,6 +120,7 @@ class ExerciseTile extends StatelessWidget {
|
||||
children: List.generate(
|
||||
exercise.sets,
|
||||
(s) => RepCircle(
|
||||
semanticLabel: '${exercise.name} set ${s + 1}',
|
||||
targetReps: exercise.reps,
|
||||
doneReps: doneReps[s],
|
||||
tapped: tapped[s],
|
||||
|
||||
@ -39,9 +39,15 @@ class RepCircle extends StatelessWidget {
|
||||
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;
|
||||
|
||||
@ -109,6 +115,8 @@ class RepCircle extends StatelessWidget {
|
||||
// color already differentiates this circle from the page.
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Semantics(
|
||||
label: semanticLabel,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
@ -118,6 +126,7 @@ class RepCircle extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
102
stronglift_replacement/workout_app/scripts/run_sandbox_xvfb.sh
Executable file
102
stronglift_replacement/workout_app/scripts/run_sandbox_xvfb.sh
Executable file
@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Run the workout app's Linux build as the SANDBOX on a virtual display.
|
||||
#
|
||||
# For checking a change on the PC before the phone: the app runs under Xvfb
|
||||
# (never the live display), with an isolated HOME so the PC's real workout
|
||||
# state is untouched, WORKOUT_SANDBOX=1 (offline, short rests, red ribbon),
|
||||
# and its window sized to the phone's logical 411x914.
|
||||
#
|
||||
# Usage:
|
||||
# run_sandbox_xvfb.sh start # build must exist (flutter build linux --debug)
|
||||
# run_sandbox_xvfb.sh shot <out.png> # screenshot the virtual display
|
||||
# run_sandbox_xvfb.sh tap <x> <y> # click at window-relative logical coords
|
||||
# run_sandbox_xvfb.sh log # tail the app's stdout (the sandbox trace)
|
||||
# run_sandbox_xvfb.sh stop
|
||||
#
|
||||
# Requires: Xvfb, xdotool, scrot (all in pacman).
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
readonly SCRIPT_NAME
|
||||
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly APP_DIR
|
||||
readonly STATE_DIR="${WORKOUT_SANDBOX_XVFB_DIR:-/tmp/workout_sandbox_xvfb}"
|
||||
readonly BUNDLE="$APP_DIR/build/linux/x64/debug/bundle/workout_app"
|
||||
readonly PHONE_W=411
|
||||
readonly PHONE_H=914
|
||||
readonly DISPLAY_NUM="${WORKOUT_SANDBOX_DISPLAY:-:97}"
|
||||
|
||||
usage() {
|
||||
sed -n '/^# Usage:/,/^# Requires/p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 2
|
||||
}
|
||||
|
||||
require() {
|
||||
command -v "$1" >/dev/null || { echo "$SCRIPT_NAME: missing $1 (pacman -S ${2:-$1})" >&2; exit 1; }
|
||||
}
|
||||
|
||||
window_id() {
|
||||
# The toplevel is named "workout_app"; the GTK app id (com.kuhy.workout_app)
|
||||
# is its class, shared with a 10x10 helper window, so match on the name.
|
||||
DISPLAY="$DISPLAY_NUM" xdotool search --onlyvisible --name '^workout_app$' 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
start() {
|
||||
require Xvfb xorg-server-xvfb; require xdotool; require scrot
|
||||
[[ -x "$BUNDLE" ]] || { echo "$SCRIPT_NAME: no Linux build at $BUNDLE — run: flutter build linux --debug" >&2; exit 1; }
|
||||
mkdir -p "$STATE_DIR/home"
|
||||
Xvfb "$DISPLAY_NUM" -screen 0 "${PHONE_W}x${PHONE_H}x24" > "$STATE_DIR/xvfb.log" 2>&1 &
|
||||
echo $! > "$STATE_DIR/xvfb.pid"
|
||||
sleep 1
|
||||
# Isolated HOME: the app resolves its documents dir from it, so the
|
||||
# sandbox's SQLite lands under $STATE_DIR/home, never in ~/.local/share.
|
||||
HOME="$STATE_DIR/home" XDG_DATA_HOME="$STATE_DIR/home/.local/share" \
|
||||
XDG_CONFIG_HOME="$STATE_DIR/home/.config" DISPLAY="$DISPLAY_NUM" \
|
||||
WORKOUT_SANDBOX=1 "$BUNDLE" > "$STATE_DIR/app.log" 2>&1 &
|
||||
echo $! > "$STATE_DIR/app.pid"
|
||||
local wid=""
|
||||
for _ in $(seq 1 30); do
|
||||
wid=$(window_id) && [[ -n "$wid" ]] && break
|
||||
sleep 0.5
|
||||
done
|
||||
[[ -n "$wid" ]] || { echo "$SCRIPT_NAME: app window never appeared; see $STATE_DIR/app.log" >&2; stop; exit 1; }
|
||||
DISPLAY="$DISPLAY_NUM" xdotool windowmove "$wid" 0 0 windowsize "$wid" "$PHONE_W" "$PHONE_H"
|
||||
sleep 1
|
||||
echo "sandbox up on $DISPLAY_NUM (window $wid, ${PHONE_W}x${PHONE_H}); trace: $STATE_DIR/app.log"
|
||||
}
|
||||
|
||||
shot() {
|
||||
local out="${1:?shot needs an output path}"
|
||||
DISPLAY="$DISPLAY_NUM" scrot -o "$out"
|
||||
echo "wrote $out"
|
||||
}
|
||||
|
||||
tap() {
|
||||
local x="${1:?tap needs x}" y="${2:?tap needs y}"
|
||||
DISPLAY="$DISPLAY_NUM" xdotool mousemove "$x" "$y" click 1
|
||||
sleep 0.5
|
||||
}
|
||||
|
||||
stop() {
|
||||
local f
|
||||
for f in app xvfb; do
|
||||
if [[ -f "$STATE_DIR/$f.pid" ]]; then
|
||||
kill "$(cat "$STATE_DIR/$f.pid")" 2>/dev/null || true
|
||||
rm -f "$STATE_DIR/$f.pid"
|
||||
fi
|
||||
done
|
||||
echo "stopped (data kept in $STATE_DIR/home; rm -rf it for a fresh sandbox)"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) start ;;
|
||||
shot) shot "${2:-}" ;;
|
||||
tap) tap "${2:-}" "${3:-}" ;;
|
||||
log) tail -n "${2:-40}" "$STATE_DIR/app.log" ;;
|
||||
stop) stop ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@ -0,0 +1,26 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/sandbox/sandbox_http.dart';
|
||||
|
||||
void main() {
|
||||
final printed = <String>[];
|
||||
setUp(() {
|
||||
printed.clear();
|
||||
debugPrint = (String? message, {int? wrapWidth}) => printed.add(message ?? '');
|
||||
});
|
||||
tearDown(() => debugPrint = debugPrintThrottled);
|
||||
|
||||
test('refuses every connection before a socket is opened', () async {
|
||||
final client = SandboxHttpOverrides().createHttpClient(null);
|
||||
// Thrown from inside getUrl itself: the refusal happens before any
|
||||
// request object exists, let alone a socket.
|
||||
await expectLater(
|
||||
() async => client.getUrl(Uri.parse('http://firebase.example.invalid/x')),
|
||||
throwsA(isA<SocketException>()),
|
||||
);
|
||||
expect(printed.single, contains('refused network connection to firebase.example.invalid'));
|
||||
client.close(force: true);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/sandbox/sandbox_log.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final printed = <String>[];
|
||||
final sent = <MethodCall>[];
|
||||
|
||||
void channelUp() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(Sandbox.channel, (call) async {
|
||||
sent.add(call);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
printed.clear();
|
||||
sent.clear();
|
||||
debugPrint = (String? message, {int? wrapWidth}) => printed.add(message ?? '');
|
||||
SandboxLog.resetForTesting();
|
||||
Sandbox.enabled = true;
|
||||
});
|
||||
tearDown(() {
|
||||
debugPrint = debugPrintThrottled;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(Sandbox.channel, null);
|
||||
Sandbox.enabled = false;
|
||||
});
|
||||
|
||||
test('is silent in the daily build', () async {
|
||||
Sandbox.enabled = false;
|
||||
channelUp();
|
||||
SandboxLog.event('tap set', {'set': 1});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(printed, isEmpty);
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
|
||||
test('prints and forwards one line per event', () async {
|
||||
channelUp();
|
||||
SandboxLog.event('break start', {'secs': 5});
|
||||
SandboxLog.event('break end');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(printed, ['WorkoutSandbox: break start {secs: 5}', 'WorkoutSandbox: break end']);
|
||||
expect(sent.map((c) => c.method), ['log', 'log']);
|
||||
expect(sent.first.arguments, 'break start {secs: 5}');
|
||||
});
|
||||
|
||||
test('says once that logcat is unreachable, then keeps printing', () async {
|
||||
SandboxLog.event('one');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
SandboxLog.event('two');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(printed.where((l) => l.contains('logcat channel unavailable')), hasLength(1));
|
||||
expect(printed.where((l) => l == 'WorkoutSandbox: two'), hasLength(1));
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
void answer(Object? Function(MethodCall call)? handler) {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
Sandbox.channel,
|
||||
handler == null ? null : (call) async => handler(call),
|
||||
);
|
||||
}
|
||||
|
||||
final printed = <String>[];
|
||||
setUp(() {
|
||||
printed.clear();
|
||||
debugPrint = (String? message, {int? wrapWidth}) => printed.add(message ?? '');
|
||||
Sandbox.enabled = false;
|
||||
Sandbox.restSecs = Sandbox.defaultRestSecs;
|
||||
});
|
||||
tearDown(() {
|
||||
debugPrint = debugPrintThrottled;
|
||||
answer(null);
|
||||
Sandbox.enabled = false;
|
||||
});
|
||||
|
||||
test('init takes the flavor from MainActivity', () async {
|
||||
answer((call) => call.method == 'isSandbox');
|
||||
await Sandbox.init();
|
||||
expect(Sandbox.enabled, isTrue);
|
||||
});
|
||||
|
||||
test('init treats a null answer as the daily build', () async {
|
||||
answer((_) => null);
|
||||
await Sandbox.init();
|
||||
expect(Sandbox.enabled, isFalse);
|
||||
});
|
||||
|
||||
test('init falls back to the environment where there is no channel', () async {
|
||||
answer(null);
|
||||
await Sandbox.init({Sandbox.envVar: '1'});
|
||||
expect(Sandbox.enabled, isTrue);
|
||||
expect(printed.single, contains('WORKOUT_SANDBOX=1: sandbox'));
|
||||
|
||||
await Sandbox.init({});
|
||||
expect(Sandbox.enabled, isFalse);
|
||||
expect(printed.last, contains('unset: daily build'));
|
||||
});
|
||||
|
||||
test('init reads the real environment by default', () async {
|
||||
answer(null);
|
||||
await Sandbox.init();
|
||||
// Whatever the host env says, it must not throw and must report it.
|
||||
expect(printed.single, contains('no sandbox channel'));
|
||||
});
|
||||
|
||||
test('rest and label pass through for the daily build', () {
|
||||
expect(Sandbox.rest(180), 180);
|
||||
expect(Sandbox.restLabel('Rest (3 min — well done!)'), 'Rest (3 min — well done!)');
|
||||
});
|
||||
|
||||
test('rest and label are overridden in the sandbox', () {
|
||||
Sandbox.enabled = true;
|
||||
Sandbox.restSecs = 9;
|
||||
expect(Sandbox.rest(180), 9);
|
||||
expect(Sandbox.restLabel('Rest (3 min — well done!)'), 'Rest (9 s — sandbox)');
|
||||
});
|
||||
}
|
||||
@ -10,6 +10,7 @@ import 'package:workout_app/models/exercise.dart';
|
||||
import 'package:workout_app/screens/workout_screen.dart';
|
||||
import 'package:workout_app/services/foreground_break_client.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
import 'package:workout_app/widgets/break_banner.dart';
|
||||
|
||||
Map<String, dynamic> completeSaved() => {
|
||||
'workoutType': 'A',
|
||||
@ -29,7 +30,10 @@ Map<String, dynamic> completeSaved() => {
|
||||
///
|
||||
/// Negative values describe a break that ended while the app was away — the
|
||||
/// case that used to be dropped in silence.
|
||||
Map<String, dynamic> savedWithBreak(int secondsFromNow, {int durationSecs = 180}) => {
|
||||
Map<String, dynamic> savedWithBreak(
|
||||
int secondsFromNow, {
|
||||
int durationSecs = 180,
|
||||
}) => {
|
||||
'workoutType': 'A',
|
||||
'startTimeMs': DateTime.now()
|
||||
.subtract(const Duration(minutes: 5))
|
||||
@ -101,3 +105,10 @@ Future<void> longPressReal(WidgetTester tester, Finder f) async {
|
||||
});
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// Whether the (always present) rest strip is counting down.
|
||||
///
|
||||
/// The banner is a permanently reserved slot since 2026-09-20, so presence
|
||||
/// no longer says anything; `active` is the discriminator.
|
||||
bool restRunning(WidgetTester tester) =>
|
||||
tester.widget<BreakBanner>(find.byType(BreakBanner)).active;
|
||||
|
||||
@ -164,9 +164,9 @@ void main() {
|
||||
// stale keystore entry would otherwise shadow a good backup forever
|
||||
// and demand a pointless re-authorization.
|
||||
final tempDir = Directory.systemTemp.createTempSync('settings_recover_');
|
||||
BackupService.baseDirForTesting = tempDir.path;
|
||||
BackupService.baseDir = tempDir.path;
|
||||
addTearDown(() {
|
||||
BackupService.baseDirForTesting = kBackupDir;
|
||||
BackupService.baseDir = kBackupDir;
|
||||
tempDir.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,140 @@
|
||||
// The SANDBOX section: present only in the sandbox flavor, and each of its
|
||||
// three controls acts on the (sandbox's) database and reports back.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/screens/settings_screen.dart';
|
||||
import 'package:workout_app/services/progression_sync_service.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import '../fake_secure_storage.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
StorageService.resetForTesting();
|
||||
await StorageService.init();
|
||||
installFakeSecureStorage();
|
||||
Sandbox.enabled = true;
|
||||
Sandbox.restSecs = Sandbox.defaultRestSecs;
|
||||
});
|
||||
tearDown(() {
|
||||
Sandbox.enabled = false;
|
||||
Sandbox.restSecs = Sandbox.defaultRestSecs;
|
||||
});
|
||||
|
||||
Widget wrap() => MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
home: SettingsScreen(
|
||||
firebaseFactory: () async => null,
|
||||
sessionProbe: () async => false,
|
||||
storageChecker: () async => false,
|
||||
progressionPuller: () async =>
|
||||
const ProgressionSyncResult(changed: false, reason: 'stubbed'),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> pump(WidgetTester tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(wrap());
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
// The list, explicitly: the section's own TextField is a Scrollable too,
|
||||
// and scrolling that one never brings the header into view.
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('SANDBOX'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> tapReal(WidgetTester tester, Finder f) async {
|
||||
// A status line appearing below the row can push the row's button out
|
||||
// of the viewport between taps.
|
||||
await tester.ensureVisible(f);
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(f);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
});
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('is absent from the daily build', (tester) async {
|
||||
Sandbox.enabled = false;
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(wrap());
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
expect(find.text('SANDBOX'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('wipe resets the database and the rest override', (tester) async {
|
||||
// DB writes run on the real loop: under the test zone's fake clock a
|
||||
// sqflite-ffi call never completes.
|
||||
await tester.runAsync(() async {
|
||||
await StorageService.instance.setSandboxRestSecs(30);
|
||||
await StorageService.instance.saveActiveSession({'workoutType': 'A'});
|
||||
});
|
||||
Sandbox.restSecs = 30;
|
||||
await pump(tester);
|
||||
await tapReal(tester, find.text('Wipe sandbox data'));
|
||||
expect(find.textContaining('Wiped —'), findsOneWidget);
|
||||
expect(Sandbox.restSecs, Sandbox.defaultRestSecs);
|
||||
final active = await tester.runAsync(
|
||||
StorageService.instance.loadActiveSession,
|
||||
);
|
||||
expect(active, isNull);
|
||||
});
|
||||
|
||||
testWidgets('mark today not done reports what it removed', (tester) async {
|
||||
await pump(tester);
|
||||
await tapReal(tester, find.text('Mark today not done'));
|
||||
expect(find.textContaining('Nothing logged today'), findsOneWidget);
|
||||
|
||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||
await tester.runAsync(
|
||||
() => StorageService.instance.saveSession(
|
||||
date: today,
|
||||
workoutType: 'A',
|
||||
durationSeconds: 1,
|
||||
succeeded: true,
|
||||
json: '{}',
|
||||
),
|
||||
);
|
||||
await tapReal(tester, find.text('Mark today not done'));
|
||||
expect(find.textContaining('Removed 1 workout'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('rest length: rejects junk, persists a whole number', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester);
|
||||
final field = find.byKey(const Key('sandbox-rest-secs'));
|
||||
await tester.enterText(field, 'abc');
|
||||
await tapReal(tester, find.text('Save'));
|
||||
expect(find.textContaining('whole number of seconds'), findsOneWidget);
|
||||
expect(Sandbox.restSecs, Sandbox.defaultRestSecs);
|
||||
|
||||
await tester.enterText(field, '0');
|
||||
await tapReal(tester, find.text('Save'));
|
||||
expect(find.textContaining('whole number of seconds'), findsOneWidget);
|
||||
|
||||
await tester.enterText(field, '45');
|
||||
await tapReal(tester, find.text('Save'));
|
||||
expect(find.text('Every rest now lasts 45 s.'), findsOneWidget);
|
||||
expect(Sandbox.restSecs, 45);
|
||||
final saved = await tester.runAsync(
|
||||
() => StorageService.instance.getSandboxRestSecs(5),
|
||||
);
|
||||
expect(saved, 45);
|
||||
});
|
||||
}
|
||||
@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/widgets/break_banner.dart';
|
||||
|
||||
import '../fake_audio_platform.dart';
|
||||
import '../fake_secure_storage.dart';
|
||||
@ -46,10 +45,11 @@ void main() {
|
||||
expect(
|
||||
audio.resumedSources.where((s) => s.contains('break_end')),
|
||||
isNotEmpty,
|
||||
reason: 'the rest ended 45s ago, inside the grace window, so the user '
|
||||
reason:
|
||||
'the rest ended 45s ago, inside the grace window, so the user '
|
||||
'should hear the cue they were waiting for',
|
||||
);
|
||||
expect(find.byType(BreakBanner), findsNothing);
|
||||
expect(restRunning(tester), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a long-expired break is not replayed, only logged', (
|
||||
@ -64,10 +64,11 @@ void main() {
|
||||
expect(
|
||||
audio.resumedSources.where((s) => s.contains('break_end')),
|
||||
isEmpty,
|
||||
reason: 'two hours late is past the grace window — replaying the sound '
|
||||
reason:
|
||||
'two hours late is past the grace window — replaying the sound '
|
||||
'would just be startling',
|
||||
);
|
||||
expect(find.byType(BreakBanner), findsNothing);
|
||||
expect(restRunning(tester), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a break still running is restored and keeps counting', (
|
||||
@ -75,7 +76,7 @@ void main() {
|
||||
) async {
|
||||
await pumpWorkout(tester, wrapWorkout(savedState: savedWithBreak(90)));
|
||||
|
||||
expect(find.byType(BreakBanner), findsOneWidget);
|
||||
expect(restRunning(tester), isTrue);
|
||||
expect(find.textContaining('well done'), findsOneWidget);
|
||||
expect(
|
||||
audio.resumedSources.where((s) => s.contains('break_end')),
|
||||
@ -91,7 +92,7 @@ void main() {
|
||||
// it comes back. Waiting for the next tick would delay the cue by a second
|
||||
// on a good day and forever on a Dozing phone.
|
||||
await pumpWorkout(tester, wrapWorkout(savedState: savedWithBreak(1)));
|
||||
expect(find.byType(BreakBanner), findsOneWidget);
|
||||
expect(restRunning(tester), isTrue);
|
||||
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 1200)),
|
||||
@ -103,7 +104,7 @@ void main() {
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(BreakBanner), findsNothing);
|
||||
expect(restRunning(tester), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('resuming with no rest running is harmless', (tester) async {
|
||||
@ -113,7 +114,7 @@ void main() {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
});
|
||||
await tester.pump();
|
||||
expect(find.byType(BreakBanner), findsNothing);
|
||||
expect(restRunning(tester), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a dead audio route does not take the workout down with it', (
|
||||
@ -140,7 +141,7 @@ void main() {
|
||||
isNotEmpty,
|
||||
reason: 'a cue that cannot sound must say so, not fail in silence',
|
||||
);
|
||||
expect(find.byType(BreakBanner), findsNothing);
|
||||
expect(restRunning(tester), isFalse);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/screens/workout_screen.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
import 'package:workout_app/widgets/break_banner.dart';
|
||||
import 'package:workout_app/widgets/exercise_tile.dart';
|
||||
import 'package:workout_app/widgets/rep_circle.dart';
|
||||
import 'package:workout_app/widgets/workout_summary_dialog.dart';
|
||||
@ -51,12 +50,12 @@ void main() {
|
||||
) async {
|
||||
await pumpWorkout(tester, wrapWorkout());
|
||||
await tapReal(tester, find.byType(RepCircle).first);
|
||||
expect(find.byType(BreakBanner), findsOneWidget);
|
||||
expect(restRunning(tester), isTrue);
|
||||
// Success break (5 done >= 5 target).
|
||||
expect(find.textContaining('well done'), findsOneWidget);
|
||||
|
||||
await tapReal(tester, find.text('Skip'));
|
||||
expect(find.byType(BreakBanner), findsNothing);
|
||||
expect(restRunning(tester), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('tapping warmup starts a warmup break', (tester) async {
|
||||
@ -83,10 +82,10 @@ void main() {
|
||||
await pumpWorkout(tester, wrapWorkout());
|
||||
final circle = find.byType(RepCircle).first;
|
||||
await tapReal(tester, circle);
|
||||
expect(find.byType(BreakBanner), findsOneWidget);
|
||||
expect(restRunning(tester), isTrue);
|
||||
|
||||
await longPressReal(tester, circle);
|
||||
expect(find.byType(BreakBanner), findsNothing);
|
||||
expect(restRunning(tester), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('finishing a completed workout saves and shows the summary', (
|
||||
@ -188,7 +187,7 @@ void main() {
|
||||
'breakEndMs': now.add(const Duration(seconds: 2)).millisecondsSinceEpoch,
|
||||
};
|
||||
await pumpWorkout(tester, wrapWorkout(savedState: saved));
|
||||
expect(find.byType(BreakBanner), findsOneWidget); // break restored
|
||||
expect(restRunning(tester), isTrue); // break restored
|
||||
|
||||
// The break timer is a real periodic timer (initState ran under pumpWorkout's
|
||||
// runAsync). Wait out its ~2s remaining so _tickBreak reaches 0 and
|
||||
@ -199,6 +198,6 @@ void main() {
|
||||
});
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.byType(BreakBanner), findsNothing);
|
||||
expect(restRunning(tester), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,168 @@
|
||||
// The workout screen at the phone's real logical size (1080x2400 @ 420 dpi):
|
||||
// every tile visible without scrolling, and nothing moves when a rest starts.
|
||||
//
|
||||
// Set WORKOUT_LAYOUT_SHOTS=<dir> to also dump idle.png / break.png there for
|
||||
// eyeballing the render when the phone cannot show the screen (done for
|
||||
// today).
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/widgets/break_banner.dart';
|
||||
import 'package:workout_app/widgets/exercise_tile.dart';
|
||||
import 'package:workout_app/widgets/rep_circle.dart';
|
||||
|
||||
import '../fake_audio_platform.dart';
|
||||
import '../fake_secure_storage.dart';
|
||||
import '_workout_screen_test_fixtures.dart';
|
||||
|
||||
const _phonePhysical = Size(1080, 2400);
|
||||
const _phoneDpr = 420 / 160;
|
||||
|
||||
Future<void> _shot(WidgetTester tester, String name) async {
|
||||
final dir = Platform.environment['WORKOUT_LAYOUT_SHOTS'];
|
||||
if (dir == null) {
|
||||
return;
|
||||
}
|
||||
final boundary =
|
||||
tester.renderObject(find.byKey(const Key('shot')))
|
||||
as RenderRepaintBoundary;
|
||||
await tester.runAsync(() async {
|
||||
final image = await boundary.toImage(pixelRatio: _phoneDpr);
|
||||
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
File('$dir/$name.png').writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
});
|
||||
}
|
||||
|
||||
/// Registers the SDK's Roboto as the test font.
|
||||
///
|
||||
/// The default test font draws every glyph one em wide, which makes the
|
||||
/// header and warmup rows overflow at the phone's width; the geometry under
|
||||
/// test only means something with the font the phone actually uses.
|
||||
Future<void> _loadRoboto() async {
|
||||
// The test runner's dart lives at <sdk>/bin/cache/dart-sdk/bin/dart; walk
|
||||
// up to <sdk>/bin/cache, where the SDK keeps the Material fonts.
|
||||
final cache = File(Platform.resolvedExecutable).parent.parent.parent.parent;
|
||||
final loader = FontLoader('Roboto');
|
||||
for (final weight in ['Regular', 'Medium', 'Bold']) {
|
||||
final ttf = File(
|
||||
'${cache.path}/artifacts/material_fonts/Roboto-$weight.ttf',
|
||||
);
|
||||
loader.addFont(Future.value(ByteData.sublistView(ttf.readAsBytesSync())));
|
||||
}
|
||||
await loader.load();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
await _loadRoboto();
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
StorageService.resetForTesting();
|
||||
await StorageService.init();
|
||||
installFakeSecureStorage();
|
||||
installFakeAudioPlatform();
|
||||
});
|
||||
|
||||
testWidgets('all tiles fit the phone and stay put when a rest starts', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = _phonePhysical;
|
||||
tester.view.devicePixelRatio = _phoneDpr;
|
||||
// The phone's status bar: it is what tips the four tiles over the
|
||||
// viewport, so the scale-to-fit guarantee is exercised, not just present.
|
||||
tester.view.padding = const FakeViewPadding(top: 24 * _phoneDpr);
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await pumpWorkout(
|
||||
tester,
|
||||
RepaintBoundary(
|
||||
key: const Key('shot'),
|
||||
child: wrapWorkout(type: 'B', exercises: workoutB),
|
||||
),
|
||||
);
|
||||
final viewport = tester.view.physicalSize / _phoneDpr;
|
||||
|
||||
Map<String, Rect> tileRects() => {
|
||||
for (final e in workoutB)
|
||||
e.name: tester.getRect(
|
||||
find.ancestor(
|
||||
of: find.text(e.name),
|
||||
matching: find.byType(ExerciseTile),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
final idle = tileRects();
|
||||
expect(idle, hasLength(workoutB.length));
|
||||
// FittedBox(scaleDown) makes "fits" true by construction; the property
|
||||
// that can actually regress is HOW MUCH it had to shrink. Measured at
|
||||
// 0.96 on the phone with four tiles; a fifth exercise or a taller tile
|
||||
// would push this through the floor and be caught here, not on the phone.
|
||||
final scale = idle.values.first.width / (viewport.width - 24);
|
||||
expect(scale, greaterThanOrEqualTo(0.9));
|
||||
expect(scale, lessThanOrEqualTo(1.0));
|
||||
expect(find.byType(Scrollable), findsNothing);
|
||||
expect(tester.getSize(find.byType(BreakBanner)).height, BreakBanner.height);
|
||||
expect(restRunning(tester), isFalse);
|
||||
expect(find.text('Rest'), findsOneWidget);
|
||||
expect(find.text('00:00'), findsOneWidget);
|
||||
await _shot(tester, 'idle');
|
||||
|
||||
await tapReal(tester, find.byType(RepCircle).first);
|
||||
expect(restRunning(tester), isTrue);
|
||||
expect(tester.getSize(find.byType(BreakBanner)).height, BreakBanner.height);
|
||||
expect(tileRects(), idle);
|
||||
await _shot(tester, 'break');
|
||||
|
||||
await tapReal(tester, find.text('Skip'));
|
||||
expect(restRunning(tester), isFalse);
|
||||
expect(tileRects(), idle);
|
||||
});
|
||||
|
||||
testWidgets('landscape would need the scale floor; portrait is locked', (
|
||||
tester,
|
||||
) async {
|
||||
// Documents why the manifest pins portrait: at the phone's landscape
|
||||
// size the column shrinks to roughly half, which is unreadable. The
|
||||
// guarantee still holds (nothing scrolls), the lock keeps it from
|
||||
// mattering.
|
||||
tester.view.physicalSize = Size(
|
||||
_phonePhysical.height,
|
||||
_phonePhysical.width,
|
||||
);
|
||||
tester.view.devicePixelRatio = _phoneDpr;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpWorkout(tester, wrapWorkout(type: 'B', exercises: workoutB));
|
||||
expect(find.byType(Scrollable), findsNothing);
|
||||
// getRect, not getSize: only the global rect carries the FittedBox scale.
|
||||
final width = tester.getRect(find.byType(ExerciseTile).first).width;
|
||||
expect(
|
||||
width / (tester.view.physicalSize.width / _phoneDpr - 24),
|
||||
lessThan(0.6),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('sandbox rests are short and say so', (tester) async {
|
||||
Sandbox.enabled = true;
|
||||
Sandbox.restSecs = 7;
|
||||
addTearDown(() {
|
||||
Sandbox.enabled = false;
|
||||
Sandbox.restSecs = Sandbox.defaultRestSecs;
|
||||
});
|
||||
await pumpWorkout(tester, wrapWorkout(type: 'B', exercises: workoutB));
|
||||
await tapReal(tester, find.byType(RepCircle).first);
|
||||
expect(find.text('Rest (7 s — sandbox)'), findsOneWidget);
|
||||
expect(find.text('00:07'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@ -14,7 +14,6 @@ import 'package:workout_app/models/break_snapshot.dart';
|
||||
import 'package:workout_app/services/break_intent_queue.dart';
|
||||
import 'package:workout_app/services/break_intent_store.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/widgets/break_banner.dart';
|
||||
import 'package:workout_app/widgets/rep_circle.dart';
|
||||
|
||||
import '../fake_audio_platform.dart';
|
||||
@ -35,8 +34,8 @@ void main() {
|
||||
await StorageService.init();
|
||||
installFakeSecureStorage();
|
||||
installFakeAudioPlatform();
|
||||
SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync
|
||||
.empty();
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
client = FakeForegroundBreakClient();
|
||||
});
|
||||
|
||||
@ -104,7 +103,7 @@ void main() {
|
||||
|
||||
await nudge(tester);
|
||||
|
||||
expect(find.byType(BreakBanner), findsOneWidget);
|
||||
expect(restRunning(tester), isTrue);
|
||||
expect(lastPushed().setsRemaining, 5);
|
||||
});
|
||||
|
||||
@ -112,7 +111,7 @@ void main() {
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester, saved: savedWithBreak(90));
|
||||
expect(find.byType(BreakBanner), findsOneWidget);
|
||||
expect(restRunning(tester), isTrue);
|
||||
|
||||
// In-app, tapping a NEW set mid-break is refused. From the notification it
|
||||
// means "I am done resting and done with the set" -- both taps at once.
|
||||
@ -135,11 +134,11 @@ void main() {
|
||||
|
||||
testWidgets('Skip break from the notification ends the rest', (tester) async {
|
||||
await pump(tester, saved: savedWithBreak(90));
|
||||
expect(find.byType(BreakBanner), findsOneWidget);
|
||||
expect(restRunning(tester), isTrue);
|
||||
|
||||
await seed([(BreakIntentKind.skipBreak, 0, 0)]);
|
||||
await nudge(tester);
|
||||
|
||||
expect(find.byType(BreakBanner), findsNothing);
|
||||
expect(restRunning(tester), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@ -131,7 +131,9 @@ void main() {
|
||||
|
||||
testWidgets('elapsed timer shows time in app bar', (tester) async {
|
||||
await pumpWorkout(tester, wrapWorkout());
|
||||
expect(find.textContaining('00:00'), findsOneWidget);
|
||||
// The idle rest strip shows 00:00 too; the app bar's clock carries the
|
||||
// workout type in the same string.
|
||||
expect(find.textContaining('Workout A · 00:00'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('B workout type shows in app bar', (tester) async {
|
||||
|
||||
@ -8,16 +8,16 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
tempDir = Directory.systemTemp.createTempSync('backup_service_test_');
|
||||
BackupService.baseDirForTesting = tempDir.path;
|
||||
BackupService.baseDir = tempDir.path;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
BackupService.baseDirForTesting = kBackupDir;
|
||||
BackupService.baseDir = kBackupDir;
|
||||
tempDir.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('baseDirForTesting getter reflects the last setter value', () {
|
||||
expect(BackupService.baseDirForTesting, tempDir.path);
|
||||
test('baseDir getter reflects the last setter value', () {
|
||||
expect(BackupService.baseDir, tempDir.path);
|
||||
});
|
||||
|
||||
test('export then readBackup round-trips the data', () async {
|
||||
@ -44,27 +44,33 @@ void main() {
|
||||
expect(await BackupService.instance.readSyncToken(), isNull);
|
||||
});
|
||||
|
||||
test('exportSyncToken with an empty token deletes an existing file', () async {
|
||||
test(
|
||||
'exportSyncToken with an empty token deletes an existing file',
|
||||
() async {
|
||||
await BackupService.instance.exportSyncToken('gho_abc123');
|
||||
await BackupService.instance.exportSyncToken('');
|
||||
expect(await BackupService.instance.readSyncToken(), isNull);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('exportSyncToken with an empty token is a no-op when no file exists', () async {
|
||||
test(
|
||||
'exportSyncToken with an empty token is a no-op when no file exists',
|
||||
() async {
|
||||
await BackupService.instance.exportSyncToken('');
|
||||
expect(await BackupService.instance.readSyncToken(), isNull);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('export creates the target directory if missing', () async {
|
||||
final nested = Directory('${tempDir.path}/nested');
|
||||
BackupService.baseDirForTesting = nested.path;
|
||||
BackupService.baseDir = nested.path;
|
||||
await BackupService.instance.export({'x': 1});
|
||||
expect(await BackupService.instance.readBackup(), {'x': 1});
|
||||
});
|
||||
|
||||
test('exportSyncToken creates the target directory if missing', () async {
|
||||
final nested = Directory('${tempDir.path}/nested2');
|
||||
BackupService.baseDirForTesting = nested.path;
|
||||
BackupService.baseDir = nested.path;
|
||||
await BackupService.instance.exportSyncToken('tok');
|
||||
expect(await BackupService.instance.readSyncToken(), 'tok');
|
||||
});
|
||||
@ -74,7 +80,7 @@ void main() {
|
||||
// throw -- export must swallow it, not crash the caller.
|
||||
final blocker = File('${tempDir.path}/blocker');
|
||||
blocker.writeAsStringSync('x');
|
||||
BackupService.baseDirForTesting = blocker.path;
|
||||
BackupService.baseDir = blocker.path;
|
||||
await BackupService.instance.export({'x': 1});
|
||||
// No exception reaching here is the assertion.
|
||||
});
|
||||
@ -84,7 +90,7 @@ void main() {
|
||||
() async {
|
||||
final blocker = File('${tempDir.path}/blocker2');
|
||||
blocker.writeAsStringSync('x');
|
||||
BackupService.baseDirForTesting = blocker.path;
|
||||
BackupService.baseDir = blocker.path;
|
||||
await BackupService.instance.exportSyncToken('tok');
|
||||
},
|
||||
);
|
||||
|
||||
@ -28,11 +28,11 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
tmp = Directory.systemTemp.createTempSync('mw_clobber');
|
||||
BackupService.baseDirForTesting = tmp.path;
|
||||
BackupService.baseDir = tmp.path;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
BackupService.baseDirForTesting = kBackupDir;
|
||||
BackupService.baseDir = kBackupDir;
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
@ -79,9 +79,8 @@ void main() {
|
||||
// The good backup must still be intact.
|
||||
expect(history.length, 1, reason: 'real session was clobbered');
|
||||
expect((history.first as Map)['date'], '2026-07-17');
|
||||
final situp = exercises.firstWhere(
|
||||
(e) => (e as Map)['name'] == 'Situp',
|
||||
) as Map;
|
||||
final situp =
|
||||
exercises.firstWhere((e) => (e as Map)['name'] == 'Situp') as Map;
|
||||
expect(situp['reps'], 31, reason: 'progression reps were clobbered');
|
||||
});
|
||||
|
||||
@ -106,11 +105,11 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
tmp = Directory.systemTemp.createTempSync('mw_restore');
|
||||
BackupService.baseDirForTesting = tmp.path;
|
||||
BackupService.baseDir = tmp.path;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
BackupService.baseDirForTesting = kBackupDir;
|
||||
BackupService.baseDir = kBackupDir;
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
StorageService.resetForTesting();
|
||||
await StorageService.init();
|
||||
});
|
||||
|
||||
Future<void> logWorkout(String date) => StorageService.instance.saveSession(
|
||||
date: date,
|
||||
workoutType: 'A',
|
||||
durationSeconds: 60,
|
||||
succeeded: true,
|
||||
json: '{}',
|
||||
);
|
||||
|
||||
test('wipeAll empties every table and re-seeds the exercises', () async {
|
||||
final storage = StorageService.instance;
|
||||
await logWorkout('2026-09-20');
|
||||
await storage.setLastWorkoutType('A');
|
||||
await storage.saveActiveSession({'workoutType': 'A'});
|
||||
await storage.setExerciseWeight('Dumbbell Lunge', 99);
|
||||
|
||||
await storage.wipeAll();
|
||||
|
||||
expect(await storage.getWorkoutHistory(), isEmpty);
|
||||
expect(await storage.loadActiveSession(), isNull);
|
||||
expect(await storage.getNextWorkoutType(), 'A');
|
||||
final lunge = await storage.getExerciseState('Dumbbell Lunge');
|
||||
expect(lunge!.weight, workoutA.first.weight);
|
||||
});
|
||||
|
||||
test(
|
||||
'deleteWorkoutsOn removes only that day and reports the count',
|
||||
() async {
|
||||
final storage = StorageService.instance;
|
||||
await logWorkout('2026-09-20');
|
||||
await logWorkout('2026-09-20');
|
||||
await logWorkout('2026-09-19');
|
||||
expect(await storage.deleteWorkoutsOn(DateTime(2026, 9, 20)), 2);
|
||||
expect(await storage.deleteWorkoutsOn(DateTime(2026, 9, 20)), 0);
|
||||
expect(await storage.getWorkoutHistory(), hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test('rest override round-trips and falls back when unset', () async {
|
||||
final storage = StorageService.instance;
|
||||
expect(await storage.getSandboxRestSecs(5), 5);
|
||||
await storage.setSandboxRestSecs(42);
|
||||
expect(await storage.getSandboxRestSecs(5), 42);
|
||||
});
|
||||
}
|
||||
@ -46,22 +46,25 @@ void main() {
|
||||
expect(await _svc.loadActiveSession(), isNull);
|
||||
});
|
||||
|
||||
test('saveActiveSession persists and loadActiveSession retrieves', () async {
|
||||
test(
|
||||
'saveActiveSession persists and loadActiveSession retrieves',
|
||||
() async {
|
||||
final data = {'workoutType': 'A', 'startTimeMs': 1000};
|
||||
await _svc.saveActiveSession(data);
|
||||
final loaded = await _svc.loadActiveSession();
|
||||
expect(loaded, isNotNull);
|
||||
expect(loaded!['workoutType'], 'A');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('survives a full app-data wipe via the external mirror', () async {
|
||||
// Regression: `pm clear` (or an uninstall) wipes app-private SQLite,
|
||||
// which is where active_session lives — the user lost the exact set and
|
||||
// reps they were standing on mid-workout. The mirror must bring it back.
|
||||
final tmp = Directory.systemTemp.createTempSync('mw_active_wipe');
|
||||
BackupService.baseDirForTesting = tmp.path;
|
||||
BackupService.baseDir = tmp.path;
|
||||
addTearDown(() {
|
||||
BackupService.baseDirForTesting = kBackupDir;
|
||||
BackupService.baseDir = kBackupDir;
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
@ -92,9 +95,9 @@ void main() {
|
||||
test('clearActiveSession also clears the mirror', () async {
|
||||
// Otherwise a finished workout would be resurrected on next launch.
|
||||
final tmp = Directory.systemTemp.createTempSync('mw_active_clear');
|
||||
BackupService.baseDirForTesting = tmp.path;
|
||||
BackupService.baseDir = tmp.path;
|
||||
addTearDown(() {
|
||||
BackupService.baseDirForTesting = kBackupDir;
|
||||
BackupService.baseDir = kBackupDir;
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/models/exercise.dart';
|
||||
@ -67,6 +69,32 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('writes the result file where filePath points', () async {
|
||||
final dir = Directory.systemTemp.createTempSync('sync_service_test');
|
||||
addTearDown(() {
|
||||
SyncService.filePath = kSyncFilePath;
|
||||
dir.deleteSync(recursive: true);
|
||||
});
|
||||
// The sandbox flavor redirects this the same way, to keep its
|
||||
// workouts out of the file the PC pulls.
|
||||
SyncService.filePath = '${dir.path}/workout_result.json';
|
||||
final session = WorkoutSession(
|
||||
workoutType: 'A',
|
||||
startTime: DateTime(2024),
|
||||
endTime: DateTime(2024),
|
||||
exercises: [],
|
||||
);
|
||||
|
||||
final result = await SyncService().writeWorkoutResult(session);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.path, SyncService.filePath);
|
||||
expect(
|
||||
File(SyncService.filePath).readAsStringSync(),
|
||||
contains('"workout_type": "A"'),
|
||||
);
|
||||
});
|
||||
|
||||
test('failed result has error message when no writable path', () async {
|
||||
final session = WorkoutSession(
|
||||
workoutType: 'B',
|
||||
|
||||
@ -15,11 +15,11 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
tempDir = Directory.systemTemp.createTempSync('sync_settings_test_');
|
||||
BackupService.baseDirForTesting = tempDir.path;
|
||||
BackupService.baseDir = tempDir.path;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
BackupService.baseDirForTesting = kBackupDir;
|
||||
BackupService.baseDir = kBackupDir;
|
||||
tempDir.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
@ -55,11 +55,14 @@ void main() {
|
||||
expect(s.token, '');
|
||||
});
|
||||
|
||||
test('load returns an empty token when the keystore is unavailable', () async {
|
||||
test(
|
||||
'load returns an empty token when the keystore is unavailable',
|
||||
() async {
|
||||
installFakeSecureStorage(throwing: true);
|
||||
final s = await SyncSettings.load();
|
||||
expect(s.token, '');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('save returns false when the keystore is unavailable', () async {
|
||||
installFakeSecureStorage(throwing: true);
|
||||
@ -74,10 +77,7 @@ void main() {
|
||||
test('save mirrors the token to the external-storage backup', () async {
|
||||
installFakeSecureStorage();
|
||||
await const SyncSettings(token: 'gho_backed_up').save();
|
||||
expect(
|
||||
await BackupService.instance.readSyncToken(),
|
||||
'gho_backed_up',
|
||||
);
|
||||
expect(await BackupService.instance.readSyncToken(), 'gho_backed_up');
|
||||
});
|
||||
|
||||
test(
|
||||
@ -90,10 +90,8 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'load recovers the token from the backup when the keystore is empty '
|
||||
'(e.g. right after a reinstall) and re-seeds the keystore',
|
||||
() async {
|
||||
test('load recovers the token from the backup when the keystore is empty '
|
||||
'(e.g. right after a reinstall) and re-seeds the keystore', () async {
|
||||
installFakeSecureStorage();
|
||||
await BackupService.instance.exportSyncToken('gho_recovered');
|
||||
|
||||
@ -103,8 +101,7 @@ void main() {
|
||||
// Re-seeded so the next load doesn't need the backup fallback.
|
||||
final again = await SyncSettings.load();
|
||||
expect(again.token, 'gho_recovered');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'load recovers from the backup even when the keystore write fails',
|
||||
@ -118,7 +115,9 @@ void main() {
|
||||
);
|
||||
|
||||
group('recoverFromBackup', () {
|
||||
test('re-seeds the keystore when the backup holds a different token', () async {
|
||||
test(
|
||||
're-seeds the keystore when the backup holds a different token',
|
||||
() async {
|
||||
// A stale keystore entry shadows a good backup forever: load() only
|
||||
// consults the backup when the keystore is EMPTY.
|
||||
installFakeSecureStorage(initial: {'sync.token': 'stale'});
|
||||
@ -131,13 +130,17 @@ void main() {
|
||||
expect(recovered, 'good-from-backup');
|
||||
// The keystore was re-seeded, so the next load() returns the good one.
|
||||
expect((await SyncSettings.load()).token, 'good-from-backup');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('returns null when the backup holds the same rejected token', () async {
|
||||
test(
|
||||
'returns null when the backup holds the same rejected token',
|
||||
() async {
|
||||
installFakeSecureStorage(initial: {'sync.token': 'same'});
|
||||
await const SyncSettings(token: 'same').save();
|
||||
expect(await SyncSettings.recoverFromBackup('same'), isNull);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('returns null when there is no backup to recover from', () async {
|
||||
installFakeSecureStorage(initial: {'sync.token': 'rejected'});
|
||||
|
||||
@ -29,9 +29,7 @@ void main() {
|
||||
installFakeSecureStorage();
|
||||
final (:httpClient, putCalls: _) = mockGitHub();
|
||||
expect(
|
||||
await syncService(
|
||||
httpClient: httpClient,
|
||||
).readMergedManualPayloads(),
|
||||
await syncService(httpClient: httpClient).readMergedManualPayloads(),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
@ -162,9 +160,7 @@ void main() {
|
||||
(request) async => http.Response('', 500),
|
||||
);
|
||||
expect(
|
||||
await syncService(
|
||||
httpClient: httpClient,
|
||||
).readMergedManualPayloads(),
|
||||
await syncService(httpClient: httpClient).readMergedManualPayloads(),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
@ -173,9 +169,9 @@ void main() {
|
||||
// A stale keystore token shadows a good backup (load() only falls back
|
||||
// when the keystore is EMPTY), which would leave history silently empty.
|
||||
final tempDir = Directory.systemTemp.createTempSync('sync_recover_');
|
||||
BackupService.baseDirForTesting = tempDir.path;
|
||||
BackupService.baseDir = tempDir.path;
|
||||
addTearDown(() {
|
||||
BackupService.baseDirForTesting = kBackupDir;
|
||||
BackupService.baseDir = kBackupDir;
|
||||
tempDir.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
@ -199,10 +195,7 @@ void main() {
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'content': base64Encode(utf8.encode(pcLog)),
|
||||
'sha': 'sha',
|
||||
}),
|
||||
jsonEncode({'content': base64Encode(utf8.encode(pcLog)), 'sha': 'sha'}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
@ -218,9 +211,9 @@ void main() {
|
||||
|
||||
test('reports the failure when the backup token is rejected too', () async {
|
||||
final tempDir = Directory.systemTemp.createTempSync('sync_recover_fail_');
|
||||
BackupService.baseDirForTesting = tempDir.path;
|
||||
BackupService.baseDir = tempDir.path;
|
||||
addTearDown(() {
|
||||
BackupService.baseDirForTesting = kBackupDir;
|
||||
BackupService.baseDir = kBackupDir;
|
||||
tempDir.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/main.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/services/http_server_service.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
@ -33,6 +34,29 @@ void main() {
|
||||
expect(find.text('Workout Tracker'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the sandbox flavor wears its ribbon on every screen', (
|
||||
tester,
|
||||
) async {
|
||||
Sandbox.enabled = true;
|
||||
addTearDown(() => Sandbox.enabled = false);
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(const WorkoutApp());
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
expect(find.byType(Banner), findsOneWidget);
|
||||
expect(tester.widget<Banner>(find.byType(Banner)).message, 'SANDBOX');
|
||||
});
|
||||
|
||||
testWidgets('the daily build has no ribbon', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(const WorkoutApp());
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
expect(find.byType(Banner), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('app-lifecycle changes drive the HTTP server', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(const WorkoutApp());
|
||||
|
||||
@ -14,6 +14,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
BreakBanner(
|
||||
active: true,
|
||||
breakRemaining: 90,
|
||||
breakLabel: 'Rest',
|
||||
onSkip: () {},
|
||||
@ -28,6 +29,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
BreakBanner(
|
||||
active: true,
|
||||
breakRemaining: 5,
|
||||
breakLabel: 'Warmup rest',
|
||||
onSkip: () {},
|
||||
@ -42,6 +44,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
BreakBanner(
|
||||
active: true,
|
||||
breakRemaining: 60,
|
||||
breakLabel: 'Rest',
|
||||
onSkip: () => skipped = true,
|
||||
@ -52,10 +55,36 @@ void main() {
|
||||
expect(skipped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('keeps its height and hides Skip while idle', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
BreakBanner(
|
||||
active: false,
|
||||
breakRemaining: 0,
|
||||
breakLabel: 'Rest',
|
||||
onSkip: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byType(BreakBanner)).height,
|
||||
BreakBanner.height,
|
||||
);
|
||||
expect(find.text('00:00'), findsOneWidget);
|
||||
// Skip keeps its space (nothing may move when a rest starts) but is
|
||||
// neither visible nor tappable.
|
||||
final skip = tester.widget<Visibility>(
|
||||
find.ancestor(of: find.text('Skip'), matching: find.byType(Visibility)),
|
||||
);
|
||||
expect(skip.visible, isFalse);
|
||||
expect(skip.maintainSize, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('zero seconds formats as 00:00', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
BreakBanner(
|
||||
active: true,
|
||||
breakRemaining: 0,
|
||||
breakLabel: 'Rest',
|
||||
onSkip: () {},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user