screen-locker/stronglift_replacement/workout_app/lib/main.dart
Krzysztof kuhy Rudnicki aab51eed2d
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Carry progression through Firebase, and stop demanding storage permission
Progression and the in-progress session now live at
screen-locker-sync/exercise_state/<name> and .../active_session — siblings of
devices/, not inside it. Both workout readers (_workout_sync.py:113 and
workout_sync_service.dart:307) list only screen-locker-sync/devices, so a
progression record is structurally invisible to them and cannot be miscounted
as a workout, which would silently grant unlock credit. Keyed on exercise name
because a reinstall mints a new per-install uuid.

Startup no longer calls requestStoragePermission(): that threw the user into a
system Settings page on every launch, for a permission that is now genuinely
optional. It only checks; the grant moved to an OFFLINE BACKUP section in
Settings, its sole caller.

The clobber guard keys on a persisted progression_synced_at marker, NOT
looksFreshlyInstalled(). The only production caller (_finishWorkout) writes a
workout_history row and last_workout_type BEFORE it pushes, so a freshness test
was already false by then and could never fire — a reinstall that connected
Firebase late (the uninstall wipes the keystore, so startup's pull finds no
account) would have overwritten every real remote record with factory defaults
on its first finished workout. Connecting in Settings now pulls immediately,
which closes that window. The same marker stops a pull reverting a deliberate
setExerciseWeight/resetExerciseToDefaults, which write no history at all.

Also adds a Dart silent-failure pre-commit hook. The Python one is
types:[python] and had never looked at a single Dart file — in the app where a
swallowed sync error costs the user a workout with nothing anywhere saying so.
It found 21 real silent swallows (keystore reads/writes, /sdcard mirror
failures, the HTTP bind, device-flow polls, and a .catchError((_) {})), all now
logging why.

Verified on the Pixel 6a: reinstall + deny storage + connect late restored
Bench 22.5→20.0, Row 22.5→25.0, Situp 30→31 reps with the remote copy
untouched; the RTDB device list stayed at exactly 3 entries and no progression
leaked into pull_synced_workout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JupKvZCNKRmEeRQY3G1unG
2026-08-10 16:54:21 +02:00

120 lines
4.6 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:workout_app/screens/home_screen.dart';
import 'package:workout_app/services/backup_service.dart';
import 'package:workout_app/services/http_server_service.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/ui/theme.dart';
// coverage:ignore-start
// App bootstrap: permission request, DB init, and a real listening socket —
// platform-channel work that can't run on the CI test host. The pieces it wires
// up are unit-tested individually; the entry point itself is not.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Before anything that stamps an Hlc or syncs: until this resolves, the
// device id falls back to the pre-migration constant.
await initSyncDeviceId();
// Storage permission is settled before the DB is opened: opening it seeds
// factory defaults for any missing exercise, and restoring afterwards is a
// no-op once rows exist. Losing that race is what wiped months of
// progression on 2026-08-05.
//
// CHECKED, never requested. `request()` throws the user into a system
// Settings page on every launch where the permission is absent, and since
// Firebase now carries progression the `/sdcard` backup is a second copy
// rather than the only one. The grant prompt lives in Settings, where the
// user asks for it.
final storageGranted = await BackupService.instance.hasStoragePermission();
if (!storageGranted) {
debugPrint(
'WorkoutApp: no storage permission — the backup at $kBackupPath can '
'neither be read nor written, so progression rides on Firebase alone. '
'Grant it in Settings to keep a second, offline copy.',
);
}
await StorageService.init();
await StorageService.instance.restoreFromBackupIfNeeded();
// The permission-free restore path, and the reason storage is now optional.
// `backup.json` is unreadable without the grant, so on a denied reinstall the
// line above is a no-op and this is the ONLY thing between the user and
// factory defaults. Safe to run unconditionally: it applies remote records
// only to a freshly-installed DB, so it restores a wiped install but never
// overwrites progression — or a deliberate reset — made on this phone.
//
// Bounded: this is N sequential network reads on the launch path, so a slow
// or half-open connection would otherwise hold the UI on a blank screen.
// 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(
const Duration(seconds: 20),
onTimeout: () => const ProgressionSyncResult(
changed: false,
reason:
'progression pull timed out after 20s — starting on local state; '
'the next launch will retry',
),
);
debugPrint('WorkoutApp: ${restored.reason}');
await HttpServerService.instance.start();
runApp(const WorkoutApp());
}
// coverage:ignore-end
/// Root widget that bootstraps the app with Material 3 dark theming.
///
/// Also owns the app-lifecycle observer that stops [HttpServerService]'s
/// listening socket while the app is backgrounded, so the process (and its
/// open socket) doesn't sit alive indefinitely and block Doze/App-Standby.
class WorkoutApp extends StatefulWidget {
/// Creates the root app widget.
const WorkoutApp({super.key});
@override
State<WorkoutApp> createState() => _WorkoutAppState();
}
class _WorkoutAppState extends State<WorkoutApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.paused:
case AppLifecycleState.detached:
unawaited(HttpServerService.instance.stop());
case AppLifecycleState.resumed:
unawaited(HttpServerService.instance.start());
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
break;
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Workout Tracker',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
home: const HomeScreen(),
);
}
}