mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +01:00
The rest banner used to be inserted into the column only while a break ran, so its arrival took height from the exercise list, shifted every tile down and pushed the last one into scroll range. The strip is now a permanently reserved 64 dp slot (muted "Rest / 00:00" while idle, Skip keeps its space), the list is a non-scrolling column that scales down as a whole if it would not fit (0.96 on the Pixel 6a with four tiles, pinned by a widget test at the phone's logical size with the status bar simulated), and the activity is locked to portrait. Verified on the real phone: only the tapped circle's pixels change when a rest starts. Verifying that needed a workout screen that today's already-logged workout did not offer, so the app gained a `sandbox` product flavor beside `daily`: com.kuhy.workout_app.sandbox installs side by side with its own data, refuses every network connection at the socket layer, starts no LAN server for the PC to pull from, keeps its /sdcard files beside the daily build's, wears a red SANDBOX ribbon, and offers a SANDBOX section in Settings (wipe, mark today not done, rest length in seconds). MainActivity answers the flavor and carries a `WorkoutSandbox` logcat trace of every tap, break, save and navigation. On Linux the same mode is WORKOUT_SANDBOX=1, driven under Xvfb by scripts/run_sandbox_xvfb.sh. CI builds --flavor daily; .phone-deploy-flavor tells phone_deploy.sh the same. Set circles and the home icon buttons carry semantics labels so they can be addressed by element (and by screen readers). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YbBZqUaNWyhHYfzz6zGC7q
219 lines
7.8 KiB
Dart
219 lines
7.8 KiB
Dart
/// Settings screen: per-exercise streak thresholds and manual weight
|
|
/// overrides, plus links to the sync surfaces.
|
|
///
|
|
/// "Sync settings" is the shared `sync_settings_ui` package (Firebase sync;
|
|
/// no Backup section -- see the class doc on [SettingsScreen] for why).
|
|
/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]) because
|
|
/// the shared package has no GitHub surface at all.
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'package:crdt_sync/crdt_sync.dart';
|
|
import 'package:flutter/material.dart';
|
|
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';
|
|
import 'package:workout_app/services/google_sign_in_backend.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';
|
|
|
|
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';
|
|
|
|
class _SettingsScreenState extends State<SettingsScreen> {
|
|
bool _loading = true;
|
|
|
|
final Map<String, int> _successThresholds = {};
|
|
final Map<String, int> _failThresholds = {};
|
|
final Map<String, double> _weights = {};
|
|
final Map<String, int> _reps = {};
|
|
|
|
// Debounce weight saves to avoid resetting streaks on every tap.
|
|
final Map<String, Timer> _weightTimers = {};
|
|
|
|
// Same debounce for reps, for the same reason.
|
|
final Map<String, Timer> _repsTimers = {};
|
|
|
|
bool _storageGranted = false;
|
|
|
|
// Set after a Sync settings visit restores progression, so the banner
|
|
// persists until the user looks back at the screen -- mirrors the pattern
|
|
// every app-local sync status field in this migration already used.
|
|
String? _progressionStatus;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(_load());
|
|
unawaited(_loadStorageGranted());
|
|
}
|
|
|
|
Future<void> _loadStorageGranted() async {
|
|
final granted =
|
|
await (widget.storageChecker ??
|
|
BackupService.instance.hasStoragePermission)();
|
|
if (!mounted) return;
|
|
setState(() => _storageGranted = granted);
|
|
}
|
|
|
|
/// Opens the system page for MANAGE_EXTERNAL_STORAGE.
|
|
///
|
|
/// The ONLY caller of [BackupService.requestStoragePermission]: startup used
|
|
/// to call it and dumped the user in system Settings on every launch, for a
|
|
/// permission that is now genuinely optional.
|
|
Future<void> _grantStorage() async {
|
|
final granted =
|
|
await (widget.storageRequester ??
|
|
BackupService.instance.requestStoragePermission)();
|
|
if (!mounted) return;
|
|
setState(() => _storageGranted = granted);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final t in _weightTimers.values) {
|
|
t.cancel();
|
|
}
|
|
for (final t in _repsTimers.values) {
|
|
t.cancel();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
final states = await StorageService.instance.getAllExerciseStates();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
for (final s in states) {
|
|
_successThresholds[s.name] = s.successThreshold;
|
|
_failThresholds[s.name] = s.failThreshold;
|
|
_weights[s.name] = s.weight;
|
|
_reps[s.name] = s.reps;
|
|
}
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
/// Pushes the shared Sync settings screen, then pulls progression once it
|
|
/// pops -- covering both "connected just now" and "disconnected/no-op".
|
|
///
|
|
/// Not hooked into the connect flow itself: `SyncSettingsScreen` has no
|
|
/// post-connect callback, and [ProgressionSyncService.pullProgression]
|
|
/// already self-gates (returns immediately with no account, and refuses to
|
|
/// overwrite local state once [StorageService.hasSyncedProgression] is
|
|
/// true), so calling it unconditionally on pop is safe and simpler than
|
|
/// threading a callback through the shared screen's Firebase closures.
|
|
/// [ProgressionSyncService.pushProgression]'s own guard (never overwrite
|
|
/// remote progression from an install that hasn't pulled yet) is the actual
|
|
/// safety net against the reinstall-then-finish-a-workout race this
|
|
/// mirrors; this call is a convenience that restores progression
|
|
/// immediately instead of waiting for the next app launch, which also
|
|
/// pulls unconditionally at startup.
|
|
Future<void> _openSyncSettings() async {
|
|
await _pushSyncSettingsScreen();
|
|
if (!mounted) return;
|
|
final restored =
|
|
await (widget.progressionPuller ??
|
|
ProgressionSyncService().pullProgression)();
|
|
if (!mounted) return;
|
|
if (restored.changed) {
|
|
setState(
|
|
() => _progressionStatus =
|
|
'Restored ${restored.count} exercise(s) from Firebase.',
|
|
);
|
|
await _load();
|
|
}
|
|
}
|
|
|
|
void _onWeightChanged(String name, double value) {
|
|
setState(() => _weights[name] = value);
|
|
_weightTimers[name]?.cancel();
|
|
_weightTimers[name] = Timer(const Duration(milliseconds: 600), () {
|
|
unawaited(StorageService.instance.setExerciseWeight(name, value));
|
|
});
|
|
}
|
|
|
|
void _onRepsChanged(String name, int value) {
|
|
setState(() => _reps[name] = value);
|
|
_repsTimers[name]?.cancel();
|
|
_repsTimers[name] = Timer(const Duration(milliseconds: 600), () {
|
|
unawaited(StorageService.instance.setExerciseReps(name, value));
|
|
});
|
|
}
|
|
|
|
Future<void> _onThresholdChanged(String name, int success, int fail) async {
|
|
setState(() {
|
|
_successThresholds[name] = success;
|
|
_failThresholds[name] = fail;
|
|
});
|
|
await StorageService.instance.setExerciseThresholds(
|
|
name,
|
|
successThreshold: success,
|
|
failThreshold: fail,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: _SettingsAppBar(
|
|
loading: _loading,
|
|
onReset: () => unawaited(_resetToDefaults()),
|
|
),
|
|
body: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_WeightsSection(
|
|
orderedNames: _orderedNames,
|
|
weights: _weights,
|
|
onWeightChanged: _onWeightChanged,
|
|
),
|
|
const SizedBox(height: 20),
|
|
_RepsSection(
|
|
orderedNames: _orderedNames,
|
|
reps: _reps,
|
|
onRepsChanged: _onRepsChanged,
|
|
),
|
|
const SizedBox(height: 20),
|
|
_ThresholdsSection(
|
|
orderedNames: _orderedNames,
|
|
successThresholds: _successThresholds,
|
|
failThresholds: _failThresholds,
|
|
onThresholdChanged: (name, success, fail) =>
|
|
unawaited(_onThresholdChanged(name, success, fail)),
|
|
),
|
|
const SizedBox(height: 20),
|
|
_SyncSection(
|
|
progressionStatus: _progressionStatus,
|
|
onOpenSyncSettings: () => unawaited(_openSyncSettings()),
|
|
onOpenGitHubMirror: () => unawaited(_openGitHubMirror()),
|
|
),
|
|
const SizedBox(height: 20),
|
|
_OfflineBackupSection(
|
|
storageGranted: _storageGranted,
|
|
onGrantStorage: _grantStorage,
|
|
),
|
|
if (Sandbox.enabled) ...[
|
|
const SizedBox(height: 20),
|
|
const _SandboxSection(),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|