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
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
include: package:very_good_analysis/analysis_options.yaml
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
errors:
# Promote key lints to errors so they fail CI, not just warn.
missing_required_param: error
unnecessary_null_comparison: error
dead_code: error
invalid_annotation_target: error
exclude:
- build/**
- "**/*.g.dart"
- "**/*.freezed.dart"
- "test/**"
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
# very_good_analysis enables most rules; add extras it doesn't include.
always_use_package_imports: true
avoid_print: true
avoid_relative_lib_imports: true
cancel_subscriptions: true
close_sinks: true
comment_references: false # conflicts with typical Flutter API docs style
directives_ordering: true
lines_longer_than_80_chars: true
public_member_api_docs: true
unawaited_futures: true

View File

@ -4,6 +4,9 @@
android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
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" />
<!-- Wake lock keeps timer running in background -->
<uses-permission android:name="android.permission.WAKE_LOCK" />

View File

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

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;
/// Default weight cap: above this, reps increase instead of weight.
const double kDefaultMaxWeight = 27.5;
/// Weight increment used for progression steps (kg).
const double kWeightIncrement = 2.5;
/// Immutable definition of a single exercise and its current target state.
class Exercise {
/// Creates an exercise with the given parameters.
const Exercise({
required this.name,
required this.sets,
required this.reps,
required this.weight,
this.maxWeight = kDefaultMaxWeight,
this.hasWarmup = true,
});
/// Deserializes an exercise from a JSON map.
factory Exercise.fromJson(Map<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;
/// Number of working sets per session.
final int sets;
/// Target reps per set.
final int reps;
/// Current working weight in kg.
final double weight;
/// Weight cap beyond which reps increase instead of weight.
final double maxWeight;
/// Whether a warmup set should be shown for this exercise.
final bool hasWarmup;
/// Warmup weight: 4/5 of target weight, rounded DOWN to nearest 2.5 kg.
double get warmupWeight {
final raw = weight * 4.0 / 5.0;
return (raw / kWeightIncrement).floor() * kWeightIncrement;
}
/// Returns a copy of this exercise with the given fields replaced.
Exercise copyWith({
String? name,
int? sets,
int? reps,
double? weight,
double? maxWeight,
bool? hasWarmup,
}) {
return Exercise(
name: name ?? this.name,
@ -40,22 +68,17 @@ class Exercise {
reps: reps ?? this.reps,
weight: weight ?? this.weight,
maxWeight: maxWeight ?? this.maxWeight,
hasWarmup: hasWarmup ?? this.hasWarmup,
);
}
/// Serializes this exercise to a JSON map.
Map<String, dynamic> toJson() => {
'name': name,
'sets': sets,
'reps': reps,
'weight': weight,
'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/set_result.dart';
/// Aggregated results for a single exercise across all its sets in a session.
class ExerciseResult {
/// Creates a result for [exercise] with the given [sets] outcomes.
const ExerciseResult({
required this.exercise,
required this.sets,
this.warmupDone = false,
});
/// The exercise definition this result belongs to.
final Exercise exercise;
/// Results for each individual set.
final List<SetResult> sets;
/// Whether the warmup set was completed before the working sets.
final bool warmupDone;
/// True when every set was fully completed.
bool get succeeded => sets.isNotEmpty && sets.every((s) => s.succeeded);
/// Serializes this exercise result to a JSON map.
Map<String, dynamic> toJson() => {
'name': exercise.name,
'targetSets': exercise.sets,

View File

@ -1,39 +1,46 @@
/// Result of a single set during a workout session.
library;
/// Immutable result of one set, recording target vs actual reps.
class SetResult {
/// Creates a set result.
const SetResult({
required this.targetReps,
required this.doneReps,
required this.weight,
});
/// Deserializes a set result from a JSON map.
factory SetResult.fromJson(Map<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;
/// How many reps the user actually completed (may be < targetReps on failure).
/// Reps actually completed (may be less than [targetReps] on failure).
final int doneReps;
/// Weight used for this set in kg.
final double weight;
/// True when the user completed every target rep.
bool get succeeded => doneReps >= targetReps;
/// Returns a copy with [doneReps] replaced.
SetResult copyWith({int? doneReps}) => SetResult(
targetReps: targetReps,
doneReps: doneReps ?? this.doneReps,
weight: weight,
);
/// Serializes this set result to a JSON map.
Map<String, dynamic> toJson() => {
'targetReps': targetReps,
'doneReps': doneReps,
'weight': weight,
'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';
/// Situp has a lower max weight cap.
const double kSitupMaxWeight = 10.0;
const double kSitupMaxWeight = 10;
/// Plan A: lower-body and push/pull focus.
final workoutA = [
const Exercise(name: 'Dumbbell Lunge', sets: 5, reps: 12, weight: 7.5),
const Exercise(name: 'Dumbbell Bench Press', sets: 5, reps: 12, weight: 22.5),
const Exercise(
name: 'Dumbbell Bench Press',
sets: 5,
reps: 12,
weight: 22.5,
),
const Exercise(name: 'Dumbbell Row', sets: 4, reps: 6, weight: 22.5),
const Exercise(name: 'Dumbbell Curl', sets: 3, reps: 12, weight: 12.5),
];
/// Plan B: posterior chain, overhead, and core focus.
final workoutB = [
const Exercise(
name: 'Dumbbell Romanian Deadlift',
@ -26,12 +33,18 @@ final workoutB = [
reps: 12,
weight: 7.5,
),
const Exercise(name: 'Dumbbell Bench Press', sets: 5, reps: 12, weight: 22.5),
const Exercise(
name: 'Dumbbell Bench Press',
sets: 5,
reps: 12,
weight: 22.5,
),
const Exercise(
name: 'Situp',
sets: 3,
reps: 30,
weight: 10.0,
weight: 10,
maxWeight: kSitupMaxWeight,
hasWarmup: false,
),
];

View File

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

View File

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

View File

@ -1,6 +1,7 @@
/// Home screen: auto-resumes an active session, shows done-today status.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/screens/history_screen.dart';
@ -9,7 +10,9 @@ import 'package:workout_app/screens/workout_screen.dart';
import 'package:workout_app/services/http_server_service.dart';
import 'package:workout_app/services/storage_service.dart';
/// Home screen: auto-resumes active sessions and shows done-today status.
class HomeScreen extends StatefulWidget {
/// Creates a [HomeScreen].
const HomeScreen({super.key});
@override
@ -17,7 +20,7 @@ class HomeScreen extends StatefulWidget {
}
class _HomeScreenState extends State<HomeScreen> {
List<Exercise>? _exercises;
late List<Exercise> _exercises;
String _nextType = 'A';
List<String> _serverAddresses = [];
bool _loading = true;
@ -31,7 +34,7 @@ class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
_load();
unawaited(_load());
}
Future<void> _load() async {
@ -42,7 +45,8 @@ class _HomeScreenState extends State<HomeScreen> {
final addrs = await HttpServerService.instance.localAddresses;
final lastDate = await storage.getLastWorkoutDate();
final today = DateTime.now();
final doneToday = lastDate != null &&
final doneToday =
lastDate != null &&
lastDate.year == today.year &&
lastDate.month == today.month &&
lastDate.day == today.day;
@ -61,7 +65,7 @@ class _HomeScreenState extends State<HomeScreen> {
if (saved != null && !_hasAutoResumed) {
_hasAutoResumed = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _openWorkout(resume: true);
if (mounted) unawaited(_openWorkout(resume: true));
});
}
}
@ -70,8 +74,8 @@ class _HomeScreenState extends State<HomeScreen> {
Future<void> _openWorkout({bool resume = false}) async {
final storage = StorageService.instance;
Map<String, dynamic>? savedState;
String type = _nextType;
List<Exercise> exercises = _exercises!;
var type = _nextType;
var exercises = _exercises;
if (resume && _savedSession != null) {
savedState = _savedSession;
@ -90,7 +94,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
),
);
_load();
unawaited(_load());
}
@override
@ -118,7 +122,7 @@ class _HomeScreenState extends State<HomeScreen> {
builder: (_) => const SettingsScreen(),
),
);
_load();
unawaited(_load());
},
),
],
@ -132,10 +136,10 @@ class _HomeScreenState extends State<HomeScreen> {
children: [
_WorkoutCard(
type: _nextType,
exercises: _exercises!,
exercises: _exercises,
doneToday: _doneToday,
hasActiveSession: _savedSession != null,
onStart: () => _openWorkout(resume: false),
onStart: _openWorkout,
onResume: () => _openWorkout(resume: true),
),
const SizedBox(height: 20),
@ -147,7 +151,7 @@ class _HomeScreenState extends State<HomeScreen> {
}
}
// Sub-widgets
// Sub-widgets
class _WorkoutCard extends StatelessWidget {
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/services/storage_service.dart';
/// Screen for editing per-exercise thresholds and manual weight overrides.
class SettingsScreen extends StatefulWidget {
/// Creates a [SettingsScreen].
const SettingsScreen({super.key});
@override
@ -16,7 +18,6 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> {
List<ExerciseState>? _states;
bool _loading = true;
final Map<String, int> _successThresholds = {};
@ -29,7 +30,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
void initState() {
super.initState();
_load();
unawaited(_load());
}
@override
@ -44,7 +45,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
final states = await StorageService.instance.getAllExerciseStates();
if (mounted) {
setState(() {
_states = states;
for (final s in states) {
_successThresholds[s.name] = s.successThreshold;
_failThresholds[s.name] = s.failThreshold;
@ -59,7 +59,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
setState(() => _weights[name] = value);
_weightTimers[name]?.cancel();
_weightTimers[name] = Timer(const Duration(milliseconds: 600), () {
StorageService.instance.setExerciseWeight(name, value);
unawaited(StorageService.instance.setExerciseWeight(name, value));
});
}
@ -92,13 +92,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child:
const Text('Cancel', style: TextStyle(color: Colors.white70)),
child: const Text(
'Cancel',
style: TextStyle(color: Colors.white70),
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child:
const Text('Reset', style: TextStyle(color: Colors.redAccent)),
child: const Text(
'Reset',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
@ -113,10 +117,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
List<String> get _orderedNames {
final seen = <String>{};
return [...workoutA, ...workoutB]
.map((e) => e.name)
.where(seen.add)
.toList();
return [
...workoutA,
...workoutB,
].map((e) => e.name).where(seen.add).toList();
}
@override

View File

@ -3,26 +3,40 @@
library;
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:vibration/vibration.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/exercise_result.dart';
import 'package:workout_app/models/set_result.dart';
import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/sync_service.dart';
import 'package:workout_app/widgets/break_banner.dart';
import 'package:workout_app/widgets/exercise_tile.dart';
import 'package:workout_app/widgets/workout_summary_dialog.dart';
const _successBreakSecs = 180; // 3 min after successful set
const _failBreakSecs = 300; // 5 min after failed set
const _warmupBreakSecs = 180; // 3 min after warmup
/// Screen that drives an active workout session with per-rep tracking.
class WorkoutScreen extends StatefulWidget {
/// Creates a [WorkoutScreen].
const WorkoutScreen({
super.key,
required this.workoutType,
required this.exercises,
super.key,
this.savedState,
});
/// 'A' or 'B' used for history and progression.
final String workoutType;
/// Ordered list of exercises for this session.
final List<Exercise> exercises;
/// Serialized state to restore (crash-recovery); null for a fresh session.
final Map<String, dynamic>? savedState;
@override
@ -39,6 +53,18 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
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();
bool _finished = false;
@ -54,7 +80,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
setState(() => _elapsed = DateTime.now().difference(_startTime));
});
_loadExerciseStates();
unawaited(_loadExerciseStates());
}
void _initFresh() {
@ -79,6 +105,22 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
.map((row) => (row as List).cast<int>())
.toList();
_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 {
@ -93,6 +135,8 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
@override
void dispose() {
_elapsedTimer.cancel();
_breakTimer?.cancel();
unawaited(_audio.dispose());
super.dispose();
}
@ -105,6 +149,15 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
'tapped': _tapped,
'doneReps': _doneReps,
'warmupTapped': _warmupTapped,
'breakForExIdx': _breakForExIdx,
'breakForSetIdx': _breakForSetIdx,
'breakLabel': _breakLabel,
'breakDurationSecs': _breakDurationSecs,
'breakEndMs': _breakStartTime != null
? _breakStartTime!
.add(Duration(seconds: _breakDurationSecs))
.millisecondsSinceEpoch
: 0,
});
}
@ -118,34 +171,143 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t));
/// True when [setIdx] is the last untapped set of exercise [exIdx].
bool _isLastSetOfExercise(int exIdx, int setIdx) {
final sets = widget.exercises[exIdx].sets;
for (var s = 0; s < sets; s++) {
if (s != setIdx && !_tapped[exIdx][s]) return false;
}
return true;
}
// Interaction
void _tapCircle(int exIdx, int repIdx) {
void _tapCircle(int exIdx, int setIdx) {
if (_finished) return;
final wasNotTapped = !_tapped[exIdx][setIdx];
if (wasNotTapped && _inBreak) return;
setState(() {
if (!_tapped[exIdx][repIdx]) {
_tapped[exIdx][repIdx] = true;
if (wasNotTapped) {
_tapped[exIdx][setIdx] = true;
} else {
_doneReps[exIdx][repIdx] =
(_doneReps[exIdx][repIdx] - 1).clamp(0, 999);
_doneReps[exIdx][setIdx] =
(_doneReps[exIdx][setIdx] - 1).clamp(0, 999);
_recomputeBreakIfNeeded(exIdx, setIdx);
}
});
_saveActiveSession();
if (wasNotTapped) {
final isLastSet = _isLastSetOfExercise(exIdx, setIdx);
if (!isLastSet) {
final succeeded =
_doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
_startBreak(
succeeded ? _successBreakSecs : _failBreakSecs,
succeeded
? 'Rest (3 min — well done!)'
: 'Rest (5 min — keep going!)',
exIdx,
setIdx,
);
}
}
unawaited(_saveActiveSession());
}
void _tapWarmup(int exIdx) {
if (_finished || _warmupTapped[exIdx]) return;
setState(() => _warmupTapped[exIdx] = true);
_saveActiveSession();
if (!_inBreak) {
_startBreak(_warmupBreakSecs, 'Warmup rest (3 min)', exIdx, -1);
}
unawaited(_saveActiveSession());
}
void _resetCircle(int exIdx, int repIdx) {
void _resetCircle(int exIdx, int setIdx) {
if (_finished) return;
setState(() {
_tapped[exIdx][repIdx] = false;
_doneReps[exIdx][repIdx] = widget.exercises[exIdx].reps;
_tapped[exIdx][setIdx] = false;
_doneReps[exIdx][setIdx] = widget.exercises[exIdx].reps;
});
_saveActiveSession();
if (_breakForExIdx == exIdx && _breakForSetIdx == setIdx) {
_cancelBreak();
}
unawaited(_saveActiveSession());
}
// Break management
void _startBreak(int secs, String label, int exIdx, int setIdx) {
_breakTimer?.cancel();
setState(() {
_breakDurationSecs = secs;
_breakRemaining = secs;
_breakLabel = label;
_breakForExIdx = exIdx;
_breakForSetIdx = setIdx;
_breakStartTime = DateTime.now();
});
_breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak);
}
void _tickBreak(Timer t) {
setState(() => _breakRemaining--);
if (_breakRemaining <= 0) {
t.cancel();
unawaited(_onBreakFinished());
}
}
void _cancelBreak() {
_breakTimer?.cancel();
setState(() {
_breakRemaining = 0;
_breakForExIdx = -1;
_breakForSetIdx = -1;
_breakStartTime = null;
});
}
void _skipBreak() {
_cancelBreak();
unawaited(_saveActiveSession());
}
/// When the user decrements reps on the set that triggered the current break,
/// switch between 3-min (success) and 5-min (fail) durations.
void _recomputeBreakIfNeeded(int exIdx, int setIdx) {
if (!_inBreak) return;
if (_breakForExIdx != exIdx || _breakForSetIdx != setIdx) return;
if (_breakForSetIdx == -1) return; // warmup break, never recompute
final succeeded =
_doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
final newDuration = succeeded ? _successBreakSecs : _failBreakSecs;
if (newDuration == _breakDurationSecs) return;
final elapsed = DateTime.now().difference(_breakStartTime!).inSeconds;
final newRemaining = (newDuration - elapsed).clamp(0, newDuration);
_breakDurationSecs = newDuration;
_breakRemaining = newRemaining;
_breakLabel =
succeeded ? 'Rest (3 min — well done!)' : 'Rest (5 min — keep going!)';
}
Future<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(
@ -191,8 +353,10 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child:
const Text('Cancel', style: TextStyle(color: Colors.white70)),
child: const Text(
'Cancel',
style: TextStyle(color: Colors.white70),
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
@ -223,13 +387,17 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child:
const Text('Cancel', style: TextStyle(color: Colors.white70)),
child: const Text(
'Cancel',
style: TextStyle(color: Colors.white70),
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child:
const Text('Reset', style: TextStyle(color: Colors.redAccent)),
child: const Text(
'Reset',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
@ -242,14 +410,16 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
Future<void> _finishWorkout() async {
_elapsedTimer.cancel();
_breakTimer?.cancel();
setState(() => _finished = true);
final endTime = DateTime.now();
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];
results.add(ExerciseResult(
results.add(
ExerciseResult(
exercise: ex,
warmupDone: _warmupTapped[i],
sets: List.generate(
@ -260,7 +430,8 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
weight: ex.weight,
),
),
));
),
);
}
final session = WorkoutSession(
@ -293,6 +464,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
final syncResult = await _sync.writeWorkoutResult(session);
if (!mounted) return;
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
@ -300,14 +472,17 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
session: session,
syncResult: syncResult,
),
),
);
}
// Build
// Build
@override
Widget build(BuildContext context) {
return PopScope(
// Explicit `canPop: true` makes it clear this scope never blocks the back
// button a future reader must not assume the default silently.
// ignore: avoid_redundant_argument_values
canPop: true,
child: Scaffold(
@ -322,7 +497,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
actions: [
if (!_finished)
TextButton(
onPressed: () => _confirmReset(),
onPressed: _confirmReset,
child: const Text(
'Reset',
style: TextStyle(color: Colors.redAccent),
@ -334,15 +509,23 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
child: Text(
'Finish',
style: TextStyle(
color:
_allSetsCompleted ? Colors.greenAccent : Colors.grey,
color: _allSetsCompleted ? Colors.greenAccent : Colors.grey,
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),
itemCount: widget.exercises.length,
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.
const int kWorkoutServerPort = 8765;
/// Singleton HTTP server that serves the latest workout JSON over LAN.
class HttpServerService {
HttpServerService._();
/// Singleton instance.
static final HttpServerService instance = HttpServerService._();
HttpServer? _server;
@ -38,14 +41,22 @@ class HttpServerService {
return addrs;
}
void updateLatestWorkout(String json) => _latestJson = json;
/// The most recent workout JSON served at /workout, or null if none.
String? get latestWorkout => _latestJson;
/// Updates the JSON payload served at /workout.
set latestWorkout(String json) => _latestJson = json;
/// Starts the HTTP server, loading the last saved workout from disk first.
Future<void> start() async {
if (_server != null) return; // already running
await _loadFromDisk();
try {
_server = await HttpServer.bind(InternetAddress.anyIPv4, kWorkoutServerPort);
_serve();
_server = await HttpServer.bind(
InternetAddress.anyIPv4,
kWorkoutServerPort,
);
unawaited(_serve());
} on SocketException {
// Port already in use or binding failed not fatal.
_server = null;
@ -64,7 +75,7 @@ class HttpServerService {
}
for (final path in candidates) {
final file = File(path);
if (await file.exists()) {
if (file.existsSync()) {
try {
_latestJson = await file.readAsString();
return;
@ -97,6 +108,7 @@ class HttpServerService {
}
}
/// Stops the HTTP server.
Future<void> stop() async {
await _server?.close(force: true);
_server = null;

View File

@ -1,14 +1,18 @@
/// Persistent storage for exercise progression state using SQLite.
library;
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/services/backup_service.dart';
/// Per-exercise progression state stored in SQLite.
class ExerciseState {
/// Creates an [ExerciseState] with all required progression fields.
ExerciseState({
required this.name,
required this.weight,
@ -20,23 +24,42 @@ class ExerciseState {
required this.failThreshold,
});
/// Exercise name (matches [Exercise.name], used as primary key).
final String name;
/// Current working weight in kg.
double weight;
/// Current target reps per set.
int reps;
/// Consecutive successful workouts since last progression.
int successStreak;
/// Consecutive failed workouts since last regression.
int failStreak;
/// Weight cap; reps increase instead of weight when this is reached.
final double maxWeight;
/// Successes needed in a row before weight/reps increase.
int successThreshold;
/// Failures needed in a row before weight decreases.
int failThreshold;
}
/// Singleton SQLite service for workout data persistence.
class StorageService {
StorageService._();
static StorageService? _instance;
/// Returns the initialized singleton; throws if [init] was not called first.
static StorageService get instance => _instance!;
late Database _db;
/// Initializes the singleton and opens the database (idempotent).
static Future<StorageService> init() async {
if (_instance != null) return _instance!;
final svc = StorageService._();
@ -45,8 +68,22 @@ class StorageService {
return svc;
}
// Overrides the DB path for unit tests (set by resetForTesting).
static String? _testDbPath;
/// Resets the singleton so [init] can be called again in tests.
///
/// Also switches to an in-memory database so each test starts with a clean
/// slate and file-based data from other tests does not leak in.
@visibleForTesting
static void resetForTesting() {
_instance = null;
_testDbPath = ':memory:';
}
Future<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(
dbPath,
version: 3,
@ -100,15 +137,18 @@ class StorageService {
) async {
if (oldVersion < 2) {
await db.execute(
'ALTER TABLE exercise_state ADD COLUMN success_threshold INTEGER NOT NULL DEFAULT 3',
'ALTER TABLE exercise_state '
'ADD COLUMN success_threshold INTEGER NOT NULL DEFAULT 3',
);
await db.execute(
'ALTER TABLE exercise_state ADD COLUMN fail_threshold INTEGER NOT NULL DEFAULT 2',
'ALTER TABLE exercise_state '
'ADD COLUMN fail_threshold INTEGER NOT NULL DEFAULT 2',
);
}
if (oldVersion < 3) {
await db.execute(
'CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
'CREATE TABLE IF NOT EXISTS settings '
'(key TEXT PRIMARY KEY, value TEXT NOT NULL)',
);
await db.execute(
'CREATE TABLE IF NOT EXISTS active_session '
@ -147,7 +187,7 @@ class StorageService {
where: 'key = ?',
whereArgs: [key],
);
return rows.isEmpty ? null : rows.first['value'] as String;
return rows.isEmpty ? null : rows.first['value']! as String;
}
Future<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 {
final last = await _getSetting('last_workout_type');
return last == 'A' ? 'B' : 'A';
}
/// Persists [type] as the most recently completed workout type.
Future<void> setLastWorkoutType(String type) async {
await _setSetting('last_workout_type', type);
unawaited(_backupNow());
}
// Active session (crash / exit recovery)
/// Persists [data] as the currently active (in-progress) session.
Future<void> saveActiveSession(Map<String, dynamic> data) async {
await _db.insert(
'active_session',
@ -177,18 +221,21 @@ class StorageService {
);
}
/// Returns the saved active session, or null if none exists.
Future<Map<String, dynamic>?> loadActiveSession() async {
final rows = await _db.query('active_session', where: 'id = 1');
if (rows.isEmpty) return null;
return jsonDecode(rows.first['json'] as String) as Map<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 {
await _db.delete('active_session', where: 'id = 1');
}
// Exercise state
/// Returns the progression state for [name], or null if not found.
Future<ExerciseState?> getExerciseState(String name) async {
final rows = await _db.query(
'exercise_state',
@ -198,17 +245,18 @@ class StorageService {
if (rows.isEmpty) return null;
final r = rows.first;
return ExerciseState(
name: r['name'] as String,
weight: r['weight'] as double,
reps: r['reps'] as int,
successStreak: r['success_streak'] as int,
failStreak: r['fail_streak'] as int,
maxWeight: r['max_weight'] as double,
name: r['name']! as String,
weight: r['weight']! as double,
reps: r['reps']! as int,
successStreak: r['success_streak']! as int,
failStreak: r['fail_streak']! as int,
maxWeight: r['max_weight']! as double,
successThreshold: r['success_threshold'] as int? ?? 3,
failThreshold: r['fail_threshold'] as int? ?? 2,
);
}
/// Returns progression states for every exercise across both plans.
Future<List<ExerciseState>> getAllExerciseStates() async {
final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet();
final states = <ExerciseState>[];
@ -219,6 +267,7 @@ class StorageService {
return states;
}
/// Updates the streak thresholds for exercise [name].
Future<void> setExerciseThresholds(
String name, {
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 {
await _db.update(
'exercise_state',
@ -242,8 +292,10 @@ class StorageService {
where: 'name = ?',
whereArgs: [name],
);
unawaited(_backupNow());
}
/// Returns exercises for [workoutType] with weights/reps from stored state.
Future<List<Exercise>> getCurrentExercises(String workoutType) async {
final template = workoutType == 'A' ? workoutA : workoutB;
final result = <Exercise>[];
@ -258,6 +310,7 @@ class StorageService {
return result;
}
/// Applies progressive overload or regression based on [succeededExercises].
Future<void> applyProgression({
required Map<String, bool> succeededExercises,
required DateTime lastWorkoutDate,
@ -270,8 +323,10 @@ class StorageService {
if (state == null) continue;
if (hadBreak) {
final newWeight =
(state.weight - kWeightIncrement).clamp(0.0, state.maxWeight);
final newWeight = (state.weight - kWeightIncrement).clamp(
0.0,
state.maxWeight,
);
await _db.update(
'exercise_state',
{'weight': newWeight, 'success_streak': 0, 'fail_streak': 0},
@ -284,15 +339,17 @@ class StorageService {
if (entry.value) {
final newStreak = state.successStreak + 1;
final shouldProgress = newStreak >= state.successThreshold;
double newWeight = state.weight;
int newReps = state.reps;
var newWeight = state.weight;
var newReps = state.reps;
if (shouldProgress) {
if (state.weight >= state.maxWeight) {
newReps = state.reps + 1;
} else {
newWeight =
(state.weight + kWeightIncrement).clamp(0.0, state.maxWeight);
newWeight = (state.weight + kWeightIncrement).clamp(
0.0,
state.maxWeight,
);
}
}
@ -328,6 +385,7 @@ class StorageService {
}
}
/// Persists a completed session to the workout history table.
Future<void> saveSession({
required String date,
required String workoutType,
@ -342,16 +400,19 @@ class StorageService {
'succeeded': succeeded ? 1 : 0,
'json': json,
});
unawaited(_backupNow());
}
/// Returns the date of the most recent completed session, or null.
Future<DateTime?> getLastWorkoutDate() async {
final rows = await _db.rawQuery(
'SELECT date FROM workout_history ORDER BY date DESC LIMIT 1',
);
if (rows.isEmpty) return null;
return DateTime.tryParse(rows.first['date'] as String);
return DateTime.tryParse(rows.first['date']! as String);
}
/// Returns up to [limit] rows from workout history, newest first.
Future<List<Map<String, dynamic>>> getWorkoutHistory({
int limit = 60,
}) async {
@ -362,13 +423,15 @@ class StorageService {
);
}
/// Returns all distinct workout dates (YYYY-MM-DD), newest first.
Future<List<String>> getAllWorkoutDates() async {
final rows = await _db.rawQuery(
'SELECT DISTINCT date FROM workout_history ORDER BY date DESC',
);
return rows.map((r) => r['date'] as String).toList();
return rows.map((r) => r['date']! as String).toList();
}
/// Resets [name] to its default weight and thresholds, clearing streaks.
Future<void> resetExerciseToDefaults(String name) async {
final defaults = [...workoutA, ...workoutB].firstWhere(
(e) => e.name == name,
@ -386,5 +449,62 @@ class StorageService {
where: 'name = ?',
whereArgs: [name],
);
unawaited(_backupNow());
}
// Backup / restore
/// Exports all persistent data to external storage as a JSON snapshot.
Future<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;
import 'dart:io';
@ -6,9 +6,10 @@ import 'package:path_provider/path_provider.dart';
import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/http_server_service.dart';
/// Path on the phone's external storage where the PC reads workout data via ADB.
/// Path on the phone's external storage where the PC reads workout data.
const String kSyncFilePath = '/sdcard/workout_result.json';
/// Handles writing completed workout sessions to disk and the HTTP server.
class SyncService {
/// Writes [session] as JSON to external storage and updates the HTTP server.
///
@ -17,13 +18,13 @@ class SyncService {
final json = session.toJsonString();
// Always update the in-app HTTP server so the PC can read via WiFi.
HttpServerService.instance.updateLatestWorkout(json);
HttpServerService.instance.latestWorkout = json;
// Try the primary path first (/sdcard/ ADB-accessible without root).
try {
final file = File(kSyncFilePath);
await file.writeAsString(json);
return SyncResult(success: true, path: kSyncFilePath);
return const SyncResult(success: true, path: kSyncFilePath);
} on Exception {
// Fallback: app-specific external directory (still ADB accessible).
}
@ -39,14 +40,25 @@ class SyncService {
// Fallback failed.
}
return SyncResult(success: false, path: null, error: 'No writable external path');
return const SyncResult(
success: false,
path: null,
error: 'No writable external path',
);
}
}
/// Result of a [SyncService.writeWorkoutResult] call.
class SyncResult {
/// Creates a sync result.
const SyncResult({required this.success, required this.path, this.error});
/// Whether the write succeeded.
final bool success;
/// Absolute path where the file was written, or null on failure.
final String? path;
/// Human-readable error message on failure.
final String? error;
}

View File

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

View File

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

View File

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

View File

@ -7,34 +7,53 @@
/// failed red, shows 0 (all reps deducted)
///
/// Interaction:
/// single tap neutralsuccess, successpartial(-1 rep), partialpartial(-1 rep),
/// failed stays failed
/// single tap neutralsuccess, successpartial(-1 rep),
/// partialpartial(-1 rep), failed stays failed
/// long press reset to neutral
library;
import 'package:flutter/material.dart';
enum RepCircleState { neutral, success, partial, failed }
/// Visual state of a [RepCircle].
enum RepCircleState {
/// Not yet tapped; shows target reps.
neutral,
/// All reps completed; green.
success,
/// Some reps completed; orange, shows actual count.
partial,
/// All reps deducted; red.
failed,
}
/// Tappable circle representing one working set of an exercise.
class RepCircle extends StatelessWidget {
/// Creates a [RepCircle].
const RepCircle({
super.key,
required this.targetReps,
required this.doneReps,
required this.tapped,
required this.onTap,
required this.onLongPress,
super.key,
});
/// Number of reps the user is aiming for this set.
final int targetReps;
/// Reps currently registered (may be < targetReps if user tapped multiple times).
/// Reps currently registered (may be < targetReps after repeated taps).
final int doneReps;
/// Whether this circle has been tapped at all (distinguishes neutral from success).
/// Whether this circle has been tapped at all (neutral vs success).
final bool tapped;
/// Called on a single tap.
final VoidCallback onTap;
/// Called on a long press (resets to neutral).
final VoidCallback onLongPress;
RepCircleState get _state {

View File

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

View File

@ -200,6 +200,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hooks:
dependency: transitive
description:
@ -304,6 +312,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.18.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
url: "https://pub.dev"
source: hosted
version: "0.19.1"
objective_c:
dependency: transitive
description:
@ -545,10 +561,18 @@ packages:
dependency: transitive
description:
name: sqflite_common
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
sha256: cce558075afe2a83f3fd7fc123acd6b090683e4f23910d44fbb31ecd7800b014
url: "https://pub.dev"
source: hosted
version: "2.5.8"
version: "2.5.9"
sqflite_common_ffi:
dependency: "direct dev"
description:
name: sqflite_common_ffi
sha256: "3ddad0ec96ad411d5fea45b4912c3cd5743436c9e1890c26a6e688a32d901cae"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqflite_darwin:
dependency: transitive
description:
@ -565,6 +589,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
url: "https://pub.dev"
source: hosted
version: "3.3.2"
stack_trace:
dependency: transitive
description:
@ -593,10 +625,10 @@ packages:
dependency: transitive
description:
name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
url: "https://pub.dev"
source: hosted
version: "3.4.0+1"
version: "3.4.1"
term_glyph:
dependency: transitive
description:
@ -637,6 +669,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
very_good_analysis:
dependency: "direct dev"
description:
name: very_good_analysis
sha256: d1cb1d66a5aae2c702d68caca6c8347306d35e728fd94555fa21fa0448a972e0
url: "https://pub.dev"
source: hosted
version: "10.2.0"
vibration:
dependency: "direct main"
description:

View File

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

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.
void main() {}
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/main.dart';
import 'package:workout_app/services/storage_service.dart';
void main() {
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
StorageService.resetForTesting();
await StorageService.init();
});
testWidgets('WorkoutApp renders HomeScreen', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const WorkoutApp());
await Future<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.
});
});
}