Add comprehensive test suite, backup service, and linting to workout app

- Add sqflite_common_ffi + very_good_analysis; tighten analysis_options
- Add BackupService for JSON export/import of exercise state
- Add full test coverage: models, screens, services, widgets
- Add scripts/check_flutter_coverage.sh to enforce 100% line coverage
- Add docstrings to ExerciseState fields and storage service
- Minor fixes across screens, widgets, and sync/HTTP services

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuiPt6GPWkxpLbJFrnfy8U
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-28 08:11:43 +02:00
parent 74a8bd7529
commit 23d2173d9f
42 changed files with 2927 additions and 353 deletions

View File

@ -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%)."

View File

@ -1,28 +1,32 @@
# This file configures the analyzer, which statically analyzes Dart code to include: package:very_good_analysis/analysis_options.yaml
# 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`.
# The following line activates a set of recommended lints for Flutter apps, analyzer:
# packages, and plugins designed to encourage good coding practices. language:
include: package:flutter_lints/flutter.yaml 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: 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: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # very_good_analysis enables most rules; add extras it doesn't include.
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule always_use_package_imports: true
avoid_print: true
# Additional information about this file can be found at avoid_relative_lib_imports: true
# https://dart.dev/guides/language/analysis-options 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

View File

@ -4,6 +4,9 @@
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<!-- Full external storage access — needed to persist backup.json across
uninstall/reinstall on Android 11+ where legacy permissions are revoked. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<!-- Wake lock keeps timer running in background --> <!-- Wake lock keeps timer running in background -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />

View File

@ -1,16 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:workout_app/screens/home_screen.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/http_server_service.dart';
import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/storage_service.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await BackupService.instance.requestStoragePermission();
await StorageService.init(); await StorageService.init();
await StorageService.instance.restoreFromBackupIfNeeded();
await HttpServerService.instance.start(); await HttpServerService.instance.start();
runApp(const WorkoutApp()); runApp(const WorkoutApp());
} }
/// Root widget that bootstraps the app with Material 3 dark theming.
class WorkoutApp extends StatelessWidget { class WorkoutApp extends StatelessWidget {
/// Creates the root app widget.
const WorkoutApp({super.key}); const WorkoutApp({super.key});
@override @override

View File

@ -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; library;
/// Default weight cap: above this, reps increase instead of weight.
const double kDefaultMaxWeight = 27.5; const double kDefaultMaxWeight = 27.5;
/// Weight increment used for progression steps (kg).
const double kWeightIncrement = 2.5; const double kWeightIncrement = 2.5;
/// Immutable definition of a single exercise and its current target state.
class Exercise { class Exercise {
/// Creates an exercise with the given parameters.
const Exercise({ const Exercise({
required this.name, required this.name,
required this.sets, required this.sets,
required this.reps, required this.reps,
required this.weight, required this.weight,
this.maxWeight = kDefaultMaxWeight, this.maxWeight = kDefaultMaxWeight,
this.hasWarmup = true,
}); });
/// Deserializes an exercise from a JSON map.
factory Exercise.fromJson(Map<String, dynamic> 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; final String name;
/// Number of working sets per session.
final int sets; final int sets;
/// Target reps per set.
final int reps; final int reps;
/// Current working weight in kg.
final double weight; final double weight;
/// Weight cap beyond which reps increase instead of weight. /// Weight cap beyond which reps increase instead of weight.
final double maxWeight; 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. /// Warmup weight: 4/5 of target weight, rounded DOWN to nearest 2.5 kg.
double get warmupWeight { double get warmupWeight {
final raw = weight * 4.0 / 5.0; final raw = weight * 4.0 / 5.0;
return (raw / kWeightIncrement).floor() * kWeightIncrement; return (raw / kWeightIncrement).floor() * kWeightIncrement;
} }
/// Returns a copy of this exercise with the given fields replaced.
Exercise copyWith({ Exercise copyWith({
String? name, String? name,
int? sets, int? sets,
int? reps, int? reps,
double? weight, double? weight,
double? maxWeight, double? maxWeight,
bool? hasWarmup,
}) { }) {
return Exercise( return Exercise(
name: name ?? this.name, name: name ?? this.name,
@ -40,22 +68,17 @@ class Exercise {
reps: reps ?? this.reps, reps: reps ?? this.reps,
weight: weight ?? this.weight, weight: weight ?? this.weight,
maxWeight: maxWeight ?? this.maxWeight, maxWeight: maxWeight ?? this.maxWeight,
hasWarmup: hasWarmup ?? this.hasWarmup,
); );
} }
/// Serializes this exercise to a JSON map.
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'name': name, 'name': name,
'sets': sets, 'sets': sets,
'reps': reps, 'reps': reps,
'weight': weight, 'weight': weight,
'maxWeight': maxWeight, 'maxWeight': maxWeight,
'hasWarmup': hasWarmup,
}; };
factory Exercise.fromJson(Map<String, dynamic> 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,
);
} }

View File

@ -4,20 +4,28 @@ library;
import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/set_result.dart'; import 'package:workout_app/models/set_result.dart';
/// Aggregated results for a single exercise across all its sets in a session.
class ExerciseResult { class ExerciseResult {
/// Creates a result for [exercise] with the given [sets] outcomes.
const ExerciseResult({ const ExerciseResult({
required this.exercise, required this.exercise,
required this.sets, required this.sets,
this.warmupDone = false, this.warmupDone = false,
}); });
/// The exercise definition this result belongs to.
final Exercise exercise; final Exercise exercise;
/// Results for each individual set.
final List<SetResult> sets; final List<SetResult> sets;
/// Whether the warmup set was completed before the working sets.
final bool warmupDone; final bool warmupDone;
/// True when every set was fully completed. /// True when every set was fully completed.
bool get succeeded => sets.isNotEmpty && sets.every((s) => s.succeeded); bool get succeeded => sets.isNotEmpty && sets.every((s) => s.succeeded);
/// Serializes this exercise result to a JSON map.
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'name': exercise.name, 'name': exercise.name,
'targetSets': exercise.sets, 'targetSets': exercise.sets,

View File

@ -1,39 +1,46 @@
/// Result of a single set during a workout session. /// Result of a single set during a workout session.
library; library;
/// Immutable result of one set, recording target vs actual reps.
class SetResult { class SetResult {
/// Creates a set result.
const SetResult({ const SetResult({
required this.targetReps, required this.targetReps,
required this.doneReps, required this.doneReps,
required this.weight, required this.weight,
}); });
/// Deserializes a set result from a JSON map.
factory SetResult.fromJson(Map<String, dynamic> 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; 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; final int doneReps;
/// Weight used for this set in kg.
final double weight; final double weight;
/// True when the user completed every target rep. /// True when the user completed every target rep.
bool get succeeded => doneReps >= targetReps; bool get succeeded => doneReps >= targetReps;
/// Returns a copy with [doneReps] replaced.
SetResult copyWith({int? doneReps}) => SetResult( SetResult copyWith({int? doneReps}) => SetResult(
targetReps: targetReps, targetReps: targetReps,
doneReps: doneReps ?? this.doneReps, doneReps: doneReps ?? this.doneReps,
weight: weight, weight: weight,
); );
/// Serializes this set result to a JSON map.
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'targetReps': targetReps, 'targetReps': targetReps,
'doneReps': doneReps, 'doneReps': doneReps,
'weight': weight, 'weight': weight,
'succeeded': succeeded, 'succeeded': succeeded,
}; };
factory SetResult.fromJson(Map<String, dynamic> json) => SetResult(
targetReps: json['targetReps'] as int,
doneReps: json['doneReps'] as int,
weight: (json['weight'] as num).toDouble(),
);
} }

View File

@ -4,15 +4,22 @@ library;
import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/exercise.dart';
/// Situp has a lower max weight cap. /// 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 = [ final workoutA = [
const Exercise(name: 'Dumbbell Lunge', sets: 5, reps: 12, weight: 7.5), 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 Row', sets: 4, reps: 6, weight: 22.5),
const Exercise(name: 'Dumbbell Curl', sets: 3, reps: 12, weight: 12.5), const Exercise(name: 'Dumbbell Curl', sets: 3, reps: 12, weight: 12.5),
]; ];
/// Plan B: posterior chain, overhead, and core focus.
final workoutB = [ final workoutB = [
const Exercise( const Exercise(
name: 'Dumbbell Romanian Deadlift', name: 'Dumbbell Romanian Deadlift',
@ -26,12 +33,18 @@ final workoutB = [
reps: 12, reps: 12,
weight: 7.5, 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( const Exercise(
name: 'Situp', name: 'Situp',
sets: 3, sets: 3,
reps: 30, reps: 30,
weight: 10.0, weight: 10,
maxWeight: kSitupMaxWeight, maxWeight: kSitupMaxWeight,
hasWarmup: false,
), ),
]; ];

View File

@ -4,7 +4,9 @@ library;
import 'dart:convert'; import 'dart:convert';
import 'package:workout_app/models/exercise_result.dart'; import 'package:workout_app/models/exercise_result.dart';
/// Immutable record of a finished workout session with all its results.
class WorkoutSession { class WorkoutSession {
/// Creates a workout session record.
const WorkoutSession({ const WorkoutSession({
required this.workoutType, required this.workoutType,
required this.startTime, required this.startTime,
@ -14,15 +16,23 @@ class WorkoutSession {
/// 'A' or 'B'. /// 'A' or 'B'.
final String workoutType; final String workoutType;
/// Wall-clock time when the session started.
final DateTime startTime; final DateTime startTime;
/// Wall-clock time when the session ended.
final DateTime endTime; final DateTime endTime;
/// Ordered list of exercise results, one per exercise in the plan.
final List<ExerciseResult> exercises; final List<ExerciseResult> exercises;
/// Total elapsed time of the session.
Duration get duration => endTime.difference(startTime); Duration get duration => endTime.difference(startTime);
/// True when every exercise succeeded. /// True when every exercise succeeded.
bool get fullySucceeded => exercises.every((e) => e.succeeded); bool get fullySucceeded => exercises.every((e) => e.succeeded);
/// Serializes this session to a JSON map.
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'workout_type': workoutType, 'workout_type': workoutType,
'date': startTime.toIso8601String().substring(0, 10), 'date': startTime.toIso8601String().substring(0, 10),
@ -33,5 +43,6 @@ class WorkoutSession {
'exercises': exercises.map((e) => e.toJson()).toList(), 'exercises': exercises.map((e) => e.toJson()).toList(),
}; };
/// Serializes this session to a pretty-printed JSON string.
String toJsonString() => const JsonEncoder.withIndent(' ').convert(toJson()); String toJsonString() => const JsonEncoder.withIndent(' ').convert(toJson());
} }

View File

@ -5,6 +5,7 @@
/// exercise-only session list. /// exercise-only session list.
library; library;
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -14,7 +15,9 @@ import 'package:workout_app/widgets/calendar_widget.dart';
const _kTotal = 'Total (all workouts)'; const _kTotal = 'Total (all workouts)';
/// Screen showing workout history with per-exercise drill-down and charts.
class HistoryScreen extends StatefulWidget { class HistoryScreen extends StatefulWidget {
/// Creates a [HistoryScreen].
const HistoryScreen({super.key}); const HistoryScreen({super.key});
@override @override
@ -27,13 +30,12 @@ class _HistoryScreenState extends State<HistoryScreen> {
String _selected = _kTotal; String _selected = _kTotal;
List<String> _exerciseNames = []; List<String> _exerciseNames = [];
ExerciseState? _selectedState; ExerciseState? _selectedState;
DateTime _calendarMonth = DateTime _calendarMonth = DateTime(DateTime.now().year, DateTime.now().month);
DateTime(DateTime.now().year, DateTime.now().month);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_load(); unawaited(_load());
} }
Future<void> _load() async { Future<void> _load() async {
@ -41,9 +43,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
final names = <String>[]; final names = <String>[];
final seen = <String>{}; final seen = <String>{};
for (final row in rows) { for (final row in rows) {
final json = final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
jsonDecode(row['json'] as String) as Map<String, dynamic>; for (final ex in (json['exercises'] as List? ?? const [])) {
for (final ex in (json['exercises'] as List)) {
final name = (ex as Map<String, dynamic>)['name'] as String; final name = (ex as Map<String, dynamic>)['name'] as String;
if (seen.add(name)) names.add(name); if (seen.add(name)) names.add(name);
} }
@ -75,7 +76,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
} }
} }
// Data helpers // Data helpers
/// All workout dates (YYYY-MM-DD) across all sessions. /// All workout dates (YYYY-MM-DD) across all sessions.
Set<String> get _allWorkoutDates => Set<String> get _allWorkoutDates =>
@ -85,9 +86,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
Set<String> _exerciseDates(String name) { Set<String> _exerciseDates(String name) {
final result = <String>{}; final result = <String>{};
for (final row in _rows) { for (final row in _rows) {
final json = final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
jsonDecode(row['json'] as String) as Map<String, dynamic>; for (final ex in (json['exercises'] as List? ?? const [])) {
for (final ex in (json['exercises'] as List)) {
if ((ex as Map<String, dynamic>)['name'] == name) { if ((ex as Map<String, dynamic>)['name'] == name) {
result.add(row['date'] as String); result.add(row['date'] as String);
break; break;
@ -101,10 +101,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
List<(DateTime, double)> _totalVolumePoints() { List<(DateTime, double)> _totalVolumePoints() {
final points = <(DateTime, double)>[]; final points = <(DateTime, double)>[];
for (final row in _rows.reversed) { for (final row in _rows.reversed) {
final json = final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
jsonDecode(row['json'] as String) as Map<String, dynamic>;
double total = 0; 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<String, dynamic>; final m = ex as Map<String, dynamic>;
final w = (m['targetWeight'] as num?)?.toDouble() ?? 0; final w = (m['targetWeight'] as num?)?.toDouble() ?? 0;
final s = (m['targetSets'] as num?)?.toInt() ?? 0; final s = (m['targetSets'] as num?)?.toInt() ?? 0;
@ -121,9 +120,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
List<(DateTime, double)> _exerciseWeightPoints(String name) { List<(DateTime, double)> _exerciseWeightPoints(String name) {
final points = <(DateTime, double)>[]; final points = <(DateTime, double)>[];
for (final row in _rows.reversed) { for (final row in _rows.reversed) {
final json = final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
jsonDecode(row['json'] as String) as Map<String, dynamic>; for (final ex in (json['exercises'] as List? ?? const [])) {
for (final ex in (json['exercises'] as List)) {
final m = ex as Map<String, dynamic>; final m = ex as Map<String, dynamic>;
if (m['name'] == name) { if (m['name'] == name) {
final date = DateTime.tryParse(row['date'] as String); final date = DateTime.tryParse(row['date'] as String);
@ -140,9 +138,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
List<Map<String, dynamic>> _sessionsForExercise(String name) { List<Map<String, dynamic>> _sessionsForExercise(String name) {
final result = <Map<String, dynamic>>[]; final result = <Map<String, dynamic>>[];
for (final row in _rows) { for (final row in _rows) {
final json = final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
jsonDecode(row['json'] as String) as Map<String, dynamic>; for (final ex in (json['exercises'] as List? ?? const [])) {
for (final ex in (json['exercises'] as List)) {
final m = ex as Map<String, dynamic>; final m = ex as Map<String, dynamic>;
if (m['name'] == name) { if (m['name'] == name) {
result.add({...row, 'exerciseData': m}); result.add({...row, 'exerciseData': m});
@ -153,7 +150,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
return result; return result;
} }
// Build // Build
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -185,8 +182,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
onChanged: _pickExercise, onChanged: _pickExercise,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (isTotal) ..._buildTotalView() if (isTotal)
else ..._buildExerciseView(_selected), ..._buildTotalView()
else
..._buildExerciseView(_selected),
], ],
), ),
); );
@ -204,7 +203,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
} }
List<Widget> _buildTotalView() => [ List<Widget> _buildTotalView() => [
_SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'), const _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'),
const SizedBox(height: 6), const SizedBox(height: 6),
_WeightChart( _WeightChart(
points: _rollingAvg2(_totalVolumePoints()), points: _rollingAvg2(_totalVolumePoints()),
@ -227,7 +226,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
}), }),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_SectionLabel('ALL SESSIONS'), const _SectionLabel('ALL SESSIONS'),
const SizedBox(height: 8), const SizedBox(height: 8),
..._rows.map((row) => _AllSessionTile(row: row)), ..._rows.map((row) => _AllSessionTile(row: row)),
]; ];
@ -237,7 +236,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
_ProgressStatsCard(state: _selectedState!), _ProgressStatsCard(state: _selectedState!),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
_SectionLabel('WEIGHT OVER TIME'), const _SectionLabel('WEIGHT OVER TIME'),
const SizedBox(height: 6), const SizedBox(height: 6),
_WeightChart( _WeightChart(
points: _exerciseWeightPoints(name), points: _exerciseWeightPoints(name),
@ -262,12 +261,11 @@ class _HistoryScreenState extends State<HistoryScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
_SectionLabel(name.toUpperCase()), _SectionLabel(name.toUpperCase()),
const SizedBox(height: 8), const SizedBox(height: 8),
..._sessionsForExercise(name) ..._sessionsForExercise(name).map((s) => _ExerciseSessionTile(session: s)),
.map((s) => _ExerciseSessionTile(session: s)),
]; ];
} }
// Shared sub-widgets // Shared sub-widgets
class _SectionLabel extends StatelessWidget { class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text); const _SectionLabel(this.text);
@ -314,8 +312,7 @@ class _ExercisePicker extends StatelessWidget {
n, n,
style: TextStyle( style: TextStyle(
color: n == _kTotal ? Colors.white70 : Colors.white, color: n == _kTotal ? Colors.white70 : Colors.white,
fontStyle: fontStyle: n == _kTotal ? FontStyle.italic : FontStyle.normal,
n == _kTotal ? FontStyle.italic : FontStyle.normal,
), ),
), ),
), ),
@ -481,8 +478,18 @@ class _ChartPainter extends CustomPainter {
static const _hPad = 8.0; static const _hPad = 8.0;
static const _months = [ static const _months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jan',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]; ];
static String _shortDate(DateTime d) => '${_months[d.month - 1]} ${d.day}'; static String _shortDate(DateTime d) => '${_months[d.month - 1]} ${d.day}';
@ -496,9 +503,9 @@ class _ChartPainter extends CustomPainter {
final wRange = maxW - minW; final wRange = maxW - minW;
final tRange = maxMs - minMs; final tRange = maxMs - minMs;
final plotTop = _topPad; const plotTop = _topPad;
final plotBottom = size.height - _bottomPad; final plotBottom = size.height - _bottomPad;
final plotLeft = _hPad; const plotLeft = _hPad;
final plotRight = size.width - _hPad; final plotRight = size.width - _hPad;
final plotHeight = plotBottom - plotTop; final plotHeight = plotBottom - plotTop;
final plotWidth = plotRight - plotLeft; final plotWidth = plotRight - plotLeft;
@ -518,8 +525,7 @@ class _ChartPainter extends CustomPainter {
..color = Colors.indigoAccent ..color = Colors.indigoAccent
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
final path = Path() final path = Path()..moveTo(xOf(points.first.$1), yOf(points.first.$2));
..moveTo(xOf(points.first.$1), yOf(points.first.$2));
for (final p in points.skip(1)) { for (final p in points.skip(1)) {
path.lineTo(xOf(p.$1), yOf(p.$2)); path.lineTo(xOf(p.$1), yOf(p.$2));
} }
@ -540,7 +546,7 @@ class _ChartPainter extends CustomPainter {
..paint(canvas, offset); ..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)); drawText('${minW.round()}kg', Offset(plotLeft, plotBottom + 2));
// X-axis date labels: first, middle, last // X-axis date labels: first, middle, last
@ -592,7 +598,6 @@ class _AllSessionTile extends StatelessWidget {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: succeeded ? Colors.green.shade800 : Colors.red.shade900, color: succeeded ? Colors.green.shade800 : Colors.red.shade900,
width: 1,
), ),
), ),
child: Row( child: Row(
@ -616,8 +621,7 @@ class _AllSessionTile extends StatelessWidget {
), ),
Text( Text(
dur, dur,
style: style: const TextStyle(color: Colors.white54, fontSize: 12),
const TextStyle(color: Colors.white54, fontSize: 12),
), ),
], ],
), ),
@ -648,8 +652,7 @@ class _ExerciseSessionTile extends StatelessWidget {
final dur = _formatDuration(session['duration_seconds'] as int); final dur = _formatDuration(session['duration_seconds'] as int);
final weight = (exData['targetWeight'] as num?)?.toDouble(); final weight = (exData['targetWeight'] as num?)?.toDouble();
final warmupDone = exData['warmupDone'] as bool? ?? false; final warmupDone = exData['warmupDone'] as bool? ?? false;
final sets = final sets = (exData['sets'] as List?)?.cast<Map<String, dynamic>>() ?? [];
(exData['sets'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final targetSets = exData['targetSets'] as int? ?? sets.length; final targetSets = exData['targetSets'] as int? ?? sets.length;
final doneSets = sets.where((s) => s['succeeded'] == true).length; final doneSets = sets.where((s) => s['succeeded'] == true).length;
final repsSummary = sets.map((s) => '${s['doneReps']}').join(', '); final repsSummary = sets.map((s) => '${s['doneReps']}').join(', ');
@ -662,7 +665,6 @@ class _ExerciseSessionTile extends StatelessWidget {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: succeeded ? Colors.green.shade800 : Colors.red.shade900, color: succeeded ? Colors.green.shade800 : Colors.red.shade900,
width: 1,
), ),
), ),
child: Row( child: Row(

View File

@ -1,6 +1,7 @@
/// Home screen: auto-resumes an active session, shows done-today status. /// Home screen: auto-resumes an active session, shows done-today status.
library; library;
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/screens/history_screen.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/http_server_service.dart';
import 'package:workout_app/services/storage_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 { class HomeScreen extends StatefulWidget {
/// Creates a [HomeScreen].
const HomeScreen({super.key}); const HomeScreen({super.key});
@override @override
@ -17,7 +20,7 @@ class HomeScreen extends StatefulWidget {
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
List<Exercise>? _exercises; late List<Exercise> _exercises;
String _nextType = 'A'; String _nextType = 'A';
List<String> _serverAddresses = []; List<String> _serverAddresses = [];
bool _loading = true; bool _loading = true;
@ -31,7 +34,7 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_load(); unawaited(_load());
} }
Future<void> _load() async { Future<void> _load() async {
@ -42,7 +45,8 @@ class _HomeScreenState extends State<HomeScreen> {
final addrs = await HttpServerService.instance.localAddresses; final addrs = await HttpServerService.instance.localAddresses;
final lastDate = await storage.getLastWorkoutDate(); final lastDate = await storage.getLastWorkoutDate();
final today = DateTime.now(); final today = DateTime.now();
final doneToday = lastDate != null && final doneToday =
lastDate != null &&
lastDate.year == today.year && lastDate.year == today.year &&
lastDate.month == today.month && lastDate.month == today.month &&
lastDate.day == today.day; lastDate.day == today.day;
@ -61,7 +65,7 @@ class _HomeScreenState extends State<HomeScreen> {
if (saved != null && !_hasAutoResumed) { if (saved != null && !_hasAutoResumed) {
_hasAutoResumed = true; _hasAutoResumed = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _openWorkout(resume: true); if (mounted) unawaited(_openWorkout(resume: true));
}); });
} }
} }
@ -70,8 +74,8 @@ class _HomeScreenState extends State<HomeScreen> {
Future<void> _openWorkout({bool resume = false}) async { Future<void> _openWorkout({bool resume = false}) async {
final storage = StorageService.instance; final storage = StorageService.instance;
Map<String, dynamic>? savedState; Map<String, dynamic>? savedState;
String type = _nextType; var type = _nextType;
List<Exercise> exercises = _exercises!; var exercises = _exercises;
if (resume && _savedSession != null) { if (resume && _savedSession != null) {
savedState = _savedSession; savedState = _savedSession;
@ -90,7 +94,7 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
), ),
); );
_load(); unawaited(_load());
} }
@override @override
@ -118,7 +122,7 @@ class _HomeScreenState extends State<HomeScreen> {
builder: (_) => const SettingsScreen(), builder: (_) => const SettingsScreen(),
), ),
); );
_load(); unawaited(_load());
}, },
), ),
], ],
@ -132,10 +136,10 @@ class _HomeScreenState extends State<HomeScreen> {
children: [ children: [
_WorkoutCard( _WorkoutCard(
type: _nextType, type: _nextType,
exercises: _exercises!, exercises: _exercises,
doneToday: _doneToday, doneToday: _doneToday,
hasActiveSession: _savedSession != null, hasActiveSession: _savedSession != null,
onStart: () => _openWorkout(resume: false), onStart: _openWorkout,
onResume: () => _openWorkout(resume: true), onResume: () => _openWorkout(resume: true),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@ -147,7 +151,7 @@ class _HomeScreenState extends State<HomeScreen> {
} }
} }
// Sub-widgets // Sub-widgets
class _WorkoutCard extends StatelessWidget { class _WorkoutCard extends StatelessWidget {
const _WorkoutCard({ const _WorkoutCard({

View File

@ -8,7 +8,9 @@ import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/storage_service.dart';
/// Screen for editing per-exercise thresholds and manual weight overrides.
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
/// Creates a [SettingsScreen].
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@override @override
@ -16,7 +18,6 @@ class SettingsScreen extends StatefulWidget {
} }
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
List<ExerciseState>? _states;
bool _loading = true; bool _loading = true;
final Map<String, int> _successThresholds = {}; final Map<String, int> _successThresholds = {};
@ -29,7 +30,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_load(); unawaited(_load());
} }
@override @override
@ -44,7 +45,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
final states = await StorageService.instance.getAllExerciseStates(); final states = await StorageService.instance.getAllExerciseStates();
if (mounted) { if (mounted) {
setState(() { setState(() {
_states = states;
for (final s in states) { for (final s in states) {
_successThresholds[s.name] = s.successThreshold; _successThresholds[s.name] = s.successThreshold;
_failThresholds[s.name] = s.failThreshold; _failThresholds[s.name] = s.failThreshold;
@ -59,7 +59,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
setState(() => _weights[name] = value); setState(() => _weights[name] = value);
_weightTimers[name]?.cancel(); _weightTimers[name]?.cancel();
_weightTimers[name] = Timer(const Duration(milliseconds: 600), () { _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<SettingsScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
child: child: const Text(
const Text('Cancel', style: TextStyle(color: Colors.white70)), 'Cancel',
style: TextStyle(color: Colors.white70),
),
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: child: const Text(
const Text('Reset', style: TextStyle(color: Colors.redAccent)), 'Reset',
style: TextStyle(color: Colors.redAccent),
),
), ),
], ],
), ),
@ -113,10 +117,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
List<String> get _orderedNames { List<String> get _orderedNames {
final seen = <String>{}; final seen = <String>{};
return [...workoutA, ...workoutB] return [
.map((e) => e.name) ...workoutA,
.where(seen.add) ...workoutB,
.toList(); ].map((e) => e.name).where(seen.add).toList();
} }
@override @override

View File

@ -3,26 +3,40 @@
library; library;
import 'dart:async'; import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:vibration/vibration.dart';
import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/exercise_result.dart'; import 'package:workout_app/models/exercise_result.dart';
import 'package:workout_app/models/set_result.dart'; import 'package:workout_app/models/set_result.dart';
import 'package:workout_app/models/workout_session.dart'; import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/sync_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/exercise_tile.dart';
import 'package:workout_app/widgets/workout_summary_dialog.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 { class WorkoutScreen extends StatefulWidget {
/// Creates a [WorkoutScreen].
const WorkoutScreen({ const WorkoutScreen({
super.key,
required this.workoutType, required this.workoutType,
required this.exercises, required this.exercises,
super.key,
this.savedState, this.savedState,
}); });
/// 'A' or 'B' used for history and progression.
final String workoutType; final String workoutType;
/// Ordered list of exercises for this session.
final List<Exercise> exercises; final List<Exercise> exercises;
/// Serialized state to restore (crash-recovery); null for a fresh session.
final Map<String, dynamic>? savedState; final Map<String, dynamic>? savedState;
@override @override
@ -39,6 +53,18 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
Map<String, ExerciseState> _exerciseStates = {}; Map<String, ExerciseState> _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(); final _sync = SyncService();
bool _finished = false; bool _finished = false;
@ -54,7 +80,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) { _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
setState(() => _elapsed = DateTime.now().difference(_startTime)); setState(() => _elapsed = DateTime.now().difference(_startTime));
}); });
_loadExerciseStates(); unawaited(_loadExerciseStates());
} }
void _initFresh() { void _initFresh() {
@ -79,6 +105,22 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
.map((row) => (row as List).cast<int>()) .map((row) => (row as List).cast<int>())
.toList(); .toList();
_warmupTapped = (s['warmupTapped'] as List).cast<bool>(); _warmupTapped = (s['warmupTapped'] as List).cast<bool>();
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<void> _loadExerciseStates() async { Future<void> _loadExerciseStates() async {
@ -93,6 +135,8 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
@override @override
void dispose() { void dispose() {
_elapsedTimer.cancel(); _elapsedTimer.cancel();
_breakTimer?.cancel();
unawaited(_audio.dispose());
super.dispose(); super.dispose();
} }
@ -105,6 +149,15 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
'tapped': _tapped, 'tapped': _tapped,
'doneReps': _doneReps, 'doneReps': _doneReps,
'warmupTapped': _warmupTapped, '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<WorkoutScreen> {
bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t)); 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 // Interaction
void _tapCircle(int exIdx, int repIdx) { void _tapCircle(int exIdx, int setIdx) {
if (_finished) return; if (_finished) return;
final wasNotTapped = !_tapped[exIdx][setIdx];
if (wasNotTapped && _inBreak) return;
setState(() { setState(() {
if (!_tapped[exIdx][repIdx]) { if (wasNotTapped) {
_tapped[exIdx][repIdx] = true; _tapped[exIdx][setIdx] = true;
} else { } else {
_doneReps[exIdx][repIdx] = _doneReps[exIdx][setIdx] =
(_doneReps[exIdx][repIdx] - 1).clamp(0, 999); (_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) { void _tapWarmup(int exIdx) {
if (_finished || _warmupTapped[exIdx]) return; if (_finished || _warmupTapped[exIdx]) return;
setState(() => _warmupTapped[exIdx] = true); 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; if (_finished) return;
setState(() { setState(() {
_tapped[exIdx][repIdx] = false; _tapped[exIdx][setIdx] = false;
_doneReps[exIdx][repIdx] = widget.exercises[exIdx].reps; _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<void> _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<void> _onThresholdChanged( Future<void> _onThresholdChanged(
@ -191,8 +353,10 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
child: child: const Text(
const Text('Cancel', style: TextStyle(color: Colors.white70)), 'Cancel',
style: TextStyle(color: Colors.white70),
),
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
@ -223,13 +387,17 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
child: child: const Text(
const Text('Cancel', style: TextStyle(color: Colors.white70)), 'Cancel',
style: TextStyle(color: Colors.white70),
),
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: child: const Text(
const Text('Reset', style: TextStyle(color: Colors.redAccent)), 'Reset',
style: TextStyle(color: Colors.redAccent),
),
), ),
], ],
), ),
@ -242,14 +410,16 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
Future<void> _finishWorkout() async { Future<void> _finishWorkout() async {
_elapsedTimer.cancel(); _elapsedTimer.cancel();
_breakTimer?.cancel();
setState(() => _finished = true); setState(() => _finished = true);
final endTime = DateTime.now(); final endTime = DateTime.now();
final results = <ExerciseResult>[]; final results = <ExerciseResult>[];
for (int i = 0; i < widget.exercises.length; i++) { for (var i = 0; i < widget.exercises.length; i++) {
final ex = widget.exercises[i]; final ex = widget.exercises[i];
results.add(ExerciseResult( results.add(
ExerciseResult(
exercise: ex, exercise: ex,
warmupDone: _warmupTapped[i], warmupDone: _warmupTapped[i],
sets: List.generate( sets: List.generate(
@ -260,7 +430,8 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
weight: ex.weight, weight: ex.weight,
), ),
), ),
)); ),
);
} }
final session = WorkoutSession( final session = WorkoutSession(
@ -293,6 +464,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
final syncResult = await _sync.writeWorkoutResult(session); final syncResult = await _sync.writeWorkoutResult(session);
if (!mounted) return; if (!mounted) return;
unawaited(
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
@ -300,14 +472,17 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
session: session, session: session,
syncResult: syncResult, syncResult: syncResult,
), ),
),
); );
} }
// Build // Build
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopScope( 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 // ignore: avoid_redundant_argument_values
canPop: true, canPop: true,
child: Scaffold( child: Scaffold(
@ -322,7 +497,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
actions: [ actions: [
if (!_finished) if (!_finished)
TextButton( TextButton(
onPressed: () => _confirmReset(), onPressed: _confirmReset,
child: const Text( child: const Text(
'Reset', 'Reset',
style: TextStyle(color: Colors.redAccent), style: TextStyle(color: Colors.redAccent),
@ -334,15 +509,23 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
child: Text( child: Text(
'Finish', 'Finish',
style: TextStyle( style: TextStyle(
color: color: _allSetsCompleted ? Colors.greenAccent : Colors.grey,
_allSetsCompleted ? Colors.greenAccent : Colors.grey,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
), ),
], ],
), ),
body: ListView.separated( body: Column(
children: [
if (_inBreak)
BreakBanner(
breakRemaining: _breakRemaining,
breakLabel: _breakLabel,
onSkip: _skipBreak,
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
itemCount: widget.exercises.length, itemCount: widget.exercises.length,
separatorBuilder: (_, _) => const SizedBox(height: 8), separatorBuilder: (_, _) => const SizedBox(height: 8),
@ -365,6 +548,9 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
}, },
), ),
), ),
],
),
),
); );
} }
} }

View File

@ -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<bool> hasStoragePermission() async {
return Permission.manageExternalStorage.isGranted;
}
/// Requests MANAGE_EXTERNAL_STORAGE; opens the system settings page.
///
/// Returns true once granted.
Future<bool> 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<void> export(Map<String, dynamic> 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<Map<String, dynamic>?> readBackup() async {
try {
final f = File(kBackupPath);
if (!f.existsSync()) return null;
final raw = await f.readAsString();
return jsonDecode(raw) as Map<String, dynamic>;
} on Exception {
return null;
}
}
}

View File

@ -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. /// Port the HTTP server listens on. Must match the constant on the PC side.
const int kWorkoutServerPort = 8765; const int kWorkoutServerPort = 8765;
/// Singleton HTTP server that serves the latest workout JSON over LAN.
class HttpServerService { class HttpServerService {
HttpServerService._(); HttpServerService._();
/// Singleton instance.
static final HttpServerService instance = HttpServerService._(); static final HttpServerService instance = HttpServerService._();
HttpServer? _server; HttpServer? _server;
@ -38,14 +41,22 @@ class HttpServerService {
return addrs; 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<void> start() async { Future<void> start() async {
if (_server != null) return; // already running if (_server != null) return; // already running
await _loadFromDisk(); await _loadFromDisk();
try { try {
_server = await HttpServer.bind(InternetAddress.anyIPv4, kWorkoutServerPort); _server = await HttpServer.bind(
_serve(); InternetAddress.anyIPv4,
kWorkoutServerPort,
);
unawaited(_serve());
} on SocketException { } on SocketException {
// Port already in use or binding failed not fatal. // Port already in use or binding failed not fatal.
_server = null; _server = null;
@ -64,7 +75,7 @@ class HttpServerService {
} }
for (final path in candidates) { for (final path in candidates) {
final file = File(path); final file = File(path);
if (await file.exists()) { if (file.existsSync()) {
try { try {
_latestJson = await file.readAsString(); _latestJson = await file.readAsString();
return; return;
@ -97,6 +108,7 @@ class HttpServerService {
} }
} }
/// Stops the HTTP server.
Future<void> stop() async { Future<void> stop() async {
await _server?.close(force: true); await _server?.close(force: true);
_server = null; _server = null;

View File

@ -1,14 +1,18 @@
/// Persistent storage for exercise progression state using SQLite. /// Persistent storage for exercise progression state using SQLite.
library; library;
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/services/backup_service.dart';
/// Per-exercise progression state stored in SQLite. /// Per-exercise progression state stored in SQLite.
class ExerciseState { class ExerciseState {
/// Creates an [ExerciseState] with all required progression fields.
ExerciseState({ ExerciseState({
required this.name, required this.name,
required this.weight, required this.weight,
@ -20,23 +24,42 @@ class ExerciseState {
required this.failThreshold, required this.failThreshold,
}); });
/// Exercise name (matches [Exercise.name], used as primary key).
final String name; final String name;
/// Current working weight in kg.
double weight; double weight;
/// Current target reps per set.
int reps; int reps;
/// Consecutive successful workouts since last progression.
int successStreak; int successStreak;
/// Consecutive failed workouts since last regression.
int failStreak; int failStreak;
/// Weight cap; reps increase instead of weight when this is reached.
final double maxWeight; final double maxWeight;
/// Successes needed in a row before weight/reps increase.
int successThreshold; int successThreshold;
/// Failures needed in a row before weight decreases.
int failThreshold; int failThreshold;
} }
/// Singleton SQLite service for workout data persistence.
class StorageService { class StorageService {
StorageService._(); StorageService._();
static StorageService? _instance; static StorageService? _instance;
/// Returns the initialized singleton; throws if [init] was not called first.
static StorageService get instance => _instance!; static StorageService get instance => _instance!;
late Database _db; late Database _db;
/// Initializes the singleton and opens the database (idempotent).
static Future<StorageService> init() async { static Future<StorageService> init() async {
if (_instance != null) return _instance!; if (_instance != null) return _instance!;
final svc = StorageService._(); final svc = StorageService._();
@ -45,8 +68,22 @@ class StorageService {
return svc; 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<void> _open() async { Future<void> _open() async {
final dbPath = p.join(await getDatabasesPath(), 'workout_app.db'); final dbPath =
_testDbPath ?? p.join(await getDatabasesPath(), 'workout_app.db');
_db = await openDatabase( _db = await openDatabase(
dbPath, dbPath,
version: 3, version: 3,
@ -100,15 +137,18 @@ class StorageService {
) async { ) async {
if (oldVersion < 2) { if (oldVersion < 2) {
await db.execute( 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( 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) { if (oldVersion < 3) {
await db.execute( 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( await db.execute(
'CREATE TABLE IF NOT EXISTS active_session ' 'CREATE TABLE IF NOT EXISTS active_session '
@ -147,7 +187,7 @@ class StorageService {
where: 'key = ?', where: 'key = ?',
whereArgs: [key], whereArgs: [key],
); );
return rows.isEmpty ? null : rows.first['value'] as String; return rows.isEmpty ? null : rows.first['value']! as String;
} }
Future<void> _setSetting(String key, String value) async { Future<void> _setSetting(String key, String value) async {
@ -158,17 +198,21 @@ class StorageService {
); );
} }
/// Returns 'A' or 'B' the type that should be done next.
Future<String> getNextWorkoutType() async { Future<String> getNextWorkoutType() async {
final last = await _getSetting('last_workout_type'); final last = await _getSetting('last_workout_type');
return last == 'A' ? 'B' : 'A'; return last == 'A' ? 'B' : 'A';
} }
/// Persists [type] as the most recently completed workout type.
Future<void> setLastWorkoutType(String type) async { Future<void> setLastWorkoutType(String type) async {
await _setSetting('last_workout_type', type); await _setSetting('last_workout_type', type);
unawaited(_backupNow());
} }
// Active session (crash / exit recovery) // Active session (crash / exit recovery)
/// Persists [data] as the currently active (in-progress) session.
Future<void> saveActiveSession(Map<String, dynamic> data) async { Future<void> saveActiveSession(Map<String, dynamic> data) async {
await _db.insert( await _db.insert(
'active_session', 'active_session',
@ -177,18 +221,21 @@ class StorageService {
); );
} }
/// Returns the saved active session, or null if none exists.
Future<Map<String, dynamic>?> loadActiveSession() async { Future<Map<String, dynamic>?> loadActiveSession() async {
final rows = await _db.query('active_session', where: 'id = 1'); final rows = await _db.query('active_session', where: 'id = 1');
if (rows.isEmpty) return null; if (rows.isEmpty) return null;
return jsonDecode(rows.first['json'] as String) as Map<String, dynamic>; return jsonDecode(rows.first['json']! as String) as Map<String, dynamic>;
} }
/// Removes the active session record (called after a session is committed).
Future<void> clearActiveSession() async { Future<void> clearActiveSession() async {
await _db.delete('active_session', where: 'id = 1'); await _db.delete('active_session', where: 'id = 1');
} }
// Exercise state // Exercise state
/// Returns the progression state for [name], or null if not found.
Future<ExerciseState?> getExerciseState(String name) async { Future<ExerciseState?> getExerciseState(String name) async {
final rows = await _db.query( final rows = await _db.query(
'exercise_state', 'exercise_state',
@ -198,17 +245,18 @@ class StorageService {
if (rows.isEmpty) return null; if (rows.isEmpty) return null;
final r = rows.first; final r = rows.first;
return ExerciseState( return ExerciseState(
name: r['name'] as String, name: r['name']! as String,
weight: r['weight'] as double, weight: r['weight']! as double,
reps: r['reps'] as int, reps: r['reps']! as int,
successStreak: r['success_streak'] as int, successStreak: r['success_streak']! as int,
failStreak: r['fail_streak'] as int, failStreak: r['fail_streak']! as int,
maxWeight: r['max_weight'] as double, maxWeight: r['max_weight']! as double,
successThreshold: r['success_threshold'] as int? ?? 3, successThreshold: r['success_threshold'] as int? ?? 3,
failThreshold: r['fail_threshold'] as int? ?? 2, failThreshold: r['fail_threshold'] as int? ?? 2,
); );
} }
/// Returns progression states for every exercise across both plans.
Future<List<ExerciseState>> getAllExerciseStates() async { Future<List<ExerciseState>> getAllExerciseStates() async {
final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet(); final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet();
final states = <ExerciseState>[]; final states = <ExerciseState>[];
@ -219,6 +267,7 @@ class StorageService {
return states; return states;
} }
/// Updates the streak thresholds for exercise [name].
Future<void> setExerciseThresholds( Future<void> setExerciseThresholds(
String name, { String name, {
required int successThreshold, required int successThreshold,
@ -235,6 +284,7 @@ class StorageService {
); );
} }
/// Sets the working weight for [name], resetting streaks.
Future<void> setExerciseWeight(String name, double weight) async { Future<void> setExerciseWeight(String name, double weight) async {
await _db.update( await _db.update(
'exercise_state', 'exercise_state',
@ -242,8 +292,10 @@ class StorageService {
where: 'name = ?', where: 'name = ?',
whereArgs: [name], whereArgs: [name],
); );
unawaited(_backupNow());
} }
/// Returns exercises for [workoutType] with weights/reps from stored state.
Future<List<Exercise>> getCurrentExercises(String workoutType) async { Future<List<Exercise>> getCurrentExercises(String workoutType) async {
final template = workoutType == 'A' ? workoutA : workoutB; final template = workoutType == 'A' ? workoutA : workoutB;
final result = <Exercise>[]; final result = <Exercise>[];
@ -258,6 +310,7 @@ class StorageService {
return result; return result;
} }
/// Applies progressive overload or regression based on [succeededExercises].
Future<void> applyProgression({ Future<void> applyProgression({
required Map<String, bool> succeededExercises, required Map<String, bool> succeededExercises,
required DateTime lastWorkoutDate, required DateTime lastWorkoutDate,
@ -270,8 +323,10 @@ class StorageService {
if (state == null) continue; if (state == null) continue;
if (hadBreak) { if (hadBreak) {
final newWeight = final newWeight = (state.weight - kWeightIncrement).clamp(
(state.weight - kWeightIncrement).clamp(0.0, state.maxWeight); 0.0,
state.maxWeight,
);
await _db.update( await _db.update(
'exercise_state', 'exercise_state',
{'weight': newWeight, 'success_streak': 0, 'fail_streak': 0}, {'weight': newWeight, 'success_streak': 0, 'fail_streak': 0},
@ -284,15 +339,17 @@ class StorageService {
if (entry.value) { if (entry.value) {
final newStreak = state.successStreak + 1; final newStreak = state.successStreak + 1;
final shouldProgress = newStreak >= state.successThreshold; final shouldProgress = newStreak >= state.successThreshold;
double newWeight = state.weight; var newWeight = state.weight;
int newReps = state.reps; var newReps = state.reps;
if (shouldProgress) { if (shouldProgress) {
if (state.weight >= state.maxWeight) { if (state.weight >= state.maxWeight) {
newReps = state.reps + 1; newReps = state.reps + 1;
} else { } else {
newWeight = newWeight = (state.weight + kWeightIncrement).clamp(
(state.weight + kWeightIncrement).clamp(0.0, state.maxWeight); 0.0,
state.maxWeight,
);
} }
} }
@ -328,6 +385,7 @@ class StorageService {
} }
} }
/// Persists a completed session to the workout history table.
Future<void> saveSession({ Future<void> saveSession({
required String date, required String date,
required String workoutType, required String workoutType,
@ -342,16 +400,19 @@ class StorageService {
'succeeded': succeeded ? 1 : 0, 'succeeded': succeeded ? 1 : 0,
'json': json, 'json': json,
}); });
unawaited(_backupNow());
} }
/// Returns the date of the most recent completed session, or null.
Future<DateTime?> getLastWorkoutDate() async { Future<DateTime?> getLastWorkoutDate() async {
final rows = await _db.rawQuery( final rows = await _db.rawQuery(
'SELECT date FROM workout_history ORDER BY date DESC LIMIT 1', 'SELECT date FROM workout_history ORDER BY date DESC LIMIT 1',
); );
if (rows.isEmpty) return null; 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<List<Map<String, dynamic>>> getWorkoutHistory({ Future<List<Map<String, dynamic>>> getWorkoutHistory({
int limit = 60, int limit = 60,
}) async { }) async {
@ -362,13 +423,15 @@ class StorageService {
); );
} }
/// Returns all distinct workout dates (YYYY-MM-DD), newest first.
Future<List<String>> getAllWorkoutDates() async { Future<List<String>> getAllWorkoutDates() async {
final rows = await _db.rawQuery( final rows = await _db.rawQuery(
'SELECT DISTINCT date FROM workout_history ORDER BY date DESC', '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<void> resetExerciseToDefaults(String name) async { Future<void> resetExerciseToDefaults(String name) async {
final defaults = [...workoutA, ...workoutB].firstWhere( final defaults = [...workoutA, ...workoutB].firstWhere(
(e) => e.name == name, (e) => e.name == name,
@ -386,5 +449,62 @@ class StorageService {
where: 'name = ?', where: 'name = ?',
whereArgs: [name], whereArgs: [name],
); );
unawaited(_backupNow());
}
// Backup / restore
/// Exports all persistent data to external storage as a JSON snapshot.
Future<void> _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<void> 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<Map<String, dynamic>>()) {
await txn.insert(
'exercise_state',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
for (final row in (backup['workout_history'] as List? ?? [])
.cast<Map<String, dynamic>>()) {
await txn.insert(
'workout_history',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
for (final row in (backup['settings'] as List? ?? [])
.cast<Map<String, dynamic>>()) {
await txn.insert(
'settings',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
});
} }
} }

View File

@ -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; library;
import 'dart:io'; 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/models/workout_session.dart';
import 'package:workout_app/services/http_server_service.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'; const String kSyncFilePath = '/sdcard/workout_result.json';
/// Handles writing completed workout sessions to disk and the HTTP server.
class SyncService { class SyncService {
/// Writes [session] as JSON to external storage and updates the HTTP server. /// Writes [session] as JSON to external storage and updates the HTTP server.
/// ///
@ -17,13 +18,13 @@ class SyncService {
final json = session.toJsonString(); final json = session.toJsonString();
// Always update the in-app HTTP server so the PC can read via WiFi. // 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 the primary path first (/sdcard/ ADB-accessible without root).
try { try {
final file = File(kSyncFilePath); final file = File(kSyncFilePath);
await file.writeAsString(json); await file.writeAsString(json);
return SyncResult(success: true, path: kSyncFilePath); return const SyncResult(success: true, path: kSyncFilePath);
} on Exception { } on Exception {
// Fallback: app-specific external directory (still ADB accessible). // Fallback: app-specific external directory (still ADB accessible).
} }
@ -39,14 +40,25 @@ class SyncService {
// Fallback failed. // 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 { class SyncResult {
/// Creates a sync result.
const SyncResult({required this.success, required this.path, this.error}); const SyncResult({required this.success, required this.path, this.error});
/// Whether the write succeeded.
final bool success; final bool success;
/// Absolute path where the file was written, or null on failure.
final String? path; final String? path;
/// Human-readable error message on failure.
final String? error; final String? error;
} }

View File

@ -3,16 +3,23 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Banner widget showing a break countdown and a skip button.
class BreakBanner extends StatelessWidget { class BreakBanner extends StatelessWidget {
/// Creates a [BreakBanner].
const BreakBanner({ const BreakBanner({
super.key,
required this.breakRemaining, required this.breakRemaining,
required this.breakLabel, required this.breakLabel,
required this.onSkip, required this.onSkip,
super.key,
}); });
/// Seconds remaining in the current break.
final int breakRemaining; final int breakRemaining;
/// Display label for the break (e.g. 'Rest' or 'Warmup rest').
final String breakLabel; final String breakLabel;
/// Called when the user taps the Skip button.
final VoidCallback onSkip; final VoidCallback onSkip;
String _fmt(int secs) { String _fmt(int secs) {

View File

@ -3,27 +3,44 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Monthly calendar widget that highlights days with completed workouts.
class WorkoutCalendar extends StatelessWidget { class WorkoutCalendar extends StatelessWidget {
/// Creates a [WorkoutCalendar].
const WorkoutCalendar({ const WorkoutCalendar({
super.key,
required this.workoutDates, required this.workoutDates,
required this.month, required this.month,
required this.onPrevMonth, required this.onPrevMonth,
required this.onNextMonth, required this.onNextMonth,
super.key,
}); });
/// Set of YYYY-MM-DD date strings that had at least one workout.
final Set<String> workoutDates; final Set<String> workoutDates;
/// Only the year and month of this DateTime are used. /// Only the year and month of this DateTime are used.
final DateTime month; final DateTime month;
/// Called when the user taps the previous-month chevron.
final VoidCallback onPrevMonth; final VoidCallback onPrevMonth;
/// Called when the user taps the next-month chevron.
final VoidCallback onNextMonth; final VoidCallback onNextMonth;
static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
static const _monthNames = [ static const _monthNames = [
'January', 'February', 'March', 'April', 'May', 'June', 'January',
'July', 'August', 'September', 'October', 'November', 'December', 'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]; ];
String _dateKey(int year, int m, int day) => String _dateKey(int year, int m, int day) =>
@ -35,7 +52,7 @@ class WorkoutCalendar extends StatelessWidget {
final m = month.month; final m = month.month;
final daysInMonth = DateTime(year, m + 1, 0).day; final daysInMonth = DateTime(year, m + 1, 0).day;
// weekday: 1=Mon..7=Sun offset 0..6 // 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 totalCells = firstWeekday + daysInMonth;
final rows = (totalCells / 7).ceil(); final rows = (totalCells / 7).ceil();
@ -123,8 +140,7 @@ class WorkoutCalendar extends StatelessWidget {
style: TextStyle( style: TextStyle(
color: worked ? Colors.white : Colors.white38, color: worked ? Colors.white : Colors.white38,
fontSize: 12, fontSize: 12,
fontWeight: fontWeight: worked ? FontWeight.bold : FontWeight.normal,
worked ? FontWeight.bold : FontWeight.normal,
), ),
), ),
); );

View File

@ -5,9 +5,10 @@ import 'package:flutter/material.dart';
import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/widgets/rep_circle.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 { class ExerciseTile extends StatelessWidget {
/// Creates an [ExerciseTile].
const ExerciseTile({ const ExerciseTile({
super.key,
required this.exercise, required this.exercise,
required this.tapped, required this.tapped,
required this.doneReps, required this.doneReps,
@ -18,19 +19,37 @@ class ExerciseTile extends StatelessWidget {
required this.onLongPressCircle, required this.onLongPressCircle,
required this.onTapWarmup, required this.onTapWarmup,
required this.onThresholdChanged, required this.onThresholdChanged,
super.key,
}); });
/// The exercise definition to display.
final Exercise exercise; final Exercise exercise;
/// Per-set tap state; true when a set circle has been tapped.
final List<bool> tapped; final List<bool> tapped;
/// Per-set rep count; may be less than target after repeated taps.
final List<int> doneReps; final List<int> doneReps;
/// Whether the warmup circle has been tapped.
final bool warmupTapped; final bool warmupTapped;
/// Success streak threshold shown in the inline settings row.
final int successThreshold; final int successThreshold;
/// Fail streak threshold shown in the inline settings row.
final int failThreshold; final int failThreshold;
/// Called when a working-set circle is tapped.
final void Function(int setIdx) onTapCircle; final void Function(int setIdx) onTapCircle;
/// Called when a working-set circle is long-pressed (resets to neutral).
final void Function(int setIdx) onLongPressCircle; final void Function(int setIdx) onLongPressCircle;
/// Called when the warmup circle is tapped.
final VoidCallback onTapWarmup; 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; final void Function(int success, int fail) onThresholdChanged;
bool get _allCompleted => tapped.every((t) => t); bool get _allCompleted => tapped.every((t) => t);
@ -40,10 +59,9 @@ class ExerciseTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Color headerColor = Colors.grey.shade800; var headerColor = Colors.grey.shade800;
if (_allCompleted) { if (_allCompleted) {
headerColor = headerColor = _allSucceeded ? Colors.green.shade800 : Colors.red.shade900;
_allSucceeded ? Colors.green.shade800 : Colors.red.shade900;
} }
return Card( return Card(
@ -72,12 +90,14 @@ class ExerciseTile extends StatelessWidget {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (exercise.hasWarmup) ...[
_WarmupRow( _WarmupRow(
warmupWeight: exercise.warmupWeight, warmupWeight: exercise.warmupWeight,
tapped: warmupTapped, tapped: warmupTapped,
onTap: onTapWarmup, onTap: onTapWarmup,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
],
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,

View File

@ -7,34 +7,53 @@
/// failed red, shows 0 (all reps deducted) /// failed red, shows 0 (all reps deducted)
/// ///
/// Interaction: /// Interaction:
/// single tap neutralsuccess, successpartial(-1 rep), partialpartial(-1 rep), /// single tap neutralsuccess, successpartial(-1 rep),
/// failed stays failed /// partialpartial(-1 rep), failed stays failed
/// long press reset to neutral /// long press reset to neutral
library; library;
import 'package:flutter/material.dart'; 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 { class RepCircle extends StatelessWidget {
/// Creates a [RepCircle].
const RepCircle({ const RepCircle({
super.key,
required this.targetReps, required this.targetReps,
required this.doneReps, required this.doneReps,
required this.tapped, required this.tapped,
required this.onTap, required this.onTap,
required this.onLongPress, required this.onLongPress,
super.key,
}); });
/// Number of reps the user is aiming for this set.
final int targetReps; 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; 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; final bool tapped;
/// Called on a single tap.
final VoidCallback onTap; final VoidCallback onTap;
/// Called on a long press (resets to neutral).
final VoidCallback onLongPress; final VoidCallback onLongPress;
RepCircleState get _state { RepCircleState get _state {

View File

@ -5,14 +5,19 @@ import 'package:flutter/material.dart';
import 'package:workout_app/models/workout_session.dart'; import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/sync_service.dart'; import 'package:workout_app/services/sync_service.dart';
/// Dialog that summarises a completed workout and reports the sync status.
class WorkoutSummaryDialog extends StatelessWidget { class WorkoutSummaryDialog extends StatelessWidget {
/// Creates a [WorkoutSummaryDialog].
const WorkoutSummaryDialog({ const WorkoutSummaryDialog({
super.key,
required this.session, required this.session,
required this.syncResult, required this.syncResult,
super.key,
}); });
/// The completed workout session to summarise.
final WorkoutSession session; final WorkoutSession session;
/// Result of writing the session to disk/HTTP server.
final SyncResult syncResult; final SyncResult syncResult;
String _fmt(Duration d) { String _fmt(Duration d) {

View File

@ -200,6 +200,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
@ -304,6 +312,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" 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: objective_c:
dependency: transitive dependency: transitive
description: description:
@ -545,10 +561,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqflite_common name: sqflite_common
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" sha256: cce558075afe2a83f3fd7fc123acd6b090683e4f23910d44fbb31ecd7800b014
url: "https://pub.dev" url: "https://pub.dev"
source: hosted 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: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
@ -565,6 +589,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
url: "https://pub.dev"
source: hosted
version: "3.3.2"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -593,10 +625,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: synchronized name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.0+1" version: "3.4.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -637,6 +669,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: vibration:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -8,20 +8,22 @@ environment:
sdk: ^3.12.0 sdk: ^3.12.0
dependencies: dependencies:
audioplayers: ^6.4.0
flutter: flutter:
sdk: flutter sdk: flutter
path: ^1.9.1 path: ^1.9.1
sqflite: ^2.4.2
path_provider: ^2.1.5 path_provider: ^2.1.5
shared_preferences: ^2.5.3
audioplayers: ^6.4.0
vibration: ^3.1.0
permission_handler: ^12.0.0 permission_handler: ^12.0.0
shared_preferences: ^2.5.3
sqflite: ^2.4.2
vibration: ^3.1.0
dev_dependencies: dev_dependencies:
flutter_lints: ^6.0.0
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 sqflite_common_ffi: ^2.4.1
very_good_analysis: ^10.2.0
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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<String, dynamic>;
expect(decoded['workout_type'], 'B');
});
});
}

View File

@ -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<void> _pump(WidgetTester tester, Widget w) async {
await tester.runAsync(() async {
await tester.pumpWidget(w);
await Future<void>.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);
});
}

View File

@ -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<void> _pump(WidgetTester tester, Widget w) async {
await tester.runAsync(() async {
await tester.pumpWidget(w);
// Small real delay lets sqflite + NetworkInterface.list complete.
await Future<void>.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);
});
}

View File

@ -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<void> _pump(WidgetTester tester, Widget w) async {
await tester.runAsync(() async {
await tester.pumpWidget(w);
await Future<void>.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<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump();
final state =
await StorageService.instance.getExerciseState(workoutA.first.name);
expect(state!.weight, workoutA.first.weight);
});
}

View File

@ -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<Exercise> exercises = _exercises,
Map<String, dynamic>? savedState,
}) =>
MaterialApp(
home: WorkoutScreen(
workoutType: type,
exercises: exercises,
savedState: savedState,
),
);
void main() {
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
StorageService.resetForTesting();
await StorageService.init();
});
Future<void> _pump(WidgetTester tester, Widget w) async {
await tester.runAsync(() async {
await tester.pumpWidget(w);
await Future<void>.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<TextButton>(
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<TextButton>(
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);
});
}

View File

@ -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<List<String>>());
});
test('kWorkoutServerPort has expected value', () {
expect(kWorkoutServerPort, 8765);
});
});
}

View File

@ -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);
});
}

View File

@ -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<SyncResult>());
// 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');
});
});
}

View File

@ -1,2 +1,26 @@
// Tests are written after the user approves functionality per project rules. import 'package:flutter/material.dart';
void main() {} 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<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump();
expect(find.text('Workout Tracker'), findsOneWidget);
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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<bool>? tapped,
List<int>? 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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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.
});
});
}