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> _sessionsForExercise(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 [])) { final m = ex as Map; if (m['name'] == name) { result.add({...row, 'exerciseData': m}); @@ -153,7 +150,7 @@ class _HistoryScreenState extends State { return result; } - // ── Build ─────────────────────────────────────────────────────────────────── + // ── Build ──────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { @@ -170,25 +167,27 @@ class _HistoryScreenState extends State { body: _loading ? const Center(child: CircularProgressIndicator()) : _rows.isEmpty - ? const Center( - child: Text( - 'No workouts yet.', - style: TextStyle(color: Colors.white54), - ), - ) - : ListView( - padding: const EdgeInsets.all(12), - children: [ - _ExercisePicker( - names: allNames, - selected: _selected, - onChanged: _pickExercise, - ), - const SizedBox(height: 12), - if (isTotal) ..._buildTotalView() - else ..._buildExerciseView(_selected), - ], + ? const Center( + child: Text( + 'No workouts yet.', + style: TextStyle(color: Colors.white54), + ), + ) + : ListView( + padding: const EdgeInsets.all(12), + children: [ + _ExercisePicker( + names: allNames, + selected: _selected, + onChanged: _pickExercise, ), + const SizedBox(height: 12), + if (isTotal) + ..._buildTotalView() + else + ..._buildExerciseView(_selected), + ], + ), ); } @@ -204,70 +203,69 @@ class _HistoryScreenState extends State { } List _buildTotalView() => [ - _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'), - const SizedBox(height: 6), - _WeightChart( - points: _rollingAvg2(_totalVolumePoints()), - ), - const SizedBox(height: 16), - WorkoutCalendar( - workoutDates: _allWorkoutDates, - month: _calendarMonth, - onPrevMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month - 1, - ); - }), - onNextMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month + 1, - ); - }), - ), - const SizedBox(height: 16), - _SectionLabel('ALL SESSIONS'), - const SizedBox(height: 8), - ..._rows.map((row) => _AllSessionTile(row: row)), - ]; + const _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'), + const SizedBox(height: 6), + _WeightChart( + points: _rollingAvg2(_totalVolumePoints()), + ), + const SizedBox(height: 16), + WorkoutCalendar( + workoutDates: _allWorkoutDates, + month: _calendarMonth, + onPrevMonth: () => setState(() { + _calendarMonth = DateTime( + _calendarMonth.year, + _calendarMonth.month - 1, + ); + }), + onNextMonth: () => setState(() { + _calendarMonth = DateTime( + _calendarMonth.year, + _calendarMonth.month + 1, + ); + }), + ), + const SizedBox(height: 16), + const _SectionLabel('ALL SESSIONS'), + const SizedBox(height: 8), + ..._rows.map((row) => _AllSessionTile(row: row)), + ]; List _buildExerciseView(String name) => [ - if (_selectedState != null) ...[ - _ProgressStatsCard(state: _selectedState!), - const SizedBox(height: 12), - ], - _SectionLabel('WEIGHT OVER TIME'), - const SizedBox(height: 6), - _WeightChart( - points: _exerciseWeightPoints(name), - ), - const SizedBox(height: 16), - WorkoutCalendar( - workoutDates: _exerciseDates(name), - month: _calendarMonth, - onPrevMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month - 1, - ); - }), - onNextMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month + 1, - ); - }), - ), - const SizedBox(height: 16), - _SectionLabel(name.toUpperCase()), - const SizedBox(height: 8), - ..._sessionsForExercise(name) - .map((s) => _ExerciseSessionTile(session: s)), - ]; + if (_selectedState != null) ...[ + _ProgressStatsCard(state: _selectedState!), + const SizedBox(height: 12), + ], + const _SectionLabel('WEIGHT OVER TIME'), + const SizedBox(height: 6), + _WeightChart( + points: _exerciseWeightPoints(name), + ), + const SizedBox(height: 16), + WorkoutCalendar( + workoutDates: _exerciseDates(name), + month: _calendarMonth, + onPrevMonth: () => setState(() { + _calendarMonth = DateTime( + _calendarMonth.year, + _calendarMonth.month - 1, + ); + }), + onNextMonth: () => setState(() { + _calendarMonth = DateTime( + _calendarMonth.year, + _calendarMonth.month + 1, + ); + }), + ), + const SizedBox(height: 16), + _SectionLabel(name.toUpperCase()), + const SizedBox(height: 8), + ..._sessionsForExercise(name).map((s) => _ExerciseSessionTile(session: s)), + ]; } -// ── Shared sub-widgets ───────────────────────────────────────────────────────── +// ── Shared sub-widgets ────────────────────────────────────────────────────── class _SectionLabel extends StatelessWidget { const _SectionLabel(this.text); @@ -314,8 +312,7 @@ class _ExercisePicker extends StatelessWidget { n, style: TextStyle( color: n == _kTotal ? Colors.white70 : Colors.white, - fontStyle: - n == _kTotal ? FontStyle.italic : FontStyle.normal, + fontStyle: n == _kTotal ? FontStyle.italic : FontStyle.normal, ), ), ), @@ -481,8 +478,18 @@ class _ChartPainter extends CustomPainter { static const _hPad = 8.0; static const _months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', ]; static String _shortDate(DateTime d) => '${_months[d.month - 1]} ${d.day}'; @@ -496,9 +503,9 @@ class _ChartPainter extends CustomPainter { final wRange = maxW - minW; final tRange = maxMs - minMs; - final plotTop = _topPad; + const plotTop = _topPad; final plotBottom = size.height - _bottomPad; - final plotLeft = _hPad; + const plotLeft = _hPad; final plotRight = size.width - _hPad; final plotHeight = plotBottom - plotTop; final plotWidth = plotRight - plotLeft; @@ -518,8 +525,7 @@ class _ChartPainter extends CustomPainter { ..color = Colors.indigoAccent ..style = PaintingStyle.fill; - final path = Path() - ..moveTo(xOf(points.first.$1), yOf(points.first.$2)); + final path = Path()..moveTo(xOf(points.first.$1), yOf(points.first.$2)); for (final p in points.skip(1)) { path.lineTo(xOf(p.$1), yOf(p.$2)); } @@ -540,7 +546,7 @@ class _ChartPainter extends CustomPainter { ..paint(canvas, offset); } - drawText('${maxW.round()}kg', Offset(plotLeft, 0)); + drawText('${maxW.round()}kg', const Offset(plotLeft, 0)); drawText('${minW.round()}kg', Offset(plotLeft, plotBottom + 2)); // X-axis date labels: first, middle, last @@ -592,7 +598,6 @@ class _AllSessionTile extends StatelessWidget { borderRadius: BorderRadius.circular(8), border: Border.all( color: succeeded ? Colors.green.shade800 : Colors.red.shade900, - width: 1, ), ), child: Row( @@ -616,8 +621,7 @@ class _AllSessionTile extends StatelessWidget { ), Text( dur, - style: - const TextStyle(color: Colors.white54, fontSize: 12), + style: const TextStyle(color: Colors.white54, fontSize: 12), ), ], ), @@ -648,8 +652,7 @@ class _ExerciseSessionTile extends StatelessWidget { final dur = _formatDuration(session['duration_seconds'] as int); final weight = (exData['targetWeight'] as num?)?.toDouble(); final warmupDone = exData['warmupDone'] as bool? ?? false; - final sets = - (exData['sets'] as List?)?.cast>() ?? []; + final sets = (exData['sets'] as List?)?.cast>() ?? []; final targetSets = exData['targetSets'] as int? ?? sets.length; final doneSets = sets.where((s) => s['succeeded'] == true).length; final repsSummary = sets.map((s) => '${s['doneReps']}').join(', '); @@ -662,7 +665,6 @@ class _ExerciseSessionTile extends StatelessWidget { borderRadius: BorderRadius.circular(8), border: Border.all( color: succeeded ? Colors.green.shade800 : Colors.red.shade900, - width: 1, ), ), child: Row( diff --git a/stronglift_replacement/workout_app/lib/screens/home_screen.dart b/stronglift_replacement/workout_app/lib/screens/home_screen.dart index 411020f..fa9eecd 100644 --- a/stronglift_replacement/workout_app/lib/screens/home_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/home_screen.dart @@ -1,6 +1,7 @@ /// Home screen: auto-resumes an active session, shows done-today status. library; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/screens/history_screen.dart'; @@ -9,7 +10,9 @@ import 'package:workout_app/screens/workout_screen.dart'; import 'package:workout_app/services/http_server_service.dart'; import 'package:workout_app/services/storage_service.dart'; +/// Home screen: auto-resumes active sessions and shows done-today status. class HomeScreen extends StatefulWidget { + /// Creates a [HomeScreen]. const HomeScreen({super.key}); @override @@ -17,7 +20,7 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - List? _exercises; + late List _exercises; String _nextType = 'A'; List _serverAddresses = []; bool _loading = true; @@ -31,7 +34,7 @@ class _HomeScreenState extends State { @override void initState() { super.initState(); - _load(); + unawaited(_load()); } Future _load() async { @@ -42,7 +45,8 @@ class _HomeScreenState extends State { final addrs = await HttpServerService.instance.localAddresses; final lastDate = await storage.getLastWorkoutDate(); final today = DateTime.now(); - final doneToday = lastDate != null && + final doneToday = + lastDate != null && lastDate.year == today.year && lastDate.month == today.month && lastDate.day == today.day; @@ -61,7 +65,7 @@ class _HomeScreenState extends State { if (saved != null && !_hasAutoResumed) { _hasAutoResumed = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _openWorkout(resume: true); + if (mounted) unawaited(_openWorkout(resume: true)); }); } } @@ -70,8 +74,8 @@ class _HomeScreenState extends State { Future _openWorkout({bool resume = false}) async { final storage = StorageService.instance; Map? savedState; - String type = _nextType; - List exercises = _exercises!; + var type = _nextType; + var exercises = _exercises; if (resume && _savedSession != null) { savedState = _savedSession; @@ -90,7 +94,7 @@ class _HomeScreenState extends State { ), ), ); - _load(); + unawaited(_load()); } @override @@ -118,7 +122,7 @@ class _HomeScreenState extends State { builder: (_) => const SettingsScreen(), ), ); - _load(); + unawaited(_load()); }, ), ], @@ -132,10 +136,10 @@ class _HomeScreenState extends State { children: [ _WorkoutCard( type: _nextType, - exercises: _exercises!, + exercises: _exercises, doneToday: _doneToday, hasActiveSession: _savedSession != null, - onStart: () => _openWorkout(resume: false), + onStart: _openWorkout, onResume: () => _openWorkout(resume: true), ), const SizedBox(height: 20), @@ -147,7 +151,7 @@ class _HomeScreenState extends State { } } -// ── Sub-widgets ──────────────────────────────────────────────────────────────── +// ── Sub-widgets ────────────────────────────────────────────────────────────── class _WorkoutCard extends StatelessWidget { const _WorkoutCard({ diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart index c945db6..e78381e 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart @@ -8,7 +8,9 @@ import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/services/storage_service.dart'; +/// Screen for editing per-exercise thresholds and manual weight overrides. class SettingsScreen extends StatefulWidget { + /// Creates a [SettingsScreen]. const SettingsScreen({super.key}); @override @@ -16,7 +18,6 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { - List? _states; bool _loading = true; final Map _successThresholds = {}; @@ -29,7 +30,7 @@ class _SettingsScreenState extends State { @override void initState() { super.initState(); - _load(); + unawaited(_load()); } @override @@ -44,7 +45,6 @@ class _SettingsScreenState extends State { final states = await StorageService.instance.getAllExerciseStates(); if (mounted) { setState(() { - _states = states; for (final s in states) { _successThresholds[s.name] = s.successThreshold; _failThresholds[s.name] = s.failThreshold; @@ -59,7 +59,7 @@ class _SettingsScreenState extends State { setState(() => _weights[name] = value); _weightTimers[name]?.cancel(); _weightTimers[name] = Timer(const Duration(milliseconds: 600), () { - StorageService.instance.setExerciseWeight(name, value); + unawaited(StorageService.instance.setExerciseWeight(name, value)); }); } @@ -92,13 +92,17 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: - const Text('Cancel', style: TextStyle(color: Colors.white70)), + child: const Text( + 'Cancel', + style: TextStyle(color: Colors.white70), + ), ), TextButton( onPressed: () => Navigator.pop(context, true), - child: - const Text('Reset', style: TextStyle(color: Colors.redAccent)), + child: const Text( + 'Reset', + style: TextStyle(color: Colors.redAccent), + ), ), ], ), @@ -113,10 +117,10 @@ class _SettingsScreenState extends State { List get _orderedNames { final seen = {}; - return [...workoutA, ...workoutB] - .map((e) => e.name) - .where(seen.add) - .toList(); + return [ + ...workoutA, + ...workoutB, + ].map((e) => e.name).where(seen.add).toList(); } @override diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart index 647d4c7..746d3fa 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart @@ -3,26 +3,40 @@ library; import 'dart:async'; +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; +import 'package:vibration/vibration.dart'; 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/services/storage_service.dart'; import 'package:workout_app/services/sync_service.dart'; +import 'package:workout_app/widgets/break_banner.dart'; import 'package:workout_app/widgets/exercise_tile.dart'; import 'package:workout_app/widgets/workout_summary_dialog.dart'; +const _successBreakSecs = 180; // 3 min after successful set +const _failBreakSecs = 300; // 5 min after failed set +const _warmupBreakSecs = 180; // 3 min after warmup + +/// Screen that drives an active workout session with per-rep tracking. class WorkoutScreen extends StatefulWidget { + /// Creates a [WorkoutScreen]. const WorkoutScreen({ - super.key, required this.workoutType, required this.exercises, + super.key, this.savedState, }); + /// 'A' or 'B' — used for history and progression. final String workoutType; + + /// Ordered list of exercises for this session. final List exercises; + + /// Serialized state to restore (crash-recovery); null for a fresh session. final Map? savedState; @override @@ -39,6 +53,18 @@ class _WorkoutScreenState extends State { Map _exerciseStates = {}; + // Break state + int _breakRemaining = 0; + int _breakDurationSecs = 0; + DateTime? _breakStartTime; + Timer? _breakTimer; + String _breakLabel = ''; + int _breakForExIdx = -1; + int _breakForSetIdx = -1; // -1 = warmup break + + bool get _inBreak => _breakRemaining > 0; + + final _audio = AudioPlayer(); final _sync = SyncService(); bool _finished = false; @@ -54,7 +80,7 @@ class _WorkoutScreenState extends State { _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) { setState(() => _elapsed = DateTime.now().difference(_startTime)); }); - _loadExerciseStates(); + unawaited(_loadExerciseStates()); } void _initFresh() { @@ -79,6 +105,22 @@ class _WorkoutScreenState extends State { .map((row) => (row as List).cast()) .toList(); _warmupTapped = (s['warmupTapped'] as List).cast(); + + final breakEndMs = s['breakEndMs'] as int? ?? 0; + final breakDur = s['breakDurationSecs'] as int? ?? 0; + if (breakEndMs > 0 && breakDur > 0) { + final endTime = DateTime.fromMillisecondsSinceEpoch(breakEndMs); + final remaining = endTime.difference(DateTime.now()).inSeconds; + if (remaining > 0) { + _breakForExIdx = s['breakForExIdx'] as int? ?? -1; + _breakForSetIdx = s['breakForSetIdx'] as int? ?? -1; + _breakLabel = s['breakLabel'] as String? ?? 'Rest'; + _breakDurationSecs = breakDur; + _breakStartTime = endTime.subtract(Duration(seconds: breakDur)); + _breakRemaining = remaining; + _breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak); + } + } } Future _loadExerciseStates() async { @@ -93,6 +135,8 @@ class _WorkoutScreenState extends State { @override void dispose() { _elapsedTimer.cancel(); + _breakTimer?.cancel(); + unawaited(_audio.dispose()); super.dispose(); } @@ -105,6 +149,15 @@ class _WorkoutScreenState extends State { 'tapped': _tapped, 'doneReps': _doneReps, 'warmupTapped': _warmupTapped, + 'breakForExIdx': _breakForExIdx, + 'breakForSetIdx': _breakForSetIdx, + 'breakLabel': _breakLabel, + 'breakDurationSecs': _breakDurationSecs, + 'breakEndMs': _breakStartTime != null + ? _breakStartTime! + .add(Duration(seconds: _breakDurationSecs)) + .millisecondsSinceEpoch + : 0, }); } @@ -118,34 +171,143 @@ class _WorkoutScreenState extends State { bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t)); + /// True when [setIdx] is the last untapped set of exercise [exIdx]. + bool _isLastSetOfExercise(int exIdx, int setIdx) { + final sets = widget.exercises[exIdx].sets; + for (var s = 0; s < sets; s++) { + if (s != setIdx && !_tapped[exIdx][s]) return false; + } + return true; + } + // ── Interaction ──────────────────────────────────────────────────────────── - void _tapCircle(int exIdx, int repIdx) { + void _tapCircle(int exIdx, int setIdx) { if (_finished) return; + + final wasNotTapped = !_tapped[exIdx][setIdx]; + if (wasNotTapped && _inBreak) return; + setState(() { - if (!_tapped[exIdx][repIdx]) { - _tapped[exIdx][repIdx] = true; + if (wasNotTapped) { + _tapped[exIdx][setIdx] = true; } else { - _doneReps[exIdx][repIdx] = - (_doneReps[exIdx][repIdx] - 1).clamp(0, 999); + _doneReps[exIdx][setIdx] = + (_doneReps[exIdx][setIdx] - 1).clamp(0, 999); + _recomputeBreakIfNeeded(exIdx, setIdx); } }); - _saveActiveSession(); + + if (wasNotTapped) { + final isLastSet = _isLastSetOfExercise(exIdx, setIdx); + if (!isLastSet) { + final succeeded = + _doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps; + _startBreak( + succeeded ? _successBreakSecs : _failBreakSecs, + succeeded + ? 'Rest (3 min — well done!)' + : 'Rest (5 min — keep going!)', + exIdx, + setIdx, + ); + } + } + + unawaited(_saveActiveSession()); } void _tapWarmup(int exIdx) { if (_finished || _warmupTapped[exIdx]) return; setState(() => _warmupTapped[exIdx] = true); - _saveActiveSession(); + if (!_inBreak) { + _startBreak(_warmupBreakSecs, 'Warmup rest (3 min)', exIdx, -1); + } + unawaited(_saveActiveSession()); } - void _resetCircle(int exIdx, int repIdx) { + void _resetCircle(int exIdx, int setIdx) { if (_finished) return; setState(() { - _tapped[exIdx][repIdx] = false; - _doneReps[exIdx][repIdx] = widget.exercises[exIdx].reps; + _tapped[exIdx][setIdx] = false; + _doneReps[exIdx][setIdx] = widget.exercises[exIdx].reps; }); - _saveActiveSession(); + if (_breakForExIdx == exIdx && _breakForSetIdx == setIdx) { + _cancelBreak(); + } + unawaited(_saveActiveSession()); + } + + // ── Break management ─────────────────────────────────────────────────────── + + void _startBreak(int secs, String label, int exIdx, int setIdx) { + _breakTimer?.cancel(); + setState(() { + _breakDurationSecs = secs; + _breakRemaining = secs; + _breakLabel = label; + _breakForExIdx = exIdx; + _breakForSetIdx = setIdx; + _breakStartTime = DateTime.now(); + }); + _breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak); + } + + void _tickBreak(Timer t) { + setState(() => _breakRemaining--); + if (_breakRemaining <= 0) { + t.cancel(); + unawaited(_onBreakFinished()); + } + } + + void _cancelBreak() { + _breakTimer?.cancel(); + setState(() { + _breakRemaining = 0; + _breakForExIdx = -1; + _breakForSetIdx = -1; + _breakStartTime = null; + }); + } + + void _skipBreak() { + _cancelBreak(); + unawaited(_saveActiveSession()); + } + + /// When the user decrements reps on the set that triggered the current break, + /// switch between 3-min (success) and 5-min (fail) durations. + void _recomputeBreakIfNeeded(int exIdx, int setIdx) { + if (!_inBreak) return; + if (_breakForExIdx != exIdx || _breakForSetIdx != setIdx) return; + if (_breakForSetIdx == -1) return; // warmup break, never recompute + + final succeeded = + _doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps; + final newDuration = succeeded ? _successBreakSecs : _failBreakSecs; + if (newDuration == _breakDurationSecs) return; + + final elapsed = DateTime.now().difference(_breakStartTime!).inSeconds; + final newRemaining = (newDuration - elapsed).clamp(0, newDuration); + + _breakDurationSecs = newDuration; + _breakRemaining = newRemaining; + _breakLabel = + succeeded ? 'Rest (3 min — well done!)' : 'Rest (5 min — keep going!)'; + } + + Future _onBreakFinished() async { + await _audio.play(AssetSource('sounds/break_end.mp3')).catchError((_) {}); + if (await Vibration.hasVibrator()) { + unawaited(Vibration.vibrate(duration: 800)); + } + setState(() { + _breakForExIdx = -1; + _breakForSetIdx = -1; + _breakStartTime = null; + }); + unawaited(_saveActiveSession()); } Future _onThresholdChanged( @@ -191,8 +353,10 @@ class _WorkoutScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: - const Text('Cancel', style: TextStyle(color: Colors.white70)), + child: const Text( + 'Cancel', + style: TextStyle(color: Colors.white70), + ), ), TextButton( onPressed: () => Navigator.pop(context, true), @@ -223,13 +387,17 @@ class _WorkoutScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: - const Text('Cancel', style: TextStyle(color: Colors.white70)), + child: const Text( + 'Cancel', + style: TextStyle(color: Colors.white70), + ), ), TextButton( onPressed: () => Navigator.pop(context, true), - child: - const Text('Reset', style: TextStyle(color: Colors.redAccent)), + child: const Text( + 'Reset', + style: TextStyle(color: Colors.redAccent), + ), ), ], ), @@ -242,25 +410,28 @@ class _WorkoutScreenState extends State { Future _finishWorkout() async { _elapsedTimer.cancel(); + _breakTimer?.cancel(); setState(() => _finished = true); final endTime = DateTime.now(); final results = []; - for (int i = 0; i < widget.exercises.length; i++) { + for (var i = 0; i < widget.exercises.length; i++) { final ex = widget.exercises[i]; - results.add(ExerciseResult( - exercise: ex, - warmupDone: _warmupTapped[i], - sets: List.generate( - ex.sets, - (s) => SetResult( - targetReps: ex.reps, - doneReps: _tapped[i][s] ? _doneReps[i][s] : 0, - weight: ex.weight, + results.add( + ExerciseResult( + exercise: ex, + warmupDone: _warmupTapped[i], + sets: List.generate( + ex.sets, + (s) => SetResult( + targetReps: ex.reps, + doneReps: _tapped[i][s] ? _doneReps[i][s] : 0, + weight: ex.weight, + ), ), ), - )); + ); } final session = WorkoutSession( @@ -293,21 +464,25 @@ class _WorkoutScreenState extends State { final syncResult = await _sync.writeWorkoutResult(session); if (!mounted) return; - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => WorkoutSummaryDialog( - session: session, - syncResult: syncResult, + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => WorkoutSummaryDialog( + session: session, + syncResult: syncResult, + ), ), ); } - // ── Build ────────────────────────────────────────────────────────────────── + // ── Build ──────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { return PopScope( + // Explicit `canPop: true` makes it clear this scope never blocks the back + // button — a future reader must not assume the default silently. // ignore: avoid_redundant_argument_values canPop: true, child: Scaffold( @@ -322,7 +497,7 @@ class _WorkoutScreenState extends State { actions: [ if (!_finished) TextButton( - onPressed: () => _confirmReset(), + onPressed: _confirmReset, child: const Text( 'Reset', style: TextStyle(color: Colors.redAccent), @@ -334,35 +509,46 @@ class _WorkoutScreenState extends State { child: Text( 'Finish', style: TextStyle( - color: - _allSetsCompleted ? Colors.greenAccent : Colors.grey, + color: _allSetsCompleted ? Colors.greenAccent : Colors.grey, fontWeight: FontWeight.bold, ), ), ), ], ), - body: ListView.separated( - padding: const EdgeInsets.all(12), - itemCount: widget.exercises.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (_, i) { - final exName = widget.exercises[i].name; - final state = _exerciseStates[exName]; - return ExerciseTile( - exercise: widget.exercises[i], - tapped: _tapped[i], - doneReps: _doneReps[i], - warmupTapped: _warmupTapped[i], - successThreshold: state?.successThreshold ?? 3, - failThreshold: state?.failThreshold ?? 2, - onTapCircle: (s) => _tapCircle(i, s), - onLongPressCircle: (s) => _resetCircle(i, s), - onTapWarmup: () => _tapWarmup(i), - onThresholdChanged: (success, fail) => - _onThresholdChanged(exName, success, fail), - ); - }, + body: Column( + children: [ + if (_inBreak) + BreakBanner( + breakRemaining: _breakRemaining, + breakLabel: _breakLabel, + onSkip: _skipBreak, + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: widget.exercises.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (_, i) { + final exName = widget.exercises[i].name; + final state = _exerciseStates[exName]; + return ExerciseTile( + exercise: widget.exercises[i], + tapped: _tapped[i], + doneReps: _doneReps[i], + warmupTapped: _warmupTapped[i], + successThreshold: state?.successThreshold ?? 3, + failThreshold: state?.failThreshold ?? 2, + onTapCircle: (s) => _tapCircle(i, s), + onLongPressCircle: (s) => _resetCircle(i, s), + onTapWarmup: () => _tapWarmup(i), + onThresholdChanged: (success, fail) => + _onThresholdChanged(exName, success, fail), + ); + }, + ), + ), + ], ), ), ); diff --git a/stronglift_replacement/workout_app/lib/services/backup_service.dart b/stronglift_replacement/workout_app/lib/services/backup_service.dart new file mode 100644 index 0000000..3c52508 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/backup_service.dart @@ -0,0 +1,70 @@ +/// Backup service: mirrors the DB as JSON to external storage so data survives +/// uninstall/reinstall on Android 11+ where internal storage is wiped. +library; + +import 'dart:convert'; +import 'dart:io'; +import 'package:permission_handler/permission_handler.dart'; + +/// Path where the backup JSON lives on external storage. +const String kBackupPath = '/sdcard/WorkoutTracker/backup.json'; + +/// Handles exporting and importing the workout database as a JSON file on +/// external storage, which persists across app uninstalls. +class BackupService { + BackupService._(); + + /// The singleton instance. + static final BackupService instance = BackupService._(); + + // ── Permission ───────────────────────────────────────────────────────────── + + /// Returns true if the app has MANAGE_EXTERNAL_STORAGE. + Future hasStoragePermission() async { + return Permission.manageExternalStorage.isGranted; + } + + /// Requests MANAGE_EXTERNAL_STORAGE; opens the system settings page. + /// + /// Returns true once granted. + Future requestStoragePermission() async { + final status = + await Permission.manageExternalStorage.request(); + return status.isGranted; + } + + // ── Export ───────────────────────────────────────────────────────────────── + + /// Writes [data] to [kBackupPath] as pretty-printed JSON. + /// + /// Silently swallows errors (e.g. permission not granted, no external + /// storage) so the caller never crashes. + Future export(Map data) async { + try { + final dir = Directory('/sdcard/WorkoutTracker'); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + await File(kBackupPath).writeAsString( + const JsonEncoder.withIndent(' ').convert(data), + ); + } on Exception { + // Backup is best-effort; never throw. + } + } + + // ── Import ───────────────────────────────────────────────────────────────── + + /// Returns the parsed backup JSON, or null if the file does not exist or + /// is unreadable. + Future?> readBackup() async { + try { + final f = File(kBackupPath); + if (!f.existsSync()) return null; + final raw = await f.readAsString(); + return jsonDecode(raw) as Map; + } on Exception { + return null; + } + } +} diff --git a/stronglift_replacement/workout_app/lib/services/http_server_service.dart b/stronglift_replacement/workout_app/lib/services/http_server_service.dart index af26c28..b0e1ce5 100644 --- a/stronglift_replacement/workout_app/lib/services/http_server_service.dart +++ b/stronglift_replacement/workout_app/lib/services/http_server_service.dart @@ -16,8 +16,11 @@ import 'package:workout_app/services/sync_service.dart'; /// Port the HTTP server listens on. Must match the constant on the PC side. const int kWorkoutServerPort = 8765; +/// Singleton HTTP server that serves the latest workout JSON over LAN. class HttpServerService { HttpServerService._(); + + /// Singleton instance. static final HttpServerService instance = HttpServerService._(); HttpServer? _server; @@ -38,14 +41,22 @@ class HttpServerService { return addrs; } - void updateLatestWorkout(String json) => _latestJson = json; + /// The most recent workout JSON served at /workout, or null if none. + String? get latestWorkout => _latestJson; + /// Updates the JSON payload served at /workout. + set latestWorkout(String json) => _latestJson = json; + + /// Starts the HTTP server, loading the last saved workout from disk first. Future start() async { if (_server != null) return; // already running await _loadFromDisk(); try { - _server = await HttpServer.bind(InternetAddress.anyIPv4, kWorkoutServerPort); - _serve(); + _server = await HttpServer.bind( + InternetAddress.anyIPv4, + kWorkoutServerPort, + ); + unawaited(_serve()); } on SocketException { // Port already in use or binding failed — not fatal. _server = null; @@ -64,7 +75,7 @@ class HttpServerService { } for (final path in candidates) { final file = File(path); - if (await file.exists()) { + if (file.existsSync()) { try { _latestJson = await file.readAsString(); return; @@ -97,6 +108,7 @@ class HttpServerService { } } + /// Stops the HTTP server. Future stop() async { await _server?.close(force: true); _server = null; diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index f710660..7f263c4 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -1,14 +1,18 @@ /// Persistent storage for exercise progression state using SQLite. library; +import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/workout_plan.dart'; +import 'package:workout_app/services/backup_service.dart'; /// Per-exercise progression state stored in SQLite. class ExerciseState { + /// Creates an [ExerciseState] with all required progression fields. ExerciseState({ required this.name, required this.weight, @@ -20,23 +24,42 @@ class ExerciseState { required this.failThreshold, }); + /// Exercise name (matches [Exercise.name], used as primary key). final String name; + + /// Current working weight in kg. double weight; + + /// Current target reps per set. int reps; + + /// Consecutive successful workouts since last progression. int successStreak; + + /// Consecutive failed workouts since last regression. int failStreak; + + /// Weight cap; reps increase instead of weight when this is reached. final double maxWeight; + + /// Successes needed in a row before weight/reps increase. int successThreshold; + + /// Failures needed in a row before weight decreases. int failThreshold; } +/// Singleton SQLite service for workout data persistence. class StorageService { StorageService._(); static StorageService? _instance; + + /// Returns the initialized singleton; throws if [init] was not called first. static StorageService get instance => _instance!; late Database _db; + /// Initializes the singleton and opens the database (idempotent). static Future init() async { if (_instance != null) return _instance!; final svc = StorageService._(); @@ -45,8 +68,22 @@ class StorageService { return svc; } + // Overrides the DB path for unit tests (set by resetForTesting). + static String? _testDbPath; + + /// Resets the singleton so [init] can be called again in tests. + /// + /// Also switches to an in-memory database so each test starts with a clean + /// slate and file-based data from other tests does not leak in. + @visibleForTesting + static void resetForTesting() { + _instance = null; + _testDbPath = ':memory:'; + } + Future _open() async { - final dbPath = p.join(await getDatabasesPath(), 'workout_app.db'); + final dbPath = + _testDbPath ?? p.join(await getDatabasesPath(), 'workout_app.db'); _db = await openDatabase( dbPath, version: 3, @@ -100,15 +137,18 @@ class StorageService { ) async { if (oldVersion < 2) { await db.execute( - 'ALTER TABLE exercise_state ADD COLUMN success_threshold INTEGER NOT NULL DEFAULT 3', + 'ALTER TABLE exercise_state ' + 'ADD COLUMN success_threshold INTEGER NOT NULL DEFAULT 3', ); await db.execute( - 'ALTER TABLE exercise_state ADD COLUMN fail_threshold INTEGER NOT NULL DEFAULT 2', + 'ALTER TABLE exercise_state ' + 'ADD COLUMN fail_threshold INTEGER NOT NULL DEFAULT 2', ); } if (oldVersion < 3) { await db.execute( - 'CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)', + 'CREATE TABLE IF NOT EXISTS settings ' + '(key TEXT PRIMARY KEY, value TEXT NOT NULL)', ); await db.execute( 'CREATE TABLE IF NOT EXISTS active_session ' @@ -147,7 +187,7 @@ class StorageService { where: 'key = ?', whereArgs: [key], ); - return rows.isEmpty ? null : rows.first['value'] as String; + return rows.isEmpty ? null : rows.first['value']! as String; } Future _setSetting(String key, String value) async { @@ -158,17 +198,21 @@ class StorageService { ); } + /// Returns 'A' or 'B' — the type that should be done next. Future getNextWorkoutType() async { final last = await _getSetting('last_workout_type'); return last == 'A' ? 'B' : 'A'; } + /// Persists [type] as the most recently completed workout type. Future setLastWorkoutType(String type) async { await _setSetting('last_workout_type', type); + unawaited(_backupNow()); } // ── Active session (crash / exit recovery) ───────────────────────────────── + /// Persists [data] as the currently active (in-progress) session. Future saveActiveSession(Map data) async { await _db.insert( 'active_session', @@ -177,18 +221,21 @@ class StorageService { ); } + /// Returns the saved active session, or null if none exists. Future?> loadActiveSession() async { final rows = await _db.query('active_session', where: 'id = 1'); if (rows.isEmpty) return null; - return jsonDecode(rows.first['json'] as String) as Map; + return jsonDecode(rows.first['json']! as String) as Map; } + /// Removes the active session record (called after a session is committed). Future clearActiveSession() async { await _db.delete('active_session', where: 'id = 1'); } // ── Exercise state ───────────────────────────────────────────────────────── + /// Returns the progression state for [name], or null if not found. Future getExerciseState(String name) async { final rows = await _db.query( 'exercise_state', @@ -198,17 +245,18 @@ class StorageService { if (rows.isEmpty) return null; final r = rows.first; return ExerciseState( - name: r['name'] as String, - weight: r['weight'] as double, - reps: r['reps'] as int, - successStreak: r['success_streak'] as int, - failStreak: r['fail_streak'] as int, - maxWeight: r['max_weight'] as double, + name: r['name']! as String, + weight: r['weight']! as double, + reps: r['reps']! as int, + successStreak: r['success_streak']! as int, + failStreak: r['fail_streak']! as int, + maxWeight: r['max_weight']! as double, successThreshold: r['success_threshold'] as int? ?? 3, failThreshold: r['fail_threshold'] as int? ?? 2, ); } + /// Returns progression states for every exercise across both plans. Future> getAllExerciseStates() async { final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet(); final states = []; @@ -219,6 +267,7 @@ class StorageService { return states; } + /// Updates the streak thresholds for exercise [name]. Future setExerciseThresholds( String name, { required int successThreshold, @@ -235,6 +284,7 @@ class StorageService { ); } + /// Sets the working weight for [name], resetting streaks. Future setExerciseWeight(String name, double weight) async { await _db.update( 'exercise_state', @@ -242,8 +292,10 @@ class StorageService { where: 'name = ?', whereArgs: [name], ); + unawaited(_backupNow()); } + /// Returns exercises for [workoutType] with weights/reps from stored state. Future> getCurrentExercises(String workoutType) async { final template = workoutType == 'A' ? workoutA : workoutB; final result = []; @@ -258,6 +310,7 @@ class StorageService { return result; } + /// Applies progressive overload or regression based on [succeededExercises]. Future applyProgression({ required Map succeededExercises, required DateTime lastWorkoutDate, @@ -270,8 +323,10 @@ class StorageService { if (state == null) continue; if (hadBreak) { - final newWeight = - (state.weight - kWeightIncrement).clamp(0.0, state.maxWeight); + final newWeight = (state.weight - kWeightIncrement).clamp( + 0.0, + state.maxWeight, + ); await _db.update( 'exercise_state', {'weight': newWeight, 'success_streak': 0, 'fail_streak': 0}, @@ -284,15 +339,17 @@ class StorageService { if (entry.value) { final newStreak = state.successStreak + 1; final shouldProgress = newStreak >= state.successThreshold; - double newWeight = state.weight; - int newReps = state.reps; + var newWeight = state.weight; + var newReps = state.reps; if (shouldProgress) { if (state.weight >= state.maxWeight) { newReps = state.reps + 1; } else { - newWeight = - (state.weight + kWeightIncrement).clamp(0.0, state.maxWeight); + newWeight = (state.weight + kWeightIncrement).clamp( + 0.0, + state.maxWeight, + ); } } @@ -328,6 +385,7 @@ class StorageService { } } + /// Persists a completed session to the workout history table. Future saveSession({ required String date, required String workoutType, @@ -342,16 +400,19 @@ class StorageService { 'succeeded': succeeded ? 1 : 0, 'json': json, }); + unawaited(_backupNow()); } + /// Returns the date of the most recent completed session, or null. Future getLastWorkoutDate() async { final rows = await _db.rawQuery( 'SELECT date FROM workout_history ORDER BY date DESC LIMIT 1', ); if (rows.isEmpty) return null; - return DateTime.tryParse(rows.first['date'] as String); + return DateTime.tryParse(rows.first['date']! as String); } + /// Returns up to [limit] rows from workout history, newest first. Future>> getWorkoutHistory({ int limit = 60, }) async { @@ -362,13 +423,15 @@ class StorageService { ); } + /// Returns all distinct workout dates (YYYY-MM-DD), newest first. Future> getAllWorkoutDates() async { final rows = await _db.rawQuery( 'SELECT DISTINCT date FROM workout_history ORDER BY date DESC', ); - return rows.map((r) => r['date'] as String).toList(); + return rows.map((r) => r['date']! as String).toList(); } + /// Resets [name] to its default weight and thresholds, clearing streaks. Future resetExerciseToDefaults(String name) async { final defaults = [...workoutA, ...workoutB].firstWhere( (e) => e.name == name, @@ -386,5 +449,62 @@ class StorageService { where: 'name = ?', whereArgs: [name], ); + unawaited(_backupNow()); + } + + // ── Backup / restore ─────────────────────────────────────────────────────── + + /// Exports all persistent data to external storage as a JSON snapshot. + Future _backupNow() async { + final exerciseRows = await _db.query('exercise_state'); + final historyRows = await _db.query('workout_history'); + final settingsRows = await _db.query('settings'); + await BackupService.instance.export({ + 'exercise_state': exerciseRows, + 'workout_history': historyRows, + 'settings': settingsRows, + }); + } + + /// Restores from backup if the local DB is empty (fresh install). + /// + /// "Empty" means no workout history and no [last_workout_type] setting. + Future restoreFromBackupIfNeeded() async { + final hasHistory = + (await _db.rawQuery('SELECT COUNT(*) AS c FROM workout_history')) + .first['c'] as int? ?? + 0; + final hasType = await _getSetting('last_workout_type'); + if (hasHistory > 0 || hasType != null) return; // DB has real data + + final backup = await BackupService.instance.readBackup(); + if (backup == null) return; + + await _db.transaction((txn) async { + for (final row in (backup['exercise_state'] as List? ?? []) + .cast>()) { + await txn.insert( + 'exercise_state', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + for (final row in (backup['workout_history'] as List? ?? []) + .cast>()) { + await txn.insert( + 'workout_history', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + for (final row in (backup['settings'] as List? ?? []) + .cast>()) { + await txn.insert( + 'settings', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + }); } } diff --git a/stronglift_replacement/workout_app/lib/services/sync_service.dart b/stronglift_replacement/workout_app/lib/services/sync_service.dart index 1196c43..6bfd08b 100644 --- a/stronglift_replacement/workout_app/lib/services/sync_service.dart +++ b/stronglift_replacement/workout_app/lib/services/sync_service.dart @@ -1,4 +1,4 @@ -/// Writes workout result JSON to external storage (ADB) and the in-app HTTP server. +/// Writes workout result JSON to external storage (ADB) and the HTTP server. library; import 'dart:io'; @@ -6,9 +6,10 @@ import 'package:path_provider/path_provider.dart'; import 'package:workout_app/models/workout_session.dart'; import 'package:workout_app/services/http_server_service.dart'; -/// Path on the phone's external storage where the PC reads workout data via ADB. +/// Path on the phone's external storage where the PC reads workout data. const String kSyncFilePath = '/sdcard/workout_result.json'; +/// Handles writing completed workout sessions to disk and the HTTP server. class SyncService { /// Writes [session] as JSON to external storage and updates the HTTP server. /// @@ -17,13 +18,13 @@ class SyncService { final json = session.toJsonString(); // Always update the in-app HTTP server so the PC can read via WiFi. - HttpServerService.instance.updateLatestWorkout(json); + HttpServerService.instance.latestWorkout = json; // Try the primary path first (/sdcard/ — ADB-accessible without root). try { final file = File(kSyncFilePath); await file.writeAsString(json); - return SyncResult(success: true, path: kSyncFilePath); + return const SyncResult(success: true, path: kSyncFilePath); } on Exception { // Fallback: app-specific external directory (still ADB accessible). } @@ -39,14 +40,25 @@ class SyncService { // Fallback failed. } - return SyncResult(success: false, path: null, error: 'No writable external path'); + return const SyncResult( + success: false, + path: null, + error: 'No writable external path', + ); } } +/// Result of a [SyncService.writeWorkoutResult] call. class SyncResult { + /// Creates a sync result. const SyncResult({required this.success, required this.path, this.error}); + /// Whether the write succeeded. final bool success; + + /// Absolute path where the file was written, or null on failure. final String? path; + + /// Human-readable error message on failure. final String? error; } diff --git a/stronglift_replacement/workout_app/lib/widgets/break_banner.dart b/stronglift_replacement/workout_app/lib/widgets/break_banner.dart index 7185d8f..6da7088 100644 --- a/stronglift_replacement/workout_app/lib/widgets/break_banner.dart +++ b/stronglift_replacement/workout_app/lib/widgets/break_banner.dart @@ -3,16 +3,23 @@ library; import 'package:flutter/material.dart'; +/// Banner widget showing a break countdown and a skip button. class BreakBanner extends StatelessWidget { + /// Creates a [BreakBanner]. const BreakBanner({ - super.key, required this.breakRemaining, required this.breakLabel, required this.onSkip, + super.key, }); + /// Seconds remaining in the current break. final int breakRemaining; + + /// Display label for the break (e.g. 'Rest' or 'Warmup rest'). final String breakLabel; + + /// Called when the user taps the Skip button. final VoidCallback onSkip; String _fmt(int secs) { diff --git a/stronglift_replacement/workout_app/lib/widgets/calendar_widget.dart b/stronglift_replacement/workout_app/lib/widgets/calendar_widget.dart index ecdaf7d..ea2bb11 100644 --- a/stronglift_replacement/workout_app/lib/widgets/calendar_widget.dart +++ b/stronglift_replacement/workout_app/lib/widgets/calendar_widget.dart @@ -3,27 +3,44 @@ library; import 'package:flutter/material.dart'; +/// Monthly calendar widget that highlights days with completed workouts. class WorkoutCalendar extends StatelessWidget { + /// Creates a [WorkoutCalendar]. const WorkoutCalendar({ - super.key, required this.workoutDates, required this.month, required this.onPrevMonth, required this.onNextMonth, + super.key, }); + /// Set of YYYY-MM-DD date strings that had at least one workout. final Set workoutDates; /// Only the year and month of this DateTime are used. final DateTime month; + + /// Called when the user taps the previous-month chevron. final VoidCallback onPrevMonth; + + /// Called when the user taps the next-month chevron. final VoidCallback onNextMonth; static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; static const _monthNames = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December', + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', ]; String _dateKey(int year, int m, int day) => @@ -35,7 +52,7 @@ class WorkoutCalendar extends StatelessWidget { final m = month.month; final daysInMonth = DateTime(year, m + 1, 0).day; // weekday: 1=Mon..7=Sun → offset 0..6 - final firstWeekday = DateTime(year, m, 1).weekday - 1; + final firstWeekday = DateTime(year, m).weekday - 1; final totalCells = firstWeekday + daysInMonth; final rows = (totalCells / 7).ceil(); @@ -123,8 +140,7 @@ class WorkoutCalendar extends StatelessWidget { style: TextStyle( color: worked ? Colors.white : Colors.white38, fontSize: 12, - fontWeight: - worked ? FontWeight.bold : FontWeight.normal, + fontWeight: worked ? FontWeight.bold : FontWeight.normal, ), ), ); diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart index c9e8ac5..ba65cb5 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart @@ -5,9 +5,10 @@ import 'package:flutter/material.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/widgets/rep_circle.dart'; +/// Card widget displaying warmup and working-set rep circles for one exercise. class ExerciseTile extends StatelessWidget { + /// Creates an [ExerciseTile]. const ExerciseTile({ - super.key, required this.exercise, required this.tapped, required this.doneReps, @@ -18,19 +19,37 @@ class ExerciseTile extends StatelessWidget { required this.onLongPressCircle, required this.onTapWarmup, required this.onThresholdChanged, + super.key, }); + /// The exercise definition to display. final Exercise exercise; + + /// Per-set tap state; true when a set circle has been tapped. final List tapped; + + /// Per-set rep count; may be less than target after repeated taps. final List doneReps; + + /// Whether the warmup circle has been tapped. final bool warmupTapped; + + /// Success streak threshold shown in the inline settings row. final int successThreshold; + + /// Fail streak threshold shown in the inline settings row. final int failThreshold; + + /// Called when a working-set circle is tapped. final void Function(int setIdx) onTapCircle; + + /// Called when a working-set circle is long-pressed (resets to neutral). final void Function(int setIdx) onLongPressCircle; + + /// Called when the warmup circle is tapped. final VoidCallback onTapWarmup; - /// Called when user changes thresholds inline; args are (newSuccess, newFail). + /// Called when the user changes thresholds inline (newSuccess, newFail). final void Function(int success, int fail) onThresholdChanged; bool get _allCompleted => tapped.every((t) => t); @@ -40,10 +59,9 @@ class ExerciseTile extends StatelessWidget { @override Widget build(BuildContext context) { - Color headerColor = Colors.grey.shade800; + var headerColor = Colors.grey.shade800; if (_allCompleted) { - headerColor = - _allSucceeded ? Colors.green.shade800 : Colors.red.shade900; + headerColor = _allSucceeded ? Colors.green.shade800 : Colors.red.shade900; } return Card( @@ -72,12 +90,14 @@ class ExerciseTile extends StatelessWidget { ], ), const SizedBox(height: 8), - _WarmupRow( - warmupWeight: exercise.warmupWeight, - tapped: warmupTapped, - onTap: onTapWarmup, - ), - const SizedBox(height: 10), + if (exercise.hasWarmup) ...[ + _WarmupRow( + warmupWeight: exercise.warmupWeight, + tapped: warmupTapped, + onTap: onTapWarmup, + ), + const SizedBox(height: 10), + ], Wrap( spacing: 8, runSpacing: 8, @@ -190,22 +210,22 @@ class _MiniStepper extends StatelessWidget { } Widget _btn(IconData icon, VoidCallback? onTap) => GestureDetector( - onTap: onTap, - child: Container( - width: 22, - height: 22, - decoration: BoxDecoration( - color: Colors.grey.shade700, - borderRadius: BorderRadius.circular(4), - ), - alignment: Alignment.center, - child: Icon( - icon, - size: 12, - color: onTap != null ? Colors.white : Colors.white24, - ), - ), - ); + onTap: onTap, + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: Colors.grey.shade700, + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: Icon( + icon, + size: 12, + color: onTap != null ? Colors.white : Colors.white24, + ), + ), + ); } class _WarmupRow extends StatelessWidget { diff --git a/stronglift_replacement/workout_app/lib/widgets/rep_circle.dart b/stronglift_replacement/workout_app/lib/widgets/rep_circle.dart index a6c5536..f80d2b5 100644 --- a/stronglift_replacement/workout_app/lib/widgets/rep_circle.dart +++ b/stronglift_replacement/workout_app/lib/widgets/rep_circle.dart @@ -7,34 +7,53 @@ /// failed – red, shows 0 (all reps deducted) /// /// Interaction: -/// single tap → neutral→success, success→partial(-1 rep), partial→partial(-1 rep), -/// failed stays failed +/// single tap → neutral→success, success→partial(-1 rep), +/// partial→partial(-1 rep), failed stays failed /// long press → reset to neutral library; import 'package:flutter/material.dart'; -enum RepCircleState { neutral, success, partial, failed } +/// Visual state of a [RepCircle]. +enum RepCircleState { + /// Not yet tapped; shows target reps. + neutral, + /// All reps completed; green. + success, + + /// Some reps completed; orange, shows actual count. + partial, + + /// All reps deducted; red. + failed, +} + +/// Tappable circle representing one working set of an exercise. class RepCircle extends StatelessWidget { + /// Creates a [RepCircle]. const RepCircle({ - super.key, required this.targetReps, required this.doneReps, required this.tapped, required this.onTap, required this.onLongPress, + super.key, }); + /// Number of reps the user is aiming for this set. final int targetReps; - /// Reps currently registered (may be < targetReps if user tapped multiple times). + /// Reps currently registered (may be < targetReps after repeated taps). final int doneReps; - /// Whether this circle has been tapped at all (distinguishes neutral from success). + /// Whether this circle has been tapped at all (neutral vs success). final bool tapped; + /// Called on a single tap. final VoidCallback onTap; + + /// Called on a long press (resets to neutral). final VoidCallback onLongPress; RepCircleState get _state { diff --git a/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart b/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart index e393361..0eb777d 100644 --- a/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart +++ b/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart @@ -5,14 +5,19 @@ import 'package:flutter/material.dart'; import 'package:workout_app/models/workout_session.dart'; import 'package:workout_app/services/sync_service.dart'; +/// Dialog that summarises a completed workout and reports the sync status. class WorkoutSummaryDialog extends StatelessWidget { + /// Creates a [WorkoutSummaryDialog]. const WorkoutSummaryDialog({ - super.key, required this.session, required this.syncResult, + super.key, }); + /// The completed workout session to summarise. final WorkoutSession session; + + /// Result of writing the session to disk/HTTP server. final SyncResult syncResult; String _fmt(Duration d) { diff --git a/stronglift_replacement/workout_app/pubspec.lock b/stronglift_replacement/workout_app/pubspec.lock index 58561ad..3bf2f5c 100644 --- a/stronglift_replacement/workout_app/pubspec.lock +++ b/stronglift_replacement/workout_app/pubspec.lock @@ -200,6 +200,14 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" hooks: dependency: transitive description: @@ -304,6 +312,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96 + url: "https://pub.dev" + source: hosted + version: "0.19.1" objective_c: dependency: transitive description: @@ -545,10 +561,18 @@ packages: dependency: transitive description: name: sqflite_common - sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" + sha256: cce558075afe2a83f3fd7fc123acd6b090683e4f23910d44fbb31ecd7800b014 url: "https://pub.dev" source: hosted - version: "2.5.8" + version: "2.5.9" + sqflite_common_ffi: + dependency: "direct dev" + description: + name: sqflite_common_ffi + sha256: "3ddad0ec96ad411d5fea45b4912c3cd5743436c9e1890c26a6e688a32d901cae" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sqflite_darwin: dependency: transitive description: @@ -565,6 +589,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189" + url: "https://pub.dev" + source: hosted + version: "3.3.2" stack_trace: dependency: transitive description: @@ -593,10 +625,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" url: "https://pub.dev" source: hosted - version: "3.4.0+1" + version: "3.4.1" term_glyph: dependency: transitive description: @@ -637,6 +669,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: d1cb1d66a5aae2c702d68caca6c8347306d35e728fd94555fa21fa0448a972e0 + url: "https://pub.dev" + source: hosted + version: "10.2.0" vibration: dependency: "direct main" description: diff --git a/stronglift_replacement/workout_app/pubspec.yaml b/stronglift_replacement/workout_app/pubspec.yaml index 07dcc94..d9bb7e0 100644 --- a/stronglift_replacement/workout_app/pubspec.yaml +++ b/stronglift_replacement/workout_app/pubspec.yaml @@ -8,20 +8,22 @@ environment: sdk: ^3.12.0 dependencies: + audioplayers: ^6.4.0 flutter: sdk: flutter path: ^1.9.1 - sqflite: ^2.4.2 path_provider: ^2.1.5 - shared_preferences: ^2.5.3 - audioplayers: ^6.4.0 - vibration: ^3.1.0 permission_handler: ^12.0.0 + shared_preferences: ^2.5.3 + sqflite: ^2.4.2 + vibration: ^3.1.0 dev_dependencies: + flutter_lints: ^6.0.0 flutter_test: sdk: flutter - flutter_lints: ^6.0.0 + sqflite_common_ffi: ^2.4.1 + very_good_analysis: ^10.2.0 flutter: uses-material-design: true diff --git a/stronglift_replacement/workout_app/test/models/exercise_result_test.dart b/stronglift_replacement/workout_app/test/models/exercise_result_test.dart new file mode 100644 index 0000000..11d0270 --- /dev/null +++ b/stronglift_replacement/workout_app/test/models/exercise_result_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/models/exercise_result.dart'; +import 'package:workout_app/models/set_result.dart'; + +void main() { + const exercise = Exercise(name: 'Press', sets: 3, reps: 5, weight: 15.0); + + group('ExerciseResult.succeeded', () { + test('true when all sets succeeded', () { + const r = ExerciseResult( + exercise: exercise, + sets: [ + SetResult(targetReps: 5, doneReps: 5, weight: 15), + SetResult(targetReps: 5, doneReps: 5, weight: 15), + ], + ); + expect(r.succeeded, isTrue); + }); + + test('false when empty sets', () { + const r = ExerciseResult(exercise: exercise, sets: []); + expect(r.succeeded, isFalse); + }); + + test('false when any set failed', () { + const r = ExerciseResult( + exercise: exercise, + sets: [ + SetResult(targetReps: 5, doneReps: 5, weight: 15), + SetResult(targetReps: 5, doneReps: 3, weight: 15), + ], + ); + expect(r.succeeded, isFalse); + }); + }); + + group('ExerciseResult.toJson', () { + test('serializes all fields', () { + const r = ExerciseResult( + exercise: exercise, + warmupDone: true, + sets: [SetResult(targetReps: 5, doneReps: 5, weight: 15)], + ); + final json = r.toJson(); + expect(json['name'], 'Press'); + expect(json['targetSets'], 3); + expect(json['targetReps'], 5); + expect(json['targetWeight'], 15.0); + expect(json['warmupDone'], isTrue); + expect((json['sets'] as List).length, 1); + expect(json['succeeded'], isTrue); + }); + + test('warmupDone defaults to false', () { + const r = ExerciseResult(exercise: exercise, sets: []); + expect(r.toJson()['warmupDone'], isFalse); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/models/exercise_test.dart b/stronglift_replacement/workout_app/test/models/exercise_test.dart new file mode 100644 index 0000000..90eda9a --- /dev/null +++ b/stronglift_replacement/workout_app/test/models/exercise_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/exercise.dart'; + +void main() { + group('Exercise', () { + const e = Exercise(name: 'Squat', sets: 5, reps: 5, weight: 20.0); + + test('constructor stores fields', () { + expect(e.name, 'Squat'); + expect(e.sets, 5); + expect(e.reps, 5); + expect(e.weight, 20.0); + expect(e.maxWeight, kDefaultMaxWeight); + }); + + test('custom maxWeight', () { + const e2 = Exercise( + name: 'Situp', + sets: 3, + reps: 30, + weight: 10, + maxWeight: 10, + ); + expect(e2.maxWeight, 10); + }); + + test('warmupWeight rounds down to nearest 2.5', () { + // 20 * 4/5 = 16 → floor to 15 + expect(e.warmupWeight, 15.0); + }); + + test('warmupWeight of 27.5 → 20.0', () { + const e2 = Exercise(name: 'A', sets: 1, reps: 1, weight: 27.5); + // 27.5 * 0.8 = 22.0, then floor(22.0 / 2.5) * 2.5 = 8 * 2.5 = 20.0 + expect(e2.warmupWeight, 20.0); + }); + + test('copyWith replaces specified fields', () { + final copy = e.copyWith(weight: 25.0, reps: 6); + expect(copy.weight, 25.0); + expect(copy.reps, 6); + expect(copy.name, e.name); + expect(copy.sets, e.sets); + expect(copy.maxWeight, e.maxWeight); + }); + + test('copyWith with no args returns identical values', () { + final copy = e.copyWith(); + expect(copy.name, e.name); + expect(copy.weight, e.weight); + }); + + test('toJson round-trips via fromJson', () { + final json = e.toJson(); + final restored = Exercise.fromJson(json); + expect(restored.name, e.name); + expect(restored.sets, e.sets); + expect(restored.reps, e.reps); + expect(restored.weight, e.weight); + expect(restored.maxWeight, e.maxWeight); + }); + + test('fromJson uses default maxWeight when absent', () { + final json = {'name': 'Test', 'sets': 3, 'reps': 10, 'weight': 5.0}; + final ex = Exercise.fromJson(json); + expect(ex.maxWeight, kDefaultMaxWeight); + }); + + test('kDefaultMaxWeight and kWeightIncrement have expected values', () { + expect(kDefaultMaxWeight, 27.5); + expect(kWeightIncrement, 2.5); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/models/set_result_test.dart b/stronglift_replacement/workout_app/test/models/set_result_test.dart new file mode 100644 index 0000000..fe14d48 --- /dev/null +++ b/stronglift_replacement/workout_app/test/models/set_result_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/set_result.dart'; + +void main() { + group('SetResult', () { + const full = SetResult(targetReps: 5, doneReps: 5, weight: 20.0); + const partial = SetResult(targetReps: 5, doneReps: 3, weight: 20.0); + + test('succeeded is true when doneReps >= targetReps', () { + expect(full.succeeded, isTrue); + }); + + test('succeeded is false when doneReps < targetReps', () { + expect(partial.succeeded, isFalse); + }); + + test('copyWith replaces doneReps', () { + final copy = full.copyWith(doneReps: 2); + expect(copy.doneReps, 2); + expect(copy.targetReps, full.targetReps); + expect(copy.weight, full.weight); + }); + + test('copyWith with null keeps original doneReps', () { + final copy = full.copyWith(); + expect(copy.doneReps, full.doneReps); + }); + + test('toJson round-trips via fromJson', () { + final json = full.toJson(); + expect(json['succeeded'], isTrue); + final restored = SetResult.fromJson(json); + expect(restored.targetReps, full.targetReps); + expect(restored.doneReps, full.doneReps); + expect(restored.weight, full.weight); + }); + + test('toJson includes succeeded field', () { + final json = partial.toJson(); + expect(json['succeeded'], isFalse); + }); + + test('fromJson with num weight converts to double', () { + final json = {'targetReps': 5, 'doneReps': 5, 'weight': 20}; + final s = SetResult.fromJson(json); + expect(s.weight, 20.0); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/models/workout_plan_test.dart b/stronglift_replacement/workout_app/test/models/workout_plan_test.dart new file mode 100644 index 0000000..872948a --- /dev/null +++ b/stronglift_replacement/workout_app/test/models/workout_plan_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/workout_plan.dart'; + +void main() { + group('Workout plans', () { + test('workoutA has 4 exercises', () { + expect(workoutA.length, 4); + }); + + test('workoutB has 4 exercises', () { + expect(workoutB.length, 4); + }); + + test('kSitupMaxWeight is 10', () { + expect(kSitupMaxWeight, 10); + }); + + test('workoutA exercises have positive sets/reps/weight', () { + for (final ex in workoutA) { + expect(ex.sets, greaterThan(0)); + expect(ex.reps, greaterThan(0)); + expect(ex.weight, greaterThan(0)); + } + }); + + test('workoutB Situp uses kSitupMaxWeight', () { + final situp = workoutB.where((e) => e.name == 'Situp').first; + expect(situp.maxWeight, kSitupMaxWeight); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/models/workout_session_test.dart b/stronglift_replacement/workout_app/test/models/workout_session_test.dart new file mode 100644 index 0000000..6e9b469 --- /dev/null +++ b/stronglift_replacement/workout_app/test/models/workout_session_test.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +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'; + +void main() { + final start = DateTime(2024, 6, 1, 9, 0, 0); + final end = DateTime(2024, 6, 1, 9, 45, 30); + const exercise = Exercise(name: 'Press', sets: 3, reps: 5, weight: 15.0); + + final successResult = ExerciseResult( + exercise: exercise, + sets: List.generate( + 3, + (_) => const SetResult(targetReps: 5, doneReps: 5, weight: 15), + ), + ); + final failResult = ExerciseResult( + exercise: exercise, + sets: List.generate( + 3, + (_) => const SetResult(targetReps: 5, doneReps: 3, weight: 15), + ), + ); + + group('WorkoutSession', () { + test('duration computes correctly', () { + final s = WorkoutSession( + workoutType: 'A', + startTime: start, + endTime: end, + exercises: [], + ); + expect(s.duration, const Duration(minutes: 45, seconds: 30)); + }); + + test('fullySucceeded true when all exercises succeeded', () { + final s = WorkoutSession( + workoutType: 'A', + startTime: start, + endTime: end, + exercises: [successResult], + ); + expect(s.fullySucceeded, isTrue); + }); + + test('fullySucceeded false when any exercise failed', () { + final s = WorkoutSession( + workoutType: 'B', + startTime: start, + endTime: end, + exercises: [successResult, failResult], + ); + expect(s.fullySucceeded, isFalse); + }); + + test('toJson contains expected keys', () { + final s = WorkoutSession( + workoutType: 'A', + startTime: start, + endTime: end, + exercises: [successResult], + ); + final json = s.toJson(); + expect(json['workout_type'], 'A'); + expect(json['date'], '2024-06-01'); + expect(json['duration_seconds'], 45 * 60 + 30); + expect(json['succeeded'], isTrue); + expect((json['exercises'] as List).length, 1); + }); + + test('toJsonString is valid pretty-printed JSON', () { + final s = WorkoutSession( + workoutType: 'B', + startTime: start, + endTime: end, + exercises: [], + ); + final str = s.toJsonString(); + expect(str, contains('\n')); + final decoded = jsonDecode(str) as Map; + expect(decoded['workout_type'], 'B'); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/history_screen_test.dart b/stronglift_replacement/workout_app/test/screens/history_screen_test.dart new file mode 100644 index 0000000..089fe0a --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/history_screen_test.dart @@ -0,0 +1,176 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/screens/history_screen.dart'; +import 'package:workout_app/services/storage_service.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + }); + + Future _pump(WidgetTester tester, Widget w) async { + await tester.runAsync(() async { + await tester.pumpWidget(w); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + } + + Widget _wrap() => const MaterialApp(home: HistoryScreen()); + + testWidgets('shows Progress app bar', (tester) async { + await _pump(tester, _wrap()); + expect(find.text('Progress'), findsOneWidget); + }); + + testWidgets('shows "No workouts yet." when history is empty', (tester) async { + await _pump(tester, _wrap()); + expect(find.text('No workouts yet.'), findsOneWidget); + }); + + testWidgets('shows session rows when history has data', (tester) async { + final json = jsonEncode({ + 'exercises': [ + { + 'name': 'Squat', + 'targetSets': 3, + 'targetReps': 5, + 'targetWeight': 20.0, + 'warmupDone': false, + 'succeeded': true, + 'sets': [ + {'targetReps': 5, 'doneReps': 5, 'weight': 20.0, 'succeeded': true}, + ], + }, + ], + }); + await StorageService.instance.saveSession( + date: '2024-06-01', + workoutType: 'A', + durationSeconds: 1800, + succeeded: true, + json: json, + ); + await _pump(tester, _wrap()); + expect(find.textContaining('Workout A'), findsWidgets); + }); + + testWidgets('exercise picker shows "Total (all workouts)" initially', + (tester) async { + final json = jsonEncode({ + 'exercises': [ + { + 'name': 'Squat', + 'targetSets': 3, + 'targetReps': 5, + 'targetWeight': 20.0, + 'warmupDone': false, + 'succeeded': true, + 'sets': [], + }, + ], + }); + await StorageService.instance.saveSession( + date: '2024-06-01', + workoutType: 'A', + durationSeconds: 1800, + succeeded: true, + json: json, + ); + await _pump(tester, _wrap()); + expect(find.textContaining('Total'), findsOneWidget); + }); + + testWidgets('calendar prev/next month navigation works', (tester) async { + final json = jsonEncode({'exercises': []}); + await StorageService.instance.saveSession( + date: '2024-06-01', + workoutType: 'A', + durationSeconds: 1800, + succeeded: true, + json: json, + ); + await _pump(tester, _wrap()); + await tester.tap(find.byIcon(Icons.chevron_right).first); + await tester.pump(); + await tester.tap(find.byIcon(Icons.chevron_left).first); + await tester.pump(); + expect(find.byType(HistoryScreen), findsOneWidget); + }); + + testWidgets('session tile shows succeeded checkmark', (tester) async { + final json = jsonEncode({'exercises': []}); + await StorageService.instance.saveSession( + date: '2024-06-01', + workoutType: 'A', + durationSeconds: 1800, + succeeded: true, + json: json, + ); + await _pump(tester, _wrap()); + expect(find.byIcon(Icons.check_circle), findsWidgets); + }); + + testWidgets('session tile shows cancel icon on failure', (tester) async { + final json = jsonEncode({'exercises': []}); + await StorageService.instance.saveSession( + date: '2024-06-01', + workoutType: 'A', + durationSeconds: 900, + succeeded: false, + json: json, + ); + await _pump(tester, _wrap()); + expect(find.byIcon(Icons.cancel), findsWidgets); + }); + + testWidgets('session duration over 1 hour formats with h prefix', + (tester) async { + final json = jsonEncode({'exercises': []}); + await StorageService.instance.saveSession( + date: '2024-06-01', + workoutType: 'A', + durationSeconds: 3700, + succeeded: true, + json: json, + ); + await _pump(tester, _wrap()); + expect(find.textContaining('1h'), findsOneWidget); + }); + + testWidgets('chart renders with enough data points', (tester) async { + final json = jsonEncode({ + 'exercises': [ + { + 'name': 'Squat', + 'targetSets': 3, + 'targetReps': 5, + 'targetWeight': 20.0, + 'warmupDone': false, + 'succeeded': true, + 'sets': [], + }, + ], + }); + for (var i = 1; i <= 3; i++) { + await StorageService.instance.saveSession( + date: '2024-06-0$i', + workoutType: 'A', + durationSeconds: 1800, + succeeded: true, + json: json, + ); + } + await _pump(tester, _wrap()); + expect(find.byType(HistoryScreen), findsOneWidget); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/home_screen_test.dart b/stronglift_replacement/workout_app/test/screens/home_screen_test.dart new file mode 100644 index 0000000..02eaddf --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/home_screen_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/screens/home_screen.dart'; +import 'package:workout_app/services/storage_service.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + }); + + // runAsync steps outside FakeAsync so real I/O (NetworkInterface.list) completes. + Future _pump(WidgetTester tester, Widget w) async { + await tester.runAsync(() async { + await tester.pumpWidget(w); + // Small real delay lets sqflite + NetworkInterface.list complete. + await Future.delayed(const Duration(milliseconds: 200)); + }); + await tester.pump(); + } + + Widget _wrap() => const MaterialApp(home: HomeScreen()); + + testWidgets('shows Workout Tracker app bar', (tester) async { + await _pump(tester, _wrap()); + expect(find.text('Workout Tracker'), findsOneWidget); + }); + + testWidgets('shows Next: Workout A when no workout done', (tester) async { + await _pump(tester, _wrap()); + expect(find.textContaining('Next: Workout A'), findsOneWidget); + }); + + testWidgets('shows Start Workout A button', (tester) async { + await _pump(tester, _wrap()); + expect(find.text('Start Workout A'), findsOneWidget); + }); + + testWidgets('history icon navigates to history screen', (tester) async { + await _pump(tester, _wrap()); + await tester.tap(find.byIcon(Icons.history)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Progress'), findsOneWidget); + }); + + testWidgets('settings icon navigates to settings screen', (tester) async { + await _pump(tester, _wrap()); + await tester.tap(find.byIcon(Icons.settings)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Settings'), findsOneWidget); + }); + + testWidgets('"Done for today" message shows after saving a session today', + (tester) async { + final today = DateTime.now(); + final dateStr = + '${today.year}-${today.month.toString().padLeft(2, '0')}' + '-${today.day.toString().padLeft(2, '0')}'; + await StorageService.instance.saveSession( + date: dateStr, + workoutType: 'A', + durationSeconds: 1800, + succeeded: true, + json: '{"exercises":[]}', + ); + await _pump(tester, _wrap()); + expect(find.text('Done for today!'), findsOneWidget); + }); + + testWidgets('HTTP sync tile renders', (tester) async { + await _pump(tester, _wrap()); + expect(find.text('HTTP sync (no ADB needed)'), findsOneWidget); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart new file mode 100644 index 0000000..8cc9c4e --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.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/screens/settings_screen.dart'; +import 'package:workout_app/services/storage_service.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + }); + + Future _pump(WidgetTester tester, Widget w) async { + await tester.runAsync(() async { + await tester.pumpWidget(w); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + } + + Widget _wrap() => const MaterialApp(home: SettingsScreen()); + + testWidgets('shows Settings app bar', (tester) async { + await _pump(tester, _wrap()); + expect(find.text('Settings'), findsOneWidget); + }); + + testWidgets('shows WEIGHTS and PROGRESSION THRESHOLDS sections', + (tester) async { + await _pump(tester, _wrap()); + expect(find.text('WEIGHTS'), findsOneWidget); + expect(find.text('PROGRESSION THRESHOLDS'), findsOneWidget); + }); + + testWidgets('shows all exercise names from both workout plans', (tester) async { + await _pump(tester, _wrap()); + for (final ex in [...workoutA, ...workoutB]) { + expect(find.text(ex.name), findsWidgets); + } + }); + + testWidgets('Reset defaults button is present', (tester) async { + await _pump(tester, _wrap()); + expect(find.text('Reset defaults'), findsOneWidget); + }); + + testWidgets('increment weight button increases weight', (tester) async { + await _pump(tester, _wrap()); + + final firstName = workoutA.first.name; + final state = await StorageService.instance.getExerciseState(firstName); + final before = state!.weight; + + await tester.tap(find.byIcon(Icons.add).first); + await tester.pump(); + + expect(find.textContaining('${before + kWeightIncrement}kg'), findsWidgets); + }); + + testWidgets('decrement weight button decreases weight', (tester) async { + await _pump(tester, _wrap()); + + final firstName = workoutA.first.name; + final state = await StorageService.instance.getExerciseState(firstName); + final before = state!.weight; + + await tester.tap(find.byIcon(Icons.remove).first); + await tester.pump(); + + expect( + find.textContaining('${before - kWeightIncrement}kg'), + findsWidgets, + ); + }); + + testWidgets('threshold circles show values 1-5', (tester) async { + await _pump(tester, _wrap()); + for (int i = 1; i <= 5; i++) { + expect(find.text('$i'), findsWidgets); + } + }); + + testWidgets('Reset dialog shows on tap and cancels', (tester) async { + await _pump(tester, _wrap()); + await tester.tap(find.text('Reset defaults')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Reset to defaults?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pump(); + expect(find.text('Reset to defaults?'), findsNothing); + }); + + testWidgets('Reset dialog confirms and resets data', (tester) async { + await StorageService.instance + .setExerciseWeight(workoutA.first.name, 99.0); + + await _pump(tester, _wrap()); + await tester.tap(find.text('Reset defaults')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.text('Reset')); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + + final state = + await StorageService.instance.getExerciseState(workoutA.first.name); + expect(state!.weight, workoutA.first.weight); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart b/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart new file mode 100644 index 0000000..d802725 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/screens/workout_screen.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/widgets/exercise_tile.dart'; + +const _exercises = [ + Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20.0), + Exercise(name: 'Press', sets: 3, reps: 5, weight: 15.0), +]; + +Widget _wrap({ + String type = 'A', + List exercises = _exercises, + Map? savedState, +}) => + MaterialApp( + home: WorkoutScreen( + workoutType: type, + exercises: exercises, + savedState: savedState, + ), + ); + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + }); + + Future _pump(WidgetTester tester, Widget w) async { + await tester.runAsync(() async { + await tester.pumpWidget(w); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + } + + testWidgets('shows Workout A in app bar', (tester) async { + await _pump(tester, _wrap()); + expect(find.textContaining('Workout A'), findsOneWidget); + }); + + testWidgets('shows exercise tiles for all exercises', (tester) async { + await _pump(tester, _wrap()); + expect(find.byType(ExerciseTile), findsNWidgets(_exercises.length)); + }); + + testWidgets('Reset and Finish buttons are present', (tester) async { + await _pump(tester, _wrap()); + expect(find.text('Reset'), findsOneWidget); + expect(find.text('Finish'), findsOneWidget); + }); + + testWidgets('Finish button is disabled when not all sets done', (tester) async { + await _pump(tester, _wrap()); + final finishButton = tester.widget( + find.widgetWithText(TextButton, 'Finish'), + ); + expect(finishButton.onPressed, isNull); + }); + + testWidgets('Reset dialog shows and cancels', (tester) async { + await _pump(tester, _wrap()); + await tester.tap(find.text('Reset')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Reset workout?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pump(); + expect(find.text('Reset workout?'), findsNothing); + }); + + testWidgets('tapping a set circle marks it as tapped', (tester) async { + await _pump(tester, _wrap()); + final circles = find.byType(RepCircle); + await tester.tap(circles.first); + await tester.pump(); + expect(find.byType(ExerciseTile), findsWidgets); + }); + + testWidgets('restores saved state on construction', (tester) async { + final now = DateTime.now(); + final saved = { + 'workoutType': 'A', + 'startTimeMs': + now.subtract(const Duration(minutes: 10)).millisecondsSinceEpoch, + 'tapped': [ + [true, true, true], + [true, true, true], + ], + 'doneReps': [ + [5, 5, 5], + [5, 5, 5], + ], + 'warmupTapped': [false, false], + }; + await _pump(tester, _wrap(savedState: saved)); + final finishButton = tester.widget( + find.widgetWithText(TextButton, 'Finish'), + ); + expect(finishButton.onPressed, isNotNull); + }); + + testWidgets('Finish dialog shows when all sets complete', (tester) async { + final now = DateTime.now(); + final saved = { + 'workoutType': 'A', + 'startTimeMs': now.millisecondsSinceEpoch, + 'tapped': [ + [true, true, true], + [true, true, true], + ], + 'doneReps': [ + [5, 5, 5], + [5, 5, 5], + ], + 'warmupTapped': [false, false], + }; + await _pump(tester, _wrap(savedState: saved)); + await tester.tap(find.text('Finish')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Finish workout?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pump(); + }); + + testWidgets('elapsed timer shows time in app bar', (tester) async { + await _pump(tester, _wrap()); + expect(find.textContaining('00:00'), findsOneWidget); + }); + + testWidgets('B workout type shows in app bar', (tester) async { + await _pump(tester, _wrap(type: 'B')); + expect(find.textContaining('Workout B'), findsOneWidget); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/http_server_service_test.dart b/stronglift_replacement/workout_app/test/services/http_server_service_test.dart new file mode 100644 index 0000000..62cda23 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/http_server_service_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/http_server_service.dart'; + +void main() { + group('HttpServerService', () { + tearDown(() async { + await HttpServerService.instance.stop(); + }); + + test('is a singleton', () { + expect( + identical(HttpServerService.instance, HttpServerService.instance), + isTrue, + ); + }); + + test('latestWorkout getter returns null initially', () { + expect(HttpServerService.instance.latestWorkout, isNull); + }); + + test('latestWorkout setter updates the value', () { + HttpServerService.instance.latestWorkout = '{"test":1}'; + expect(HttpServerService.instance.latestWorkout, '{"test":1}'); + }); + + test('start binds and stop releases the server', () async { + await HttpServerService.instance.start(); + // A second start call is a no-op (idempotent). + await HttpServerService.instance.start(); + await HttpServerService.instance.stop(); + // Calling stop when already stopped is safe. + await HttpServerService.instance.stop(); + }); + + test('localAddresses returns a list', () async { + final addrs = await HttpServerService.instance.localAddresses; + expect(addrs, isA>()); + }); + + test('kWorkoutServerPort has expected value', () { + expect(kWorkoutServerPort, 8765); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/storage_service_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_test.dart new file mode 100644 index 0000000..e164be0 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_test.dart @@ -0,0 +1,319 @@ +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'; + +StorageService get _svc => StorageService.instance; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + }); + + // ── Workout type ─────────────────────────────────────────────────────────── + + group('getNextWorkoutType', () { + test('returns A when no workout has been done', () async { + expect(await _svc.getNextWorkoutType(), 'A'); + }); + + test('returns B after setting last type to A', () async { + await _svc.setLastWorkoutType('A'); + expect(await _svc.getNextWorkoutType(), 'B'); + }); + + test('returns A after setting last type to B', () async { + await _svc.setLastWorkoutType('B'); + expect(await _svc.getNextWorkoutType(), 'A'); + }); + }); + + // ── Active session ───────────────────────────────────────────────────────── + + group('active session', () { + test('loadActiveSession returns null when empty', () async { + expect(await _svc.loadActiveSession(), isNull); + }); + + 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('saveActiveSession replaces previous entry', () async { + await _svc.saveActiveSession({'v': 1}); + await _svc.saveActiveSession({'v': 2}); + final loaded = await _svc.loadActiveSession(); + expect(loaded!['v'], 2); + }); + + test('clearActiveSession removes the entry', () async { + await _svc.saveActiveSession({'x': 1}); + await _svc.clearActiveSession(); + expect(await _svc.loadActiveSession(), isNull); + }); + }); + + // ── Exercise state ───────────────────────────────────────────────────────── + + group('getExerciseState', () { + test('returns state for seeded exercises', () async { + final state = await _svc.getExerciseState(workoutA.first.name); + expect(state, isNotNull); + expect(state!.weight, workoutA.first.weight); + }); + + test('returns null for unknown exercise', () async { + expect(await _svc.getExerciseState('Unknown Exercise'), isNull); + }); + }); + + group('getAllExerciseStates', () { + test('returns states for all exercises in both plans', () async { + final states = await _svc.getAllExerciseStates(); + final allNames = {...workoutA, ...workoutB}.map((e) => e.name).toSet(); + expect(states.map((s) => s.name).toSet(), equals(allNames)); + }); + }); + + group('setExerciseThresholds', () { + test('updates thresholds and verifies', () async { + final name = workoutA.first.name; + await _svc.setExerciseThresholds( + name, + successThreshold: 5, + failThreshold: 3, + ); + final state = await _svc.getExerciseState(name); + expect(state!.successThreshold, 5); + expect(state.failThreshold, 3); + }); + }); + + group('setExerciseWeight', () { + test('updates weight and resets streaks', () async { + final name = workoutA.first.name; + await _svc.setExerciseWeight(name, 30.0); + final state = await _svc.getExerciseState(name); + expect(state!.weight, 30.0); + expect(state.successStreak, 0); + expect(state.failStreak, 0); + }); + }); + + group('getCurrentExercises', () { + test('returns exercises with state-applied weights for A', () async { + final exercises = await _svc.getCurrentExercises('A'); + expect(exercises.length, workoutA.length); + }); + + test('returns exercises for B', () async { + final exercises = await _svc.getCurrentExercises('B'); + expect(exercises.length, workoutB.length); + }); + }); + + // ── Progression ──────────────────────────────────────────────────────────── + + group('applyProgression', () { + test('increments successStreak on success below threshold', () async { + final name = workoutA.first.name; + await _svc.setExerciseThresholds( + name, + successThreshold: 3, + failThreshold: 2, + ); + final before = await _svc.getExerciseState(name); + await _svc.applyProgression( + succeededExercises: {name: true}, + lastWorkoutDate: DateTime.now().subtract(const Duration(days: 1)), + ); + final after = await _svc.getExerciseState(name); + expect(after!.successStreak, (before!.successStreak + 1)); + }); + + test('progresses weight when successStreak hits threshold', () async { + final name = workoutA.first.name; + await _svc.setExerciseThresholds( + name, + successThreshold: 1, + failThreshold: 2, + ); + final before = await _svc.getExerciseState(name); + await _svc.applyProgression( + succeededExercises: {name: true}, + lastWorkoutDate: DateTime.now().subtract(const Duration(days: 1)), + ); + final after = await _svc.getExerciseState(name); + // Weight should increase (if below maxWeight) or reps increase + if (before!.weight < before.maxWeight) { + expect(after!.weight, greaterThan(before.weight)); + } else { + expect(after!.reps, greaterThanOrEqualTo(before.reps + 1)); + } + }); + + test('increments failStreak on failure below threshold', () async { + final name = workoutA.first.name; + await _svc.setExerciseThresholds( + name, + successThreshold: 3, + failThreshold: 3, + ); + final before = await _svc.getExerciseState(name); + await _svc.applyProgression( + succeededExercises: {name: false}, + lastWorkoutDate: DateTime.now().subtract(const Duration(days: 1)), + ); + final after = await _svc.getExerciseState(name); + expect(after!.failStreak, (before!.failStreak + 1)); + }); + + test('decreases weight when failStreak hits threshold', () async { + final name = workoutA.first.name; + await _svc.setExerciseThresholds( + name, + successThreshold: 3, + failThreshold: 1, + ); + final before = await _svc.getExerciseState(name); + await _svc.applyProgression( + succeededExercises: {name: false}, + lastWorkoutDate: DateTime.now().subtract(const Duration(days: 1)), + ); + final after = await _svc.getExerciseState(name); + expect(after!.weight, lessThanOrEqualTo(before!.weight)); + }); + + test('reduces weight after long break (> 7 days)', () async { + final name = workoutA.first.name; + await _svc.setExerciseWeight(name, 20.0); + final before = await _svc.getExerciseState(name); + await _svc.applyProgression( + succeededExercises: {name: true}, + lastWorkoutDate: DateTime.now().subtract(const Duration(days: 10)), + ); + final after = await _svc.getExerciseState(name); + expect(after!.weight, lessThan(before!.weight)); + }); + + test('skips unknown exercise gracefully', () async { + await _svc.applyProgression( + succeededExercises: {'Ghost Exercise': true}, + lastWorkoutDate: DateTime.now().subtract(const Duration(days: 1)), + ); + // No exception thrown — that's the test. + }); + }); + + // ── History ──────────────────────────────────────────────────────────────── + + group('workout history', () { + test('getLastWorkoutDate returns null when empty', () async { + expect(await _svc.getLastWorkoutDate(), isNull); + }); + + test('saveSession and getLastWorkoutDate', () async { + await _svc.saveSession( + date: '2024-06-01', + workoutType: 'A', + durationSeconds: 2700, + succeeded: true, + json: '{}', + ); + final date = await _svc.getLastWorkoutDate(); + expect(date, isNotNull); + expect(date!.year, 2024); + }); + + test('getWorkoutHistory returns rows newest first', () async { + await _svc.saveSession( + date: '2024-06-01', + workoutType: 'A', + durationSeconds: 1000, + succeeded: true, + json: '{}', + ); + await _svc.saveSession( + date: '2024-06-02', + workoutType: 'B', + durationSeconds: 1200, + succeeded: false, + json: '{}', + ); + final rows = await _svc.getWorkoutHistory(limit: 10); + expect(rows.first['date'], '2024-06-02'); + }); + + test('getWorkoutHistory respects limit', () async { + for (var i = 0; i < 5; i++) { + await _svc.saveSession( + date: '2024-0$i-01', + workoutType: 'A', + durationSeconds: 1000, + succeeded: true, + json: '{}', + ); + } + final rows = await _svc.getWorkoutHistory(limit: 3); + expect(rows.length, lessThanOrEqualTo(3)); + }); + + test('getAllWorkoutDates returns distinct dates', () async { + await _svc.saveSession( + date: '2024-06-01', + workoutType: 'A', + durationSeconds: 1000, + succeeded: true, + json: '{}', + ); + await _svc.saveSession( + date: '2024-06-01', + workoutType: 'B', + durationSeconds: 1200, + succeeded: false, + json: '{}', + ); + final dates = await _svc.getAllWorkoutDates(); + expect(dates.where((d) => d == '2024-06-01').length, 1); + }); + }); + + // ── Reset to defaults ────────────────────────────────────────────────────── + + group('resetExerciseToDefaults', () { + test('restores default weight and thresholds', () async { + final name = workoutA.first.name; + await _svc.setExerciseWeight(name, 99.0); + await _svc.resetExerciseToDefaults(name); + final state = await _svc.getExerciseState(name); + expect(state!.weight, workoutA.first.weight); + expect(state.successThreshold, 3); + expect(state.failThreshold, 2); + }); + + test('throws for unknown exercise name', () async { + await expectLater( + _svc.resetExerciseToDefaults('Ghost Exercise'), + throwsException, + ); + }); + }); + + // ── init is idempotent ───────────────────────────────────────────────────── + + test('init returns same instance when called twice', () async { + final a = await StorageService.init(); + final b = await StorageService.init(); + expect(identical(a, b), isTrue); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/sync_service_test.dart b/stronglift_replacement/workout_app/test/services/sync_service_test.dart new file mode 100644 index 0000000..1e30b49 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/sync_service_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +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/services/http_server_service.dart'; +import 'package:workout_app/services/sync_service.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + group('SyncResult', () { + test('success result has correct fields', () { + const r = SyncResult(success: true, path: '/sdcard/workout_result.json'); + expect(r.success, isTrue); + expect(r.path, '/sdcard/workout_result.json'); + expect(r.error, isNull); + }); + + test('failure result has correct fields', () { + const r = SyncResult( + success: false, + path: null, + error: 'No writable external path', + ); + expect(r.success, isFalse); + expect(r.path, isNull); + expect(r.error, 'No writable external path'); + }); + }); + + group('SyncService.writeWorkoutResult', () { + test('returns SyncResult and updates HttpServerService', () async { + final session = WorkoutSession( + workoutType: 'A', + startTime: DateTime(2024, 6, 1, 9), + endTime: DateTime(2024, 6, 1, 10), + exercises: [ + ExerciseResult( + exercise: const Exercise( + name: 'Squat', + sets: 3, + reps: 5, + weight: 20, + ), + sets: List.generate( + 3, + (_) => const SetResult(targetReps: 5, doneReps: 5, weight: 20), + ), + ), + ], + ); + + final result = await SyncService().writeWorkoutResult(session); + + // On Linux /sdcard/ and getExternalStorageDirectory() both fail, so we + // expect the graceful failure path. + expect(result, isA()); + // The HTTP server must be updated regardless of file write success. + expect( + HttpServerService.instance.latestWorkout, + contains('"workout_type":"A"'), + ); + }); + + test('failed result has error message when no writable path', () async { + final session = WorkoutSession( + workoutType: 'B', + startTime: DateTime(2024), + endTime: DateTime(2024), + exercises: [], + ); + + final result = await SyncService().writeWorkoutResult(session); + // On Linux both paths fail, so success is false. + if (!result.success) { + expect(result.error, isNotNull); + expect(result.path, isNull); + } + }); + }); + + group('kSyncFilePath', () { + test('constant has expected value', () { + expect(kSyncFilePath, '/sdcard/workout_result.json'); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/widget_test.dart b/stronglift_replacement/workout_app/test/widget_test.dart index 93b96cc..d152f30 100644 --- a/stronglift_replacement/workout_app/test/widget_test.dart +++ b/stronglift_replacement/workout_app/test/widget_test.dart @@ -1,2 +1,26 @@ -// Tests are written after the user approves functionality per project rules. -void main() {} +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/services/storage_service.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + }); + + testWidgets('WorkoutApp renders HomeScreen', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const WorkoutApp()); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + expect(find.text('Workout Tracker'), findsOneWidget); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/break_banner_test.dart b/stronglift_replacement/workout_app/test/widgets/break_banner_test.dart new file mode 100644 index 0000000..59d41ed --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/break_banner_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/widgets/break_banner.dart'; + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +void main() { + group('BreakBanner', () { + testWidgets('displays label and formatted time', (tester) async { + await tester.pumpWidget( + _wrap( + const BreakBanner( + breakRemaining: 90, + breakLabel: 'Rest', + onSkip: null, + ), + ), + ); + expect(find.text('Rest'), findsOneWidget); + expect(find.text('01:30'), findsOneWidget); + }); + + testWidgets('formats time below one minute correctly', (tester) async { + await tester.pumpWidget( + _wrap( + const BreakBanner( + breakRemaining: 5, + breakLabel: 'Warmup rest', + onSkip: null, + ), + ), + ); + expect(find.text('00:05'), findsOneWidget); + }); + + testWidgets('skip button calls onSkip', (tester) async { + var skipped = false; + await tester.pumpWidget( + _wrap( + BreakBanner( + breakRemaining: 60, + breakLabel: 'Rest', + onSkip: () => skipped = true, + ), + ), + ); + await tester.tap(find.text('Skip')); + expect(skipped, isTrue); + }); + + testWidgets('zero seconds formats as 00:00', (tester) async { + await tester.pumpWidget( + _wrap( + const BreakBanner( + breakRemaining: 0, + breakLabel: 'Rest', + onSkip: null, + ), + ), + ); + expect(find.text('00:00'), findsOneWidget); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/calendar_widget_test.dart b/stronglift_replacement/workout_app/test/widgets/calendar_widget_test.dart new file mode 100644 index 0000000..b7b0adc --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/calendar_widget_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/widgets/calendar_widget.dart'; + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +void main() { + group('WorkoutCalendar', () { + final june2024 = DateTime(2024, 6); + + testWidgets('shows month and year in header', (tester) async { + await tester.pumpWidget( + _wrap( + WorkoutCalendar( + workoutDates: const {}, + month: june2024, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(find.text('June 2024'), findsOneWidget); + }); + + testWidgets('shows day-of-week headers', (tester) async { + await tester.pumpWidget( + _wrap( + WorkoutCalendar( + workoutDates: const {}, + month: june2024, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(find.text('Mo'), findsOneWidget); + expect(find.text('Su'), findsOneWidget); + }); + + testWidgets('calls onPrevMonth when left arrow tapped', (tester) async { + var called = false; + await tester.pumpWidget( + _wrap( + WorkoutCalendar( + workoutDates: const {}, + month: june2024, + onPrevMonth: () => called = true, + onNextMonth: () {}, + ), + ), + ); + await tester.tap(find.byIcon(Icons.chevron_left)); + expect(called, isTrue); + }); + + testWidgets('calls onNextMonth when right arrow tapped', (tester) async { + var called = false; + await tester.pumpWidget( + _wrap( + WorkoutCalendar( + workoutDates: const {}, + month: june2024, + onPrevMonth: () {}, + onNextMonth: () => called = true, + ), + ), + ); + await tester.tap(find.byIcon(Icons.chevron_right)); + expect(called, isTrue); + }); + + testWidgets('highlights workout dates', (tester) async { + await tester.pumpWidget( + _wrap( + WorkoutCalendar( + workoutDates: const {'2024-06-15'}, + month: june2024, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + // Day 15 should appear in the grid. + expect(find.text('15'), findsOneWidget); + }); + + testWidgets('renders month starting on Sunday correctly', (tester) async { + // September 2024 starts on a Sunday (weekday=7, offset=6 in Mon-first grid) + final sep2024 = DateTime(2024, 9); + await tester.pumpWidget( + _wrap( + WorkoutCalendar( + workoutDates: const {}, + month: sep2024, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(find.text('September 2024'), findsOneWidget); + }); + + testWidgets('renders January (first month name) correctly', (tester) async { + await tester.pumpWidget( + _wrap( + WorkoutCalendar( + workoutDates: const {}, + month: DateTime(2024), + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(find.text('January 2024'), findsOneWidget); + }); + + testWidgets('renders December (last month name) correctly', (tester) async { + await tester.pumpWidget( + _wrap( + WorkoutCalendar( + workoutDates: const {}, + month: DateTime(2024, 12), + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(find.text('December 2024'), findsOneWidget); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart b/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart new file mode 100644 index 0000000..b8b0430 --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/widgets/exercise_tile.dart'; + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +const _exercise = Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20.0); + +ExerciseTile _tile({ + List? tapped, + List? doneReps, + bool warmupTapped = false, + int successThreshold = 3, + int failThreshold = 2, + void Function(int)? onTapCircle, + void Function(int)? onLongPressCircle, + VoidCallback? onTapWarmup, + void Function(int, int)? onThresholdChanged, +}) => + ExerciseTile( + exercise: _exercise, + tapped: tapped ?? [false, false, false], + doneReps: doneReps ?? [5, 5, 5], + warmupTapped: warmupTapped, + successThreshold: successThreshold, + failThreshold: failThreshold, + onTapCircle: onTapCircle ?? (_) {}, + onLongPressCircle: onLongPressCircle ?? (_) {}, + onTapWarmup: onTapWarmup ?? () {}, + onThresholdChanged: onThresholdChanged ?? (_, __) {}, + ); + +void main() { + group('ExerciseTile', () { + testWidgets('shows exercise name and weight info', (tester) async { + await tester.pumpWidget(_wrap(_tile())); + expect(find.text('Squat'), findsOneWidget); + expect(find.textContaining('3×5×20.0kg'), findsOneWidget); + }); + + testWidgets('shows warmup weight', (tester) async { + await tester.pumpWidget(_wrap(_tile())); + // warmupWeight for 20kg squat = 10kg (50%) + expect(find.textContaining('${_exercise.warmupWeight}kg'), findsOneWidget); + }); + + testWidgets('calls onTapCircle when set circle tapped', (tester) async { + var tappedIdx = -1; + await tester.pumpWidget( + _wrap(_tile(onTapCircle: (i) => tappedIdx = i)), + ); + // Tap the first RepCircle (index 0) + await tester.tap(find.byType(RepCircle).first); + expect(tappedIdx, 0); + }); + + testWidgets('calls onLongPressCircle on long press', (tester) async { + var idx = -1; + await tester.pumpWidget( + _wrap(_tile(onLongPressCircle: (i) => idx = i)), + ); + await tester.longPress(find.byType(RepCircle).first); + expect(idx, 0); + }); + + testWidgets('calls onTapWarmup when warmup circle tapped', (tester) async { + var called = false; + await tester.pumpWidget(_wrap(_tile(onTapWarmup: () => called = true))); + // The warmup circle is the GestureDetector wrapping the AnimatedContainer. + // Find the warmup area by finding the fitness_center icon. + await tester.tap(find.byIcon(Icons.fitness_center)); + expect(called, isTrue); + }); + + testWidgets('header is green when all sets succeeded', (tester) async { + await tester.pumpWidget( + _wrap( + _tile( + tapped: [true, true, true], + doneReps: [5, 5, 5], + ), + ), + ); + // Just verify it renders without errors + expect(find.byType(ExerciseTile), findsOneWidget); + }); + + testWidgets('header is red when all sets tapped but some failed', (tester) async { + await tester.pumpWidget( + _wrap( + _tile( + tapped: [true, true, true], + doneReps: [5, 5, 3], + ), + ), + ); + expect(find.byType(ExerciseTile), findsOneWidget); + }); + + testWidgets('success threshold stepper increments', (tester) async { + var newSuccess = 0; + await tester.pumpWidget( + _wrap( + _tile( + successThreshold: 2, + onThresholdChanged: (s, _) => newSuccess = s, + ), + ), + ); + // The first add icon belongs to the success stepper + final addIcons = find.byIcon(Icons.add); + await tester.tap(addIcons.first); + expect(newSuccess, 3); + }); + + testWidgets('fail threshold stepper decrements', (tester) async { + var newFail = 0; + await tester.pumpWidget( + _wrap( + _tile( + failThreshold: 3, + onThresholdChanged: (_, f) => newFail = f, + ), + ), + ); + // The last remove icon belongs to the fail stepper + final removeIcons = find.byIcon(Icons.remove); + await tester.tap(removeIcons.last); + expect(newFail, 2); + }); + + testWidgets('stepper min/max clamps are respected', (tester) async { + var callCount = 0; + // successThreshold = 1 (at min), tapping minus should not fire + await tester.pumpWidget( + _wrap( + _tile( + successThreshold: 1, + failThreshold: 5, + onThresholdChanged: (_, __) => callCount++, + ), + ), + ); + // First minus (success, at min 1) → no callback + await tester.tap(find.byIcon(Icons.remove).first); + expect(callCount, 0); + // Last add (fail, at max 5) → no callback + await tester.tap(find.byIcon(Icons.add).last); + expect(callCount, 0); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/rep_circle_test.dart b/stronglift_replacement/workout_app/test/widgets/rep_circle_test.dart new file mode 100644 index 0000000..ad0d95f --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/rep_circle_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/widgets/rep_circle.dart'; + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +void main() { + group('RepCircle states', () { + testWidgets('neutral shows target reps and white background', (tester) async { + var tapped = false; + await tester.pumpWidget( + _wrap( + RepCircle( + targetReps: 5, + doneReps: 5, + tapped: false, + onTap: () => tapped = true, + onLongPress: () {}, + ), + ), + ); + expect(find.text('5'), findsOneWidget); + // Tapping calls onTap + await tester.tap(find.byType(RepCircle)); + expect(tapped, isTrue); + }); + + testWidgets('success state (tapped, doneReps == targetReps)', (tester) async { + await tester.pumpWidget( + _wrap( + RepCircle( + targetReps: 5, + doneReps: 5, + tapped: true, + onTap: () {}, + onLongPress: () {}, + ), + ), + ); + expect(find.text('5'), findsOneWidget); + }); + + testWidgets('partial state (tapped, 0 < doneReps < targetReps)', (tester) async { + await tester.pumpWidget( + _wrap( + RepCircle( + targetReps: 5, + doneReps: 3, + tapped: true, + onTap: () {}, + onLongPress: () {}, + ), + ), + ); + expect(find.text('3'), findsOneWidget); + }); + + testWidgets('failed state (tapped, doneReps == 0)', (tester) async { + await tester.pumpWidget( + _wrap( + RepCircle( + targetReps: 5, + doneReps: 0, + tapped: true, + onTap: () {}, + onLongPress: () {}, + ), + ), + ); + expect(find.text('0'), findsOneWidget); + }); + + testWidgets('long press calls onLongPress', (tester) async { + var pressed = false; + await tester.pumpWidget( + _wrap( + RepCircle( + targetReps: 5, + doneReps: 5, + tapped: true, + onTap: () {}, + onLongPress: () => pressed = true, + ), + ), + ); + await tester.longPress(find.byType(RepCircle)); + expect(pressed, isTrue); + }); + }); + + group('RepCircleState enum', () { + test('all values are distinct', () { + expect(RepCircleState.values.toSet().length, RepCircleState.values.length); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/workout_summary_dialog_test.dart b/stronglift_replacement/workout_app/test/widgets/workout_summary_dialog_test.dart new file mode 100644 index 0000000..a5465f3 --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/workout_summary_dialog_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +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/services/sync_service.dart'; +import 'package:workout_app/widgets/workout_summary_dialog.dart'; + +WorkoutSession _session({bool allSucceeded = true, Duration duration = const Duration(minutes: 45, seconds: 30)}) { + final start = DateTime(2024, 6, 1, 9); + final end = start.add(duration); + return WorkoutSession( + workoutType: 'A', + startTime: start, + endTime: end, + exercises: [ + ExerciseResult( + exercise: const Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20), + sets: List.generate( + 3, + (_) => SetResult( + targetReps: 5, + doneReps: allSucceeded ? 5 : 3, + weight: 20, + ), + ), + ), + ], + ); +} + +void main() { + group('WorkoutSummaryDialog', () { + testWidgets('shows "Workout Complete!" when fully succeeded', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WorkoutSummaryDialog( + session: _session(), + syncResult: const SyncResult( + success: true, + path: '/sdcard/workout_result.json', + ), + ), + ), + ); + expect(find.textContaining('Workout Complete'), findsOneWidget); + }); + + testWidgets('shows "Workout Done" when not fully succeeded', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WorkoutSummaryDialog( + session: _session(allSucceeded: false), + syncResult: const SyncResult( + success: false, + path: null, + error: 'No writable external path', + ), + ), + ), + ); + expect(find.text('Workout Done'), findsOneWidget); + }); + + testWidgets('shows duration in mm m ss s format for sub-hour workout', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WorkoutSummaryDialog( + session: _session(), + syncResult: const SyncResult(success: true, path: '/p'), + ), + ), + ); + expect(find.textContaining('45m 30s'), findsOneWidget); + }); + + testWidgets('shows hours in duration for long workouts', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WorkoutSummaryDialog( + session: _session(duration: const Duration(hours: 1, minutes: 5, seconds: 3)), + syncResult: const SyncResult(success: true, path: '/p'), + ), + ), + ); + expect(find.textContaining('1h'), findsOneWidget); + }); + + testWidgets('shows exercise name with check mark on success', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WorkoutSummaryDialog( + session: _session(), + syncResult: const SyncResult(success: true, path: '/p'), + ), + ), + ); + expect(find.textContaining('Squat: ✓'), findsOneWidget); + }); + + testWidgets('shows exercise name with cross on failure', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WorkoutSummaryDialog( + session: _session(allSucceeded: false), + syncResult: const SyncResult(success: false, path: null, error: 'err'), + ), + ), + ); + expect(find.textContaining('Squat: ✗'), findsOneWidget); + }); + + testWidgets('shows saved path on sync success', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WorkoutSummaryDialog( + session: _session(), + syncResult: const SyncResult( + success: true, + path: '/sdcard/workout_result.json', + ), + ), + ), + ); + expect( + find.textContaining('Saved to /sdcard/workout_result.json'), + findsOneWidget, + ); + }); + + testWidgets('shows error message on sync failure', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WorkoutSummaryDialog( + session: _session(allSucceeded: false), + syncResult: const SyncResult( + success: false, + path: null, + error: 'No writable external path', + ), + ), + ), + ); + expect( + find.textContaining('Sync failed: No writable external path'), + findsOneWidget, + ); + }); + + testWidgets('"Back to Home" button pops to first route', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: WorkoutSummaryDialog( + session: _session(), + syncResult: const SyncResult(success: true, path: '/p'), + ), + ), + ), + ); + await tester.tap(find.text('Back to Home')); + await tester.pumpAndSettle(); + // No exception = navigator popped cleanly. + }); + }); +}