diff --git a/scripts/check_flutter_coverage.sh b/scripts/check_flutter_coverage.sh
new file mode 100755
index 0000000..38513cc
--- /dev/null
+++ b/scripts/check_flutter_coverage.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# Enforces 100% line coverage for the Flutter workout app.
+#
+# Run from the repo root. Fails if:
+# 1. Any lib/**/*.dart file is missing from coverage/lcov.info (unreachable
+# file — the 100% would be fake without this check).
+# 2. Line coverage across all lib/ files is below 100%.
+#
+# Usage: scripts/check_flutter_coverage.sh
+set -euo pipefail
+
+APP_DIR="stronglift_replacement/workout_app"
+LCOV_INFO="$APP_DIR/coverage/lcov.info"
+
+cd "$APP_DIR"
+
+echo "Running flutter test --coverage ..."
+flutter test --coverage
+
+cd - > /dev/null
+
+if [[ ! -f "$LCOV_INFO" ]]; then
+ echo "ERROR: $LCOV_INFO not found — did flutter test run?" >&2
+ exit 1
+fi
+
+# ── Guard: every lib/**/*.dart must appear in the coverage report ──────────────
+# Flutter only reports files that are transitively imported by a test.
+# Unreferenced files silently score 100% by absence — catch that here.
+missing=0
+while IFS= read -r dart_file; do
+ rel="${dart_file#$APP_DIR/}"
+ if ! grep -qF "SF:$rel" "$LCOV_INFO" && \
+ ! grep -qF "SF:lib/${rel#lib/}" "$LCOV_INFO"; then
+ echo "MISSING FROM COVERAGE: $dart_file" >&2
+ missing=1
+ fi
+done < <(find "$APP_DIR/lib" -name "*.dart" ! -path "*/generated/*")
+
+if [[ $missing -eq 1 ]]; then
+ echo ""
+ echo "ERROR: Some lib/ files are not covered. Import them in a test." >&2
+ exit 1
+fi
+
+# ── Compute line coverage percentage from lcov.info ───────────────────────────
+total_found=0
+total_hit=0
+while IFS= read -r line; do
+ if [[ $line == LF:* ]]; then
+ total_found=$(( total_found + ${line#LF:} ))
+ elif [[ $line == LH:* ]]; then
+ total_hit=$(( total_hit + ${line#LH:} ))
+ fi
+done < "$LCOV_INFO"
+
+if [[ $total_found -eq 0 ]]; then
+ echo "ERROR: No line coverage data found in $LCOV_INFO" >&2
+ exit 1
+fi
+
+if [[ $total_hit -lt $total_found ]]; then
+ missed=$(( total_found - total_hit ))
+ echo "ERROR: Line coverage is not 100% — $missed/$total_found lines not covered." >&2
+ echo "Run: flutter test --coverage && genhtml coverage/lcov.info -o coverage/html" >&2
+ exit 1
+fi
+
+echo "Coverage OK: $total_hit/$total_found lines covered (100%)."
diff --git a/stronglift_replacement/workout_app/analysis_options.yaml b/stronglift_replacement/workout_app/analysis_options.yaml
index 0d29021..bac0810 100644
--- a/stronglift_replacement/workout_app/analysis_options.yaml
+++ b/stronglift_replacement/workout_app/analysis_options.yaml
@@ -1,28 +1,32 @@
-# This file configures the analyzer, which statically analyzes Dart code to
-# check for errors, warnings, and lints.
-#
-# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
-# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
-# invoked from the command line by running `flutter analyze`.
+include: package:very_good_analysis/analysis_options.yaml
-# The following line activates a set of recommended lints for Flutter apps,
-# packages, and plugins designed to encourage good coding practices.
-include: package:flutter_lints/flutter.yaml
+analyzer:
+ language:
+ strict-casts: true
+ strict-inference: true
+ strict-raw-types: true
+ errors:
+ # Promote key lints to errors so they fail CI, not just warn.
+ missing_required_param: error
+ unnecessary_null_comparison: error
+ dead_code: error
+ invalid_annotation_target: error
+ exclude:
+ - build/**
+ - "**/*.g.dart"
+ - "**/*.freezed.dart"
+ - "test/**"
linter:
- # The lint rules applied to this project can be customized in the
- # section below to disable rules from the `package:flutter_lints/flutter.yaml`
- # included above or to enable additional rules. A list of all available lints
- # and their documentation is published at https://dart.dev/lints.
- #
- # Instead of disabling a lint rule for the entire project in the
- # section below, it can also be suppressed for a single line of code
- # or a specific dart file by using the `// ignore: name_of_lint` and
- # `// ignore_for_file: name_of_lint` syntax on the line or in the file
- # producing the lint.
rules:
- # avoid_print: false # Uncomment to disable the `avoid_print` rule
- # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
-
-# Additional information about this file can be found at
-# https://dart.dev/guides/language/analysis-options
+ # very_good_analysis enables most rules; add extras it doesn't include.
+ always_use_package_imports: true
+ avoid_print: true
+ avoid_relative_lib_imports: true
+ cancel_subscriptions: true
+ close_sinks: true
+ comment_references: false # conflicts with typical Flutter API docs style
+ directives_ordering: true
+ lines_longer_than_80_chars: true
+ public_member_api_docs: true
+ unawaited_futures: true
diff --git a/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml b/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml
index d4bec6d..4abe52b 100644
--- a/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml
+++ b/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,9 @@
android:maxSdkVersion="29" />
+
+
diff --git a/stronglift_replacement/workout_app/lib/main.dart b/stronglift_replacement/workout_app/lib/main.dart
index f8b3329..306a445 100644
--- a/stronglift_replacement/workout_app/lib/main.dart
+++ b/stronglift_replacement/workout_app/lib/main.dart
@@ -1,16 +1,21 @@
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/storage_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
+ await BackupService.instance.requestStoragePermission();
await StorageService.init();
+ await StorageService.instance.restoreFromBackupIfNeeded();
await HttpServerService.instance.start();
runApp(const WorkoutApp());
}
+/// Root widget that bootstraps the app with Material 3 dark theming.
class WorkoutApp extends StatelessWidget {
+ /// Creates the root app widget.
const WorkoutApp({super.key});
@override
diff --git a/stronglift_replacement/workout_app/lib/models/exercise.dart b/stronglift_replacement/workout_app/lib/models/exercise.dart
index 1ca87a1..cb677da 100644
--- a/stronglift_replacement/workout_app/lib/models/exercise.dart
+++ b/stronglift_replacement/workout_app/lib/models/exercise.dart
@@ -1,38 +1,66 @@
-/// Core domain model for a single exercise definition and its current progression state.
+/// Core domain model for a single exercise and its current progression state.
library;
+/// Default weight cap: above this, reps increase instead of weight.
const double kDefaultMaxWeight = 27.5;
+
+/// Weight increment used for progression steps (kg).
const double kWeightIncrement = 2.5;
+/// Immutable definition of a single exercise and its current target state.
class Exercise {
+ /// Creates an exercise with the given parameters.
const Exercise({
required this.name,
required this.sets,
required this.reps,
required this.weight,
this.maxWeight = kDefaultMaxWeight,
+ this.hasWarmup = true,
});
+ /// Deserializes an exercise from a JSON map.
+ factory Exercise.fromJson(Map json) => Exercise(
+ name: json['name'] as String,
+ sets: json['sets'] as int,
+ reps: json['reps'] as int,
+ weight: (json['weight'] as num).toDouble(),
+ maxWeight: (json['maxWeight'] as num?)?.toDouble() ?? kDefaultMaxWeight,
+ hasWarmup: json['hasWarmup'] as bool? ?? true,
+ );
+
+ /// Display name of the exercise.
final String name;
+
+ /// Number of working sets per session.
final int sets;
+
+ /// Target reps per set.
final int reps;
+
+ /// Current working weight in kg.
final double weight;
/// Weight cap beyond which reps increase instead of weight.
final double maxWeight;
+ /// Whether a warmup set should be shown for this exercise.
+ final bool hasWarmup;
+
/// Warmup weight: 4/5 of target weight, rounded DOWN to nearest 2.5 kg.
double get warmupWeight {
final raw = weight * 4.0 / 5.0;
return (raw / kWeightIncrement).floor() * kWeightIncrement;
}
+ /// Returns a copy of this exercise with the given fields replaced.
Exercise copyWith({
String? name,
int? sets,
int? reps,
double? weight,
double? maxWeight,
+ bool? hasWarmup,
}) {
return Exercise(
name: name ?? this.name,
@@ -40,22 +68,17 @@ class Exercise {
reps: reps ?? this.reps,
weight: weight ?? this.weight,
maxWeight: maxWeight ?? this.maxWeight,
+ hasWarmup: hasWarmup ?? this.hasWarmup,
);
}
+ /// Serializes this exercise to a JSON map.
Map toJson() => {
- 'name': name,
- 'sets': sets,
- 'reps': reps,
- 'weight': weight,
- 'maxWeight': maxWeight,
- };
-
- factory Exercise.fromJson(Map json) => Exercise(
- name: json['name'] as String,
- sets: json['sets'] as int,
- reps: json['reps'] as int,
- weight: (json['weight'] as num).toDouble(),
- maxWeight: (json['maxWeight'] as num?)?.toDouble() ?? kDefaultMaxWeight,
- );
+ 'name': name,
+ 'sets': sets,
+ 'reps': reps,
+ 'weight': weight,
+ 'maxWeight': maxWeight,
+ 'hasWarmup': hasWarmup,
+ };
}
diff --git a/stronglift_replacement/workout_app/lib/models/exercise_result.dart b/stronglift_replacement/workout_app/lib/models/exercise_result.dart
index ef6559b..40a8131 100644
--- a/stronglift_replacement/workout_app/lib/models/exercise_result.dart
+++ b/stronglift_replacement/workout_app/lib/models/exercise_result.dart
@@ -4,27 +4,35 @@ library;
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/set_result.dart';
+/// Aggregated results for a single exercise across all its sets in a session.
class ExerciseResult {
+ /// Creates a result for [exercise] with the given [sets] outcomes.
const ExerciseResult({
required this.exercise,
required this.sets,
this.warmupDone = false,
});
+ /// The exercise definition this result belongs to.
final Exercise exercise;
+
+ /// Results for each individual set.
final List sets;
+
+ /// Whether the warmup set was completed before the working sets.
final bool warmupDone;
/// True when every set was fully completed.
bool get succeeded => sets.isNotEmpty && sets.every((s) => s.succeeded);
+ /// Serializes this exercise result to a JSON map.
Map toJson() => {
- 'name': exercise.name,
- 'targetSets': exercise.sets,
- 'targetReps': exercise.reps,
- 'targetWeight': exercise.weight,
- 'warmupDone': warmupDone,
- 'sets': sets.map((s) => s.toJson()).toList(),
- 'succeeded': succeeded,
- };
+ 'name': exercise.name,
+ 'targetSets': exercise.sets,
+ 'targetReps': exercise.reps,
+ 'targetWeight': exercise.weight,
+ 'warmupDone': warmupDone,
+ 'sets': sets.map((s) => s.toJson()).toList(),
+ 'succeeded': succeeded,
+ };
}
diff --git a/stronglift_replacement/workout_app/lib/models/set_result.dart b/stronglift_replacement/workout_app/lib/models/set_result.dart
index ca833a3..ff3f44e 100644
--- a/stronglift_replacement/workout_app/lib/models/set_result.dart
+++ b/stronglift_replacement/workout_app/lib/models/set_result.dart
@@ -1,39 +1,46 @@
/// Result of a single set during a workout session.
library;
+/// Immutable result of one set, recording target vs actual reps.
class SetResult {
+ /// Creates a set result.
const SetResult({
required this.targetReps,
required this.doneReps,
required this.weight,
});
+ /// Deserializes a set result from a JSON map.
+ factory SetResult.fromJson(Map json) => SetResult(
+ targetReps: json['targetReps'] as int,
+ doneReps: json['doneReps'] as int,
+ weight: (json['weight'] as num).toDouble(),
+ );
+
+ /// Target number of reps for this set.
final int targetReps;
- /// How many reps the user actually completed (may be < targetReps on failure).
+ /// Reps actually completed (may be less than [targetReps] on failure).
final int doneReps;
+ /// Weight used for this set in kg.
final double weight;
/// True when the user completed every target rep.
bool get succeeded => doneReps >= targetReps;
+ /// Returns a copy with [doneReps] replaced.
SetResult copyWith({int? doneReps}) => SetResult(
- targetReps: targetReps,
- doneReps: doneReps ?? this.doneReps,
- weight: weight,
- );
+ targetReps: targetReps,
+ doneReps: doneReps ?? this.doneReps,
+ weight: weight,
+ );
+ /// Serializes this set result to a JSON map.
Map toJson() => {
- 'targetReps': targetReps,
- 'doneReps': doneReps,
- 'weight': weight,
- 'succeeded': succeeded,
- };
-
- factory SetResult.fromJson(Map json) => SetResult(
- targetReps: json['targetReps'] as int,
- doneReps: json['doneReps'] as int,
- weight: (json['weight'] as num).toDouble(),
- );
+ 'targetReps': targetReps,
+ 'doneReps': doneReps,
+ 'weight': weight,
+ 'succeeded': succeeded,
+ };
}
diff --git a/stronglift_replacement/workout_app/lib/models/workout_plan.dart b/stronglift_replacement/workout_app/lib/models/workout_plan.dart
index 5d5be82..3adc20f 100644
--- a/stronglift_replacement/workout_app/lib/models/workout_plan.dart
+++ b/stronglift_replacement/workout_app/lib/models/workout_plan.dart
@@ -4,15 +4,22 @@ library;
import 'package:workout_app/models/exercise.dart';
/// Situp has a lower max weight cap.
-const double kSitupMaxWeight = 10.0;
+const double kSitupMaxWeight = 10;
+/// Plan A: lower-body and push/pull focus.
final workoutA = [
const Exercise(name: 'Dumbbell Lunge', sets: 5, reps: 12, weight: 7.5),
- const Exercise(name: 'Dumbbell Bench Press', sets: 5, reps: 12, weight: 22.5),
+ const Exercise(
+ name: 'Dumbbell Bench Press',
+ sets: 5,
+ reps: 12,
+ weight: 22.5,
+ ),
const Exercise(name: 'Dumbbell Row', sets: 4, reps: 6, weight: 22.5),
const Exercise(name: 'Dumbbell Curl', sets: 3, reps: 12, weight: 12.5),
];
+/// Plan B: posterior chain, overhead, and core focus.
final workoutB = [
const Exercise(
name: 'Dumbbell Romanian Deadlift',
@@ -26,12 +33,18 @@ final workoutB = [
reps: 12,
weight: 7.5,
),
- const Exercise(name: 'Dumbbell Bench Press', sets: 5, reps: 12, weight: 22.5),
+ const Exercise(
+ name: 'Dumbbell Bench Press',
+ sets: 5,
+ reps: 12,
+ weight: 22.5,
+ ),
const Exercise(
name: 'Situp',
sets: 3,
reps: 30,
- weight: 10.0,
+ weight: 10,
maxWeight: kSitupMaxWeight,
+ hasWarmup: false,
),
];
diff --git a/stronglift_replacement/workout_app/lib/models/workout_session.dart b/stronglift_replacement/workout_app/lib/models/workout_session.dart
index a80f0ff..e3f33ef 100644
--- a/stronglift_replacement/workout_app/lib/models/workout_session.dart
+++ b/stronglift_replacement/workout_app/lib/models/workout_session.dart
@@ -4,7 +4,9 @@ library;
import 'dart:convert';
import 'package:workout_app/models/exercise_result.dart';
+/// Immutable record of a finished workout session with all its results.
class WorkoutSession {
+ /// Creates a workout session record.
const WorkoutSession({
required this.workoutType,
required this.startTime,
@@ -14,24 +16,33 @@ class WorkoutSession {
/// 'A' or 'B'.
final String workoutType;
+
+ /// Wall-clock time when the session started.
final DateTime startTime;
+
+ /// Wall-clock time when the session ended.
final DateTime endTime;
+
+ /// Ordered list of exercise results, one per exercise in the plan.
final List exercises;
+ /// Total elapsed time of the session.
Duration get duration => endTime.difference(startTime);
/// True when every exercise succeeded.
bool get fullySucceeded => exercises.every((e) => e.succeeded);
+ /// Serializes this session to a JSON map.
Map toJson() => {
- 'workout_type': workoutType,
- 'date': startTime.toIso8601String().substring(0, 10),
- 'start_time': startTime.toIso8601String(),
- 'end_time': endTime.toIso8601String(),
- 'duration_seconds': duration.inSeconds,
- 'succeeded': fullySucceeded,
- 'exercises': exercises.map((e) => e.toJson()).toList(),
- };
+ 'workout_type': workoutType,
+ 'date': startTime.toIso8601String().substring(0, 10),
+ 'start_time': startTime.toIso8601String(),
+ 'end_time': endTime.toIso8601String(),
+ 'duration_seconds': duration.inSeconds,
+ 'succeeded': fullySucceeded,
+ 'exercises': exercises.map((e) => e.toJson()).toList(),
+ };
+ /// Serializes this session to a pretty-printed JSON string.
String toJsonString() => const JsonEncoder.withIndent(' ').convert(toJson());
}
diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen.dart b/stronglift_replacement/workout_app/lib/screens/history_screen.dart
index fac8306..6001984 100644
--- a/stronglift_replacement/workout_app/lib/screens/history_screen.dart
+++ b/stronglift_replacement/workout_app/lib/screens/history_screen.dart
@@ -5,6 +5,7 @@
/// exercise-only session list.
library;
+import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
@@ -14,7 +15,9 @@ import 'package:workout_app/widgets/calendar_widget.dart';
const _kTotal = 'Total (all workouts)';
+/// Screen showing workout history with per-exercise drill-down and charts.
class HistoryScreen extends StatefulWidget {
+ /// Creates a [HistoryScreen].
const HistoryScreen({super.key});
@override
@@ -27,13 +30,12 @@ class _HistoryScreenState extends State {
String _selected = _kTotal;
List _exerciseNames = [];
ExerciseState? _selectedState;
- DateTime _calendarMonth =
- DateTime(DateTime.now().year, DateTime.now().month);
+ DateTime _calendarMonth = DateTime(DateTime.now().year, DateTime.now().month);
@override
void initState() {
super.initState();
- _load();
+ unawaited(_load());
}
Future _load() async {
@@ -41,9 +43,8 @@ class _HistoryScreenState extends State {
final names = [];
final seen = {};
for (final row in rows) {
- final json =
- jsonDecode(row['json'] as String) as Map;
- for (final ex in (json['exercises'] as List)) {
+ final json = jsonDecode(row['json'] as String) as Map;
+ for (final ex in (json['exercises'] as List? ?? const [])) {
final name = (ex as Map)['name'] as String;
if (seen.add(name)) names.add(name);
}
@@ -75,7 +76,7 @@ class _HistoryScreenState extends State {
}
}
- // ── Data helpers ────────────────────────────────────────────────────────────
+ // ── Data helpers ──────────────────────────────────────────────────────────
/// All workout dates (YYYY-MM-DD) across all sessions.
Set get _allWorkoutDates =>
@@ -85,9 +86,8 @@ class _HistoryScreenState extends State {
Set _exerciseDates(String name) {
final result = {};
for (final row in _rows) {
- final json =
- jsonDecode(row['json'] as String) as Map;
- for (final ex in (json['exercises'] as List)) {
+ final json = jsonDecode(row['json'] as String) as Map;
+ for (final ex in (json['exercises'] as List? ?? const [])) {
if ((ex as Map)['name'] == name) {
result.add(row['date'] as String);
break;
@@ -101,10 +101,9 @@ class _HistoryScreenState extends State {
List<(DateTime, double)> _totalVolumePoints() {
final points = <(DateTime, double)>[];
for (final row in _rows.reversed) {
- final json =
- jsonDecode(row['json'] as String) as Map;
+ final json = jsonDecode(row['json'] as String) as Map;
double total = 0;
- for (final ex in (json['exercises'] as List)) {
+ for (final ex in (json['exercises'] as List? ?? const [])) {
final m = ex as Map;
final w = (m['targetWeight'] as num?)?.toDouble() ?? 0;
final s = (m['targetSets'] as num?)?.toInt() ?? 0;
@@ -121,9 +120,8 @@ class _HistoryScreenState extends State {
List<(DateTime, double)> _exerciseWeightPoints(String name) {
final points = <(DateTime, double)>[];
for (final row in _rows.reversed) {
- final json =
- jsonDecode(row['json'] as String) as Map;
- for (final ex in (json['exercises'] as List)) {
+ final json = jsonDecode(row['json'] as String) as Map;
+ for (final ex in (json['exercises'] as List? ?? const [])) {
final m = ex as Map;
if (m['name'] == name) {
final date = DateTime.tryParse(row['date'] as String);
@@ -140,9 +138,8 @@ class _HistoryScreenState extends State {
List