mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 11:43:09 +02:00
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:
parent
74a8bd7529
commit
23d2173d9f
69
scripts/check_flutter_coverage.sh
Executable file
69
scripts/check_flutter_coverage.sh
Executable 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%)."
|
||||||
@ -1,28 +1,32 @@
|
|||||||
# This file configures the analyzer, which statically analyzes Dart code to
|
include: package:very_good_analysis/analysis_options.yaml
|
||||||
# check for errors, warnings, and lints.
|
|
||||||
#
|
|
||||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
|
||||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
|
||||||
# invoked from the command line by running `flutter analyze`.
|
|
||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
analyzer:
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
language:
|
||||||
include: package:flutter_lints/flutter.yaml
|
strict-casts: true
|
||||||
|
strict-inference: true
|
||||||
|
strict-raw-types: true
|
||||||
|
errors:
|
||||||
|
# Promote key lints to errors so they fail CI, not just warn.
|
||||||
|
missing_required_param: error
|
||||||
|
unnecessary_null_comparison: error
|
||||||
|
dead_code: error
|
||||||
|
invalid_annotation_target: error
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- "**/*.g.dart"
|
||||||
|
- "**/*.freezed.dart"
|
||||||
|
- "test/**"
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
|
||||||
# included above or to enable additional rules. A list of all available lints
|
|
||||||
# and their documentation is published at https://dart.dev/lints.
|
|
||||||
#
|
|
||||||
# Instead of disabling a lint rule for the entire project in the
|
|
||||||
# section below, it can also be suppressed for a single line of code
|
|
||||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
|
||||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
|
||||||
# producing the lint.
|
|
||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# very_good_analysis enables most rules; add extras it doesn't include.
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
always_use_package_imports: true
|
||||||
|
avoid_print: true
|
||||||
# Additional information about this file can be found at
|
avoid_relative_lib_imports: true
|
||||||
# https://dart.dev/guides/language/analysis-options
|
cancel_subscriptions: true
|
||||||
|
close_sinks: true
|
||||||
|
comment_references: false # conflicts with typical Flutter API docs style
|
||||||
|
directives_ordering: true
|
||||||
|
lines_longer_than_80_chars: true
|
||||||
|
public_member_api_docs: true
|
||||||
|
unawaited_futures: true
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
android:maxSdkVersion="29" />
|
android:maxSdkVersion="29" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
|
<!-- Full external storage access — needed to persist backup.json across
|
||||||
|
uninstall/reinstall on Android 11+ where legacy permissions are revoked. -->
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<!-- Wake lock keeps timer running in background -->
|
<!-- Wake lock keeps timer running in background -->
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|||||||
@ -1,16 +1,21 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:workout_app/screens/home_screen.dart';
|
import 'package:workout_app/screens/home_screen.dart';
|
||||||
|
import 'package:workout_app/services/backup_service.dart';
|
||||||
import 'package:workout_app/services/http_server_service.dart';
|
import 'package:workout_app/services/http_server_service.dart';
|
||||||
import 'package:workout_app/services/storage_service.dart';
|
import 'package:workout_app/services/storage_service.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await BackupService.instance.requestStoragePermission();
|
||||||
await StorageService.init();
|
await StorageService.init();
|
||||||
|
await StorageService.instance.restoreFromBackupIfNeeded();
|
||||||
await HttpServerService.instance.start();
|
await HttpServerService.instance.start();
|
||||||
runApp(const WorkoutApp());
|
runApp(const WorkoutApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Root widget that bootstraps the app with Material 3 dark theming.
|
||||||
class WorkoutApp extends StatelessWidget {
|
class WorkoutApp extends StatelessWidget {
|
||||||
|
/// Creates the root app widget.
|
||||||
const WorkoutApp({super.key});
|
const WorkoutApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -1,38 +1,66 @@
|
|||||||
/// Core domain model for a single exercise definition and its current progression state.
|
/// Core domain model for a single exercise and its current progression state.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
/// Default weight cap: above this, reps increase instead of weight.
|
||||||
const double kDefaultMaxWeight = 27.5;
|
const double kDefaultMaxWeight = 27.5;
|
||||||
|
|
||||||
|
/// Weight increment used for progression steps (kg).
|
||||||
const double kWeightIncrement = 2.5;
|
const double kWeightIncrement = 2.5;
|
||||||
|
|
||||||
|
/// Immutable definition of a single exercise and its current target state.
|
||||||
class Exercise {
|
class Exercise {
|
||||||
|
/// Creates an exercise with the given parameters.
|
||||||
const Exercise({
|
const Exercise({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.sets,
|
required this.sets,
|
||||||
required this.reps,
|
required this.reps,
|
||||||
required this.weight,
|
required this.weight,
|
||||||
this.maxWeight = kDefaultMaxWeight,
|
this.maxWeight = kDefaultMaxWeight,
|
||||||
|
this.hasWarmup = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Deserializes an exercise from a JSON map.
|
||||||
|
factory Exercise.fromJson(Map<String, dynamic> json) => Exercise(
|
||||||
|
name: json['name'] as String,
|
||||||
|
sets: json['sets'] as int,
|
||||||
|
reps: json['reps'] as int,
|
||||||
|
weight: (json['weight'] as num).toDouble(),
|
||||||
|
maxWeight: (json['maxWeight'] as num?)?.toDouble() ?? kDefaultMaxWeight,
|
||||||
|
hasWarmup: json['hasWarmup'] as bool? ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Display name of the exercise.
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
|
/// Number of working sets per session.
|
||||||
final int sets;
|
final int sets;
|
||||||
|
|
||||||
|
/// Target reps per set.
|
||||||
final int reps;
|
final int reps;
|
||||||
|
|
||||||
|
/// Current working weight in kg.
|
||||||
final double weight;
|
final double weight;
|
||||||
|
|
||||||
/// Weight cap beyond which reps increase instead of weight.
|
/// Weight cap beyond which reps increase instead of weight.
|
||||||
final double maxWeight;
|
final double maxWeight;
|
||||||
|
|
||||||
|
/// Whether a warmup set should be shown for this exercise.
|
||||||
|
final bool hasWarmup;
|
||||||
|
|
||||||
/// Warmup weight: 4/5 of target weight, rounded DOWN to nearest 2.5 kg.
|
/// Warmup weight: 4/5 of target weight, rounded DOWN to nearest 2.5 kg.
|
||||||
double get warmupWeight {
|
double get warmupWeight {
|
||||||
final raw = weight * 4.0 / 5.0;
|
final raw = weight * 4.0 / 5.0;
|
||||||
return (raw / kWeightIncrement).floor() * kWeightIncrement;
|
return (raw / kWeightIncrement).floor() * kWeightIncrement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a copy of this exercise with the given fields replaced.
|
||||||
Exercise copyWith({
|
Exercise copyWith({
|
||||||
String? name,
|
String? name,
|
||||||
int? sets,
|
int? sets,
|
||||||
int? reps,
|
int? reps,
|
||||||
double? weight,
|
double? weight,
|
||||||
double? maxWeight,
|
double? maxWeight,
|
||||||
|
bool? hasWarmup,
|
||||||
}) {
|
}) {
|
||||||
return Exercise(
|
return Exercise(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
@ -40,22 +68,17 @@ class Exercise {
|
|||||||
reps: reps ?? this.reps,
|
reps: reps ?? this.reps,
|
||||||
weight: weight ?? this.weight,
|
weight: weight ?? this.weight,
|
||||||
maxWeight: maxWeight ?? this.maxWeight,
|
maxWeight: maxWeight ?? this.maxWeight,
|
||||||
|
hasWarmup: hasWarmup ?? this.hasWarmup,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serializes this exercise to a JSON map.
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'name': name,
|
'name': name,
|
||||||
'sets': sets,
|
'sets': sets,
|
||||||
'reps': reps,
|
'reps': reps,
|
||||||
'weight': weight,
|
'weight': weight,
|
||||||
'maxWeight': maxWeight,
|
'maxWeight': maxWeight,
|
||||||
};
|
'hasWarmup': hasWarmup,
|
||||||
|
};
|
||||||
factory Exercise.fromJson(Map<String, dynamic> json) => Exercise(
|
|
||||||
name: json['name'] as String,
|
|
||||||
sets: json['sets'] as int,
|
|
||||||
reps: json['reps'] as int,
|
|
||||||
weight: (json['weight'] as num).toDouble(),
|
|
||||||
maxWeight: (json['maxWeight'] as num?)?.toDouble() ?? kDefaultMaxWeight,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,27 +4,35 @@ library;
|
|||||||
import 'package:workout_app/models/exercise.dart';
|
import 'package:workout_app/models/exercise.dart';
|
||||||
import 'package:workout_app/models/set_result.dart';
|
import 'package:workout_app/models/set_result.dart';
|
||||||
|
|
||||||
|
/// Aggregated results for a single exercise across all its sets in a session.
|
||||||
class ExerciseResult {
|
class ExerciseResult {
|
||||||
|
/// Creates a result for [exercise] with the given [sets] outcomes.
|
||||||
const ExerciseResult({
|
const ExerciseResult({
|
||||||
required this.exercise,
|
required this.exercise,
|
||||||
required this.sets,
|
required this.sets,
|
||||||
this.warmupDone = false,
|
this.warmupDone = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The exercise definition this result belongs to.
|
||||||
final Exercise exercise;
|
final Exercise exercise;
|
||||||
|
|
||||||
|
/// Results for each individual set.
|
||||||
final List<SetResult> sets;
|
final List<SetResult> sets;
|
||||||
|
|
||||||
|
/// Whether the warmup set was completed before the working sets.
|
||||||
final bool warmupDone;
|
final bool warmupDone;
|
||||||
|
|
||||||
/// True when every set was fully completed.
|
/// True when every set was fully completed.
|
||||||
bool get succeeded => sets.isNotEmpty && sets.every((s) => s.succeeded);
|
bool get succeeded => sets.isNotEmpty && sets.every((s) => s.succeeded);
|
||||||
|
|
||||||
|
/// Serializes this exercise result to a JSON map.
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'name': exercise.name,
|
'name': exercise.name,
|
||||||
'targetSets': exercise.sets,
|
'targetSets': exercise.sets,
|
||||||
'targetReps': exercise.reps,
|
'targetReps': exercise.reps,
|
||||||
'targetWeight': exercise.weight,
|
'targetWeight': exercise.weight,
|
||||||
'warmupDone': warmupDone,
|
'warmupDone': warmupDone,
|
||||||
'sets': sets.map((s) => s.toJson()).toList(),
|
'sets': sets.map((s) => s.toJson()).toList(),
|
||||||
'succeeded': succeeded,
|
'succeeded': succeeded,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +1,46 @@
|
|||||||
/// Result of a single set during a workout session.
|
/// Result of a single set during a workout session.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
/// Immutable result of one set, recording target vs actual reps.
|
||||||
class SetResult {
|
class SetResult {
|
||||||
|
/// Creates a set result.
|
||||||
const SetResult({
|
const SetResult({
|
||||||
required this.targetReps,
|
required this.targetReps,
|
||||||
required this.doneReps,
|
required this.doneReps,
|
||||||
required this.weight,
|
required this.weight,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Deserializes a set result from a JSON map.
|
||||||
|
factory SetResult.fromJson(Map<String, dynamic> json) => SetResult(
|
||||||
|
targetReps: json['targetReps'] as int,
|
||||||
|
doneReps: json['doneReps'] as int,
|
||||||
|
weight: (json['weight'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Target number of reps for this set.
|
||||||
final int targetReps;
|
final int targetReps;
|
||||||
|
|
||||||
/// How many reps the user actually completed (may be < targetReps on failure).
|
/// Reps actually completed (may be less than [targetReps] on failure).
|
||||||
final int doneReps;
|
final int doneReps;
|
||||||
|
|
||||||
|
/// Weight used for this set in kg.
|
||||||
final double weight;
|
final double weight;
|
||||||
|
|
||||||
/// True when the user completed every target rep.
|
/// True when the user completed every target rep.
|
||||||
bool get succeeded => doneReps >= targetReps;
|
bool get succeeded => doneReps >= targetReps;
|
||||||
|
|
||||||
|
/// Returns a copy with [doneReps] replaced.
|
||||||
SetResult copyWith({int? doneReps}) => SetResult(
|
SetResult copyWith({int? doneReps}) => SetResult(
|
||||||
targetReps: targetReps,
|
targetReps: targetReps,
|
||||||
doneReps: doneReps ?? this.doneReps,
|
doneReps: doneReps ?? this.doneReps,
|
||||||
weight: weight,
|
weight: weight,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Serializes this set result to a JSON map.
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'targetReps': targetReps,
|
'targetReps': targetReps,
|
||||||
'doneReps': doneReps,
|
'doneReps': doneReps,
|
||||||
'weight': weight,
|
'weight': weight,
|
||||||
'succeeded': succeeded,
|
'succeeded': succeeded,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory SetResult.fromJson(Map<String, dynamic> json) => SetResult(
|
|
||||||
targetReps: json['targetReps'] as int,
|
|
||||||
doneReps: json['doneReps'] as int,
|
|
||||||
weight: (json['weight'] as num).toDouble(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,15 +4,22 @@ library;
|
|||||||
import 'package:workout_app/models/exercise.dart';
|
import 'package:workout_app/models/exercise.dart';
|
||||||
|
|
||||||
/// Situp has a lower max weight cap.
|
/// Situp has a lower max weight cap.
|
||||||
const double kSitupMaxWeight = 10.0;
|
const double kSitupMaxWeight = 10;
|
||||||
|
|
||||||
|
/// Plan A: lower-body and push/pull focus.
|
||||||
final workoutA = [
|
final workoutA = [
|
||||||
const Exercise(name: 'Dumbbell Lunge', sets: 5, reps: 12, weight: 7.5),
|
const Exercise(name: 'Dumbbell Lunge', sets: 5, reps: 12, weight: 7.5),
|
||||||
const Exercise(name: 'Dumbbell Bench Press', sets: 5, reps: 12, weight: 22.5),
|
const Exercise(
|
||||||
|
name: 'Dumbbell Bench Press',
|
||||||
|
sets: 5,
|
||||||
|
reps: 12,
|
||||||
|
weight: 22.5,
|
||||||
|
),
|
||||||
const Exercise(name: 'Dumbbell Row', sets: 4, reps: 6, weight: 22.5),
|
const Exercise(name: 'Dumbbell Row', sets: 4, reps: 6, weight: 22.5),
|
||||||
const Exercise(name: 'Dumbbell Curl', sets: 3, reps: 12, weight: 12.5),
|
const Exercise(name: 'Dumbbell Curl', sets: 3, reps: 12, weight: 12.5),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Plan B: posterior chain, overhead, and core focus.
|
||||||
final workoutB = [
|
final workoutB = [
|
||||||
const Exercise(
|
const Exercise(
|
||||||
name: 'Dumbbell Romanian Deadlift',
|
name: 'Dumbbell Romanian Deadlift',
|
||||||
@ -26,12 +33,18 @@ final workoutB = [
|
|||||||
reps: 12,
|
reps: 12,
|
||||||
weight: 7.5,
|
weight: 7.5,
|
||||||
),
|
),
|
||||||
const Exercise(name: 'Dumbbell Bench Press', sets: 5, reps: 12, weight: 22.5),
|
const Exercise(
|
||||||
|
name: 'Dumbbell Bench Press',
|
||||||
|
sets: 5,
|
||||||
|
reps: 12,
|
||||||
|
weight: 22.5,
|
||||||
|
),
|
||||||
const Exercise(
|
const Exercise(
|
||||||
name: 'Situp',
|
name: 'Situp',
|
||||||
sets: 3,
|
sets: 3,
|
||||||
reps: 30,
|
reps: 30,
|
||||||
weight: 10.0,
|
weight: 10,
|
||||||
maxWeight: kSitupMaxWeight,
|
maxWeight: kSitupMaxWeight,
|
||||||
|
hasWarmup: false,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -4,7 +4,9 @@ library;
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:workout_app/models/exercise_result.dart';
|
import 'package:workout_app/models/exercise_result.dart';
|
||||||
|
|
||||||
|
/// Immutable record of a finished workout session with all its results.
|
||||||
class WorkoutSession {
|
class WorkoutSession {
|
||||||
|
/// Creates a workout session record.
|
||||||
const WorkoutSession({
|
const WorkoutSession({
|
||||||
required this.workoutType,
|
required this.workoutType,
|
||||||
required this.startTime,
|
required this.startTime,
|
||||||
@ -14,24 +16,33 @@ class WorkoutSession {
|
|||||||
|
|
||||||
/// 'A' or 'B'.
|
/// 'A' or 'B'.
|
||||||
final String workoutType;
|
final String workoutType;
|
||||||
|
|
||||||
|
/// Wall-clock time when the session started.
|
||||||
final DateTime startTime;
|
final DateTime startTime;
|
||||||
|
|
||||||
|
/// Wall-clock time when the session ended.
|
||||||
final DateTime endTime;
|
final DateTime endTime;
|
||||||
|
|
||||||
|
/// Ordered list of exercise results, one per exercise in the plan.
|
||||||
final List<ExerciseResult> exercises;
|
final List<ExerciseResult> exercises;
|
||||||
|
|
||||||
|
/// Total elapsed time of the session.
|
||||||
Duration get duration => endTime.difference(startTime);
|
Duration get duration => endTime.difference(startTime);
|
||||||
|
|
||||||
/// True when every exercise succeeded.
|
/// True when every exercise succeeded.
|
||||||
bool get fullySucceeded => exercises.every((e) => e.succeeded);
|
bool get fullySucceeded => exercises.every((e) => e.succeeded);
|
||||||
|
|
||||||
|
/// Serializes this session to a JSON map.
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'workout_type': workoutType,
|
'workout_type': workoutType,
|
||||||
'date': startTime.toIso8601String().substring(0, 10),
|
'date': startTime.toIso8601String().substring(0, 10),
|
||||||
'start_time': startTime.toIso8601String(),
|
'start_time': startTime.toIso8601String(),
|
||||||
'end_time': endTime.toIso8601String(),
|
'end_time': endTime.toIso8601String(),
|
||||||
'duration_seconds': duration.inSeconds,
|
'duration_seconds': duration.inSeconds,
|
||||||
'succeeded': fullySucceeded,
|
'succeeded': fullySucceeded,
|
||||||
'exercises': exercises.map((e) => e.toJson()).toList(),
|
'exercises': exercises.map((e) => e.toJson()).toList(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Serializes this session to a pretty-printed JSON string.
|
||||||
String toJsonString() => const JsonEncoder.withIndent(' ').convert(toJson());
|
String toJsonString() => const JsonEncoder.withIndent(' ').convert(toJson());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
/// exercise-only session list.
|
/// exercise-only session list.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -14,7 +15,9 @@ import 'package:workout_app/widgets/calendar_widget.dart';
|
|||||||
|
|
||||||
const _kTotal = 'Total (all workouts)';
|
const _kTotal = 'Total (all workouts)';
|
||||||
|
|
||||||
|
/// Screen showing workout history with per-exercise drill-down and charts.
|
||||||
class HistoryScreen extends StatefulWidget {
|
class HistoryScreen extends StatefulWidget {
|
||||||
|
/// Creates a [HistoryScreen].
|
||||||
const HistoryScreen({super.key});
|
const HistoryScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -27,13 +30,12 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
String _selected = _kTotal;
|
String _selected = _kTotal;
|
||||||
List<String> _exerciseNames = [];
|
List<String> _exerciseNames = [];
|
||||||
ExerciseState? _selectedState;
|
ExerciseState? _selectedState;
|
||||||
DateTime _calendarMonth =
|
DateTime _calendarMonth = DateTime(DateTime.now().year, DateTime.now().month);
|
||||||
DateTime(DateTime.now().year, DateTime.now().month);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_load();
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
@ -41,9 +43,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
final names = <String>[];
|
final names = <String>[];
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
for (final row in rows) {
|
for (final row in rows) {
|
||||||
final json =
|
final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||||
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
for (final ex in (json['exercises'] as List? ?? const [])) {
|
||||||
for (final ex in (json['exercises'] as List)) {
|
|
||||||
final name = (ex as Map<String, dynamic>)['name'] as String;
|
final name = (ex as Map<String, dynamic>)['name'] as String;
|
||||||
if (seen.add(name)) names.add(name);
|
if (seen.add(name)) names.add(name);
|
||||||
}
|
}
|
||||||
@ -75,7 +76,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data helpers ────────────────────────────────────────────────────────────
|
// ── Data helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// All workout dates (YYYY-MM-DD) across all sessions.
|
/// All workout dates (YYYY-MM-DD) across all sessions.
|
||||||
Set<String> get _allWorkoutDates =>
|
Set<String> get _allWorkoutDates =>
|
||||||
@ -85,9 +86,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
Set<String> _exerciseDates(String name) {
|
Set<String> _exerciseDates(String name) {
|
||||||
final result = <String>{};
|
final result = <String>{};
|
||||||
for (final row in _rows) {
|
for (final row in _rows) {
|
||||||
final json =
|
final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||||
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
for (final ex in (json['exercises'] as List? ?? const [])) {
|
||||||
for (final ex in (json['exercises'] as List)) {
|
|
||||||
if ((ex as Map<String, dynamic>)['name'] == name) {
|
if ((ex as Map<String, dynamic>)['name'] == name) {
|
||||||
result.add(row['date'] as String);
|
result.add(row['date'] as String);
|
||||||
break;
|
break;
|
||||||
@ -101,10 +101,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
List<(DateTime, double)> _totalVolumePoints() {
|
List<(DateTime, double)> _totalVolumePoints() {
|
||||||
final points = <(DateTime, double)>[];
|
final points = <(DateTime, double)>[];
|
||||||
for (final row in _rows.reversed) {
|
for (final row in _rows.reversed) {
|
||||||
final json =
|
final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||||
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
|
||||||
double total = 0;
|
double total = 0;
|
||||||
for (final ex in (json['exercises'] as List)) {
|
for (final ex in (json['exercises'] as List? ?? const [])) {
|
||||||
final m = ex as Map<String, dynamic>;
|
final m = ex as Map<String, dynamic>;
|
||||||
final w = (m['targetWeight'] as num?)?.toDouble() ?? 0;
|
final w = (m['targetWeight'] as num?)?.toDouble() ?? 0;
|
||||||
final s = (m['targetSets'] as num?)?.toInt() ?? 0;
|
final s = (m['targetSets'] as num?)?.toInt() ?? 0;
|
||||||
@ -121,9 +120,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
List<(DateTime, double)> _exerciseWeightPoints(String name) {
|
List<(DateTime, double)> _exerciseWeightPoints(String name) {
|
||||||
final points = <(DateTime, double)>[];
|
final points = <(DateTime, double)>[];
|
||||||
for (final row in _rows.reversed) {
|
for (final row in _rows.reversed) {
|
||||||
final json =
|
final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||||
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
for (final ex in (json['exercises'] as List? ?? const [])) {
|
||||||
for (final ex in (json['exercises'] as List)) {
|
|
||||||
final m = ex as Map<String, dynamic>;
|
final m = ex as Map<String, dynamic>;
|
||||||
if (m['name'] == name) {
|
if (m['name'] == name) {
|
||||||
final date = DateTime.tryParse(row['date'] as String);
|
final date = DateTime.tryParse(row['date'] as String);
|
||||||
@ -140,9 +138,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
List<Map<String, dynamic>> _sessionsForExercise(String name) {
|
List<Map<String, dynamic>> _sessionsForExercise(String name) {
|
||||||
final result = <Map<String, dynamic>>[];
|
final result = <Map<String, dynamic>>[];
|
||||||
for (final row in _rows) {
|
for (final row in _rows) {
|
||||||
final json =
|
final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
||||||
jsonDecode(row['json'] as String) as Map<String, dynamic>;
|
for (final ex in (json['exercises'] as List? ?? const [])) {
|
||||||
for (final ex in (json['exercises'] as List)) {
|
|
||||||
final m = ex as Map<String, dynamic>;
|
final m = ex as Map<String, dynamic>;
|
||||||
if (m['name'] == name) {
|
if (m['name'] == name) {
|
||||||
result.add({...row, 'exerciseData': m});
|
result.add({...row, 'exerciseData': m});
|
||||||
@ -153,7 +150,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build ───────────────────────────────────────────────────────────────────
|
// ── Build ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -170,25 +167,27 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
body: _loading
|
body: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _rows.isEmpty
|
: _rows.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No workouts yet.',
|
'No workouts yet.',
|
||||||
style: TextStyle(color: Colors.white54),
|
style: TextStyle(color: Colors.white54),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
children: [
|
children: [
|
||||||
_ExercisePicker(
|
_ExercisePicker(
|
||||||
names: allNames,
|
names: allNames,
|
||||||
selected: _selected,
|
selected: _selected,
|
||||||
onChanged: _pickExercise,
|
onChanged: _pickExercise,
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (isTotal) ..._buildTotalView()
|
|
||||||
else ..._buildExerciseView(_selected),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (isTotal)
|
||||||
|
..._buildTotalView()
|
||||||
|
else
|
||||||
|
..._buildExerciseView(_selected),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,70 +203,69 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildTotalView() => [
|
List<Widget> _buildTotalView() => [
|
||||||
_SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'),
|
const _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_WeightChart(
|
_WeightChart(
|
||||||
points: _rollingAvg2(_totalVolumePoints()),
|
points: _rollingAvg2(_totalVolumePoints()),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
WorkoutCalendar(
|
WorkoutCalendar(
|
||||||
workoutDates: _allWorkoutDates,
|
workoutDates: _allWorkoutDates,
|
||||||
month: _calendarMonth,
|
month: _calendarMonth,
|
||||||
onPrevMonth: () => setState(() {
|
onPrevMonth: () => setState(() {
|
||||||
_calendarMonth = DateTime(
|
_calendarMonth = DateTime(
|
||||||
_calendarMonth.year,
|
_calendarMonth.year,
|
||||||
_calendarMonth.month - 1,
|
_calendarMonth.month - 1,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
onNextMonth: () => setState(() {
|
onNextMonth: () => setState(() {
|
||||||
_calendarMonth = DateTime(
|
_calendarMonth = DateTime(
|
||||||
_calendarMonth.year,
|
_calendarMonth.year,
|
||||||
_calendarMonth.month + 1,
|
_calendarMonth.month + 1,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_SectionLabel('ALL SESSIONS'),
|
const _SectionLabel('ALL SESSIONS'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
..._rows.map((row) => _AllSessionTile(row: row)),
|
..._rows.map((row) => _AllSessionTile(row: row)),
|
||||||
];
|
];
|
||||||
|
|
||||||
List<Widget> _buildExerciseView(String name) => [
|
List<Widget> _buildExerciseView(String name) => [
|
||||||
if (_selectedState != null) ...[
|
if (_selectedState != null) ...[
|
||||||
_ProgressStatsCard(state: _selectedState!),
|
_ProgressStatsCard(state: _selectedState!),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
_SectionLabel('WEIGHT OVER TIME'),
|
const _SectionLabel('WEIGHT OVER TIME'),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_WeightChart(
|
_WeightChart(
|
||||||
points: _exerciseWeightPoints(name),
|
points: _exerciseWeightPoints(name),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
WorkoutCalendar(
|
WorkoutCalendar(
|
||||||
workoutDates: _exerciseDates(name),
|
workoutDates: _exerciseDates(name),
|
||||||
month: _calendarMonth,
|
month: _calendarMonth,
|
||||||
onPrevMonth: () => setState(() {
|
onPrevMonth: () => setState(() {
|
||||||
_calendarMonth = DateTime(
|
_calendarMonth = DateTime(
|
||||||
_calendarMonth.year,
|
_calendarMonth.year,
|
||||||
_calendarMonth.month - 1,
|
_calendarMonth.month - 1,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
onNextMonth: () => setState(() {
|
onNextMonth: () => setState(() {
|
||||||
_calendarMonth = DateTime(
|
_calendarMonth = DateTime(
|
||||||
_calendarMonth.year,
|
_calendarMonth.year,
|
||||||
_calendarMonth.month + 1,
|
_calendarMonth.month + 1,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_SectionLabel(name.toUpperCase()),
|
_SectionLabel(name.toUpperCase()),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
..._sessionsForExercise(name)
|
..._sessionsForExercise(name).map((s) => _ExerciseSessionTile(session: s)),
|
||||||
.map((s) => _ExerciseSessionTile(session: s)),
|
];
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared sub-widgets ─────────────────────────────────────────────────────────
|
// ── Shared sub-widgets ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _SectionLabel extends StatelessWidget {
|
class _SectionLabel extends StatelessWidget {
|
||||||
const _SectionLabel(this.text);
|
const _SectionLabel(this.text);
|
||||||
@ -314,8 +312,7 @@ class _ExercisePicker extends StatelessWidget {
|
|||||||
n,
|
n,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: n == _kTotal ? Colors.white70 : Colors.white,
|
color: n == _kTotal ? Colors.white70 : Colors.white,
|
||||||
fontStyle:
|
fontStyle: n == _kTotal ? FontStyle.italic : FontStyle.normal,
|
||||||
n == _kTotal ? FontStyle.italic : FontStyle.normal,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -481,8 +478,18 @@ class _ChartPainter extends CustomPainter {
|
|||||||
static const _hPad = 8.0;
|
static const _hPad = 8.0;
|
||||||
|
|
||||||
static const _months = [
|
static const _months = [
|
||||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
'Jan',
|
||||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
];
|
];
|
||||||
|
|
||||||
static String _shortDate(DateTime d) => '${_months[d.month - 1]} ${d.day}';
|
static String _shortDate(DateTime d) => '${_months[d.month - 1]} ${d.day}';
|
||||||
@ -496,9 +503,9 @@ class _ChartPainter extends CustomPainter {
|
|||||||
final wRange = maxW - minW;
|
final wRange = maxW - minW;
|
||||||
final tRange = maxMs - minMs;
|
final tRange = maxMs - minMs;
|
||||||
|
|
||||||
final plotTop = _topPad;
|
const plotTop = _topPad;
|
||||||
final plotBottom = size.height - _bottomPad;
|
final plotBottom = size.height - _bottomPad;
|
||||||
final plotLeft = _hPad;
|
const plotLeft = _hPad;
|
||||||
final plotRight = size.width - _hPad;
|
final plotRight = size.width - _hPad;
|
||||||
final plotHeight = plotBottom - plotTop;
|
final plotHeight = plotBottom - plotTop;
|
||||||
final plotWidth = plotRight - plotLeft;
|
final plotWidth = plotRight - plotLeft;
|
||||||
@ -518,8 +525,7 @@ class _ChartPainter extends CustomPainter {
|
|||||||
..color = Colors.indigoAccent
|
..color = Colors.indigoAccent
|
||||||
..style = PaintingStyle.fill;
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
final path = Path()
|
final path = Path()..moveTo(xOf(points.first.$1), yOf(points.first.$2));
|
||||||
..moveTo(xOf(points.first.$1), yOf(points.first.$2));
|
|
||||||
for (final p in points.skip(1)) {
|
for (final p in points.skip(1)) {
|
||||||
path.lineTo(xOf(p.$1), yOf(p.$2));
|
path.lineTo(xOf(p.$1), yOf(p.$2));
|
||||||
}
|
}
|
||||||
@ -540,7 +546,7 @@ class _ChartPainter extends CustomPainter {
|
|||||||
..paint(canvas, offset);
|
..paint(canvas, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawText('${maxW.round()}kg', Offset(plotLeft, 0));
|
drawText('${maxW.round()}kg', const Offset(plotLeft, 0));
|
||||||
drawText('${minW.round()}kg', Offset(plotLeft, plotBottom + 2));
|
drawText('${minW.round()}kg', Offset(plotLeft, plotBottom + 2));
|
||||||
|
|
||||||
// X-axis date labels: first, middle, last
|
// X-axis date labels: first, middle, last
|
||||||
@ -592,7 +598,6 @@ class _AllSessionTile extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: succeeded ? Colors.green.shade800 : Colors.red.shade900,
|
color: succeeded ? Colors.green.shade800 : Colors.red.shade900,
|
||||||
width: 1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -616,8 +621,7 @@ class _AllSessionTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
dur,
|
dur,
|
||||||
style:
|
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||||
const TextStyle(color: Colors.white54, fontSize: 12),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -648,8 +652,7 @@ class _ExerciseSessionTile extends StatelessWidget {
|
|||||||
final dur = _formatDuration(session['duration_seconds'] as int);
|
final dur = _formatDuration(session['duration_seconds'] as int);
|
||||||
final weight = (exData['targetWeight'] as num?)?.toDouble();
|
final weight = (exData['targetWeight'] as num?)?.toDouble();
|
||||||
final warmupDone = exData['warmupDone'] as bool? ?? false;
|
final warmupDone = exData['warmupDone'] as bool? ?? false;
|
||||||
final sets =
|
final sets = (exData['sets'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
(exData['sets'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
||||||
final targetSets = exData['targetSets'] as int? ?? sets.length;
|
final targetSets = exData['targetSets'] as int? ?? sets.length;
|
||||||
final doneSets = sets.where((s) => s['succeeded'] == true).length;
|
final doneSets = sets.where((s) => s['succeeded'] == true).length;
|
||||||
final repsSummary = sets.map((s) => '${s['doneReps']}').join(', ');
|
final repsSummary = sets.map((s) => '${s['doneReps']}').join(', ');
|
||||||
@ -662,7 +665,6 @@ class _ExerciseSessionTile extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: succeeded ? Colors.green.shade800 : Colors.red.shade900,
|
color: succeeded ? Colors.green.shade800 : Colors.red.shade900,
|
||||||
width: 1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/// Home screen: auto-resumes an active session, shows done-today status.
|
/// Home screen: auto-resumes an active session, shows done-today status.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:workout_app/models/exercise.dart';
|
import 'package:workout_app/models/exercise.dart';
|
||||||
import 'package:workout_app/screens/history_screen.dart';
|
import 'package:workout_app/screens/history_screen.dart';
|
||||||
@ -9,7 +10,9 @@ import 'package:workout_app/screens/workout_screen.dart';
|
|||||||
import 'package:workout_app/services/http_server_service.dart';
|
import 'package:workout_app/services/http_server_service.dart';
|
||||||
import 'package:workout_app/services/storage_service.dart';
|
import 'package:workout_app/services/storage_service.dart';
|
||||||
|
|
||||||
|
/// Home screen: auto-resumes active sessions and shows done-today status.
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
|
/// Creates a [HomeScreen].
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -17,7 +20,7 @@ class HomeScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
class _HomeScreenState extends State<HomeScreen> {
|
||||||
List<Exercise>? _exercises;
|
late List<Exercise> _exercises;
|
||||||
String _nextType = 'A';
|
String _nextType = 'A';
|
||||||
List<String> _serverAddresses = [];
|
List<String> _serverAddresses = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
@ -31,7 +34,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_load();
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
@ -42,7 +45,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
final addrs = await HttpServerService.instance.localAddresses;
|
final addrs = await HttpServerService.instance.localAddresses;
|
||||||
final lastDate = await storage.getLastWorkoutDate();
|
final lastDate = await storage.getLastWorkoutDate();
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
final doneToday = lastDate != null &&
|
final doneToday =
|
||||||
|
lastDate != null &&
|
||||||
lastDate.year == today.year &&
|
lastDate.year == today.year &&
|
||||||
lastDate.month == today.month &&
|
lastDate.month == today.month &&
|
||||||
lastDate.day == today.day;
|
lastDate.day == today.day;
|
||||||
@ -61,7 +65,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
if (saved != null && !_hasAutoResumed) {
|
if (saved != null && !_hasAutoResumed) {
|
||||||
_hasAutoResumed = true;
|
_hasAutoResumed = true;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _openWorkout(resume: true);
|
if (mounted) unawaited(_openWorkout(resume: true));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -70,8 +74,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
Future<void> _openWorkout({bool resume = false}) async {
|
Future<void> _openWorkout({bool resume = false}) async {
|
||||||
final storage = StorageService.instance;
|
final storage = StorageService.instance;
|
||||||
Map<String, dynamic>? savedState;
|
Map<String, dynamic>? savedState;
|
||||||
String type = _nextType;
|
var type = _nextType;
|
||||||
List<Exercise> exercises = _exercises!;
|
var exercises = _exercises;
|
||||||
|
|
||||||
if (resume && _savedSession != null) {
|
if (resume && _savedSession != null) {
|
||||||
savedState = _savedSession;
|
savedState = _savedSession;
|
||||||
@ -90,7 +94,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_load();
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -118,7 +122,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
builder: (_) => const SettingsScreen(),
|
builder: (_) => const SettingsScreen(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_load();
|
unawaited(_load());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -132,10 +136,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_WorkoutCard(
|
_WorkoutCard(
|
||||||
type: _nextType,
|
type: _nextType,
|
||||||
exercises: _exercises!,
|
exercises: _exercises,
|
||||||
doneToday: _doneToday,
|
doneToday: _doneToday,
|
||||||
hasActiveSession: _savedSession != null,
|
hasActiveSession: _savedSession != null,
|
||||||
onStart: () => _openWorkout(resume: false),
|
onStart: _openWorkout,
|
||||||
onResume: () => _openWorkout(resume: true),
|
onResume: () => _openWorkout(resume: true),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
@ -147,7 +151,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sub-widgets ────────────────────────────────────────────────────────────────
|
// ── Sub-widgets ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _WorkoutCard extends StatelessWidget {
|
class _WorkoutCard extends StatelessWidget {
|
||||||
const _WorkoutCard({
|
const _WorkoutCard({
|
||||||
|
|||||||
@ -8,7 +8,9 @@ import 'package:workout_app/models/exercise.dart';
|
|||||||
import 'package:workout_app/models/workout_plan.dart';
|
import 'package:workout_app/models/workout_plan.dart';
|
||||||
import 'package:workout_app/services/storage_service.dart';
|
import 'package:workout_app/services/storage_service.dart';
|
||||||
|
|
||||||
|
/// Screen for editing per-exercise thresholds and manual weight overrides.
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
|
/// Creates a [SettingsScreen].
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -16,7 +18,6 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
List<ExerciseState>? _states;
|
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
||||||
final Map<String, int> _successThresholds = {};
|
final Map<String, int> _successThresholds = {};
|
||||||
@ -29,7 +30,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_load();
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -44,7 +45,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final states = await StorageService.instance.getAllExerciseStates();
|
final states = await StorageService.instance.getAllExerciseStates();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_states = states;
|
|
||||||
for (final s in states) {
|
for (final s in states) {
|
||||||
_successThresholds[s.name] = s.successThreshold;
|
_successThresholds[s.name] = s.successThreshold;
|
||||||
_failThresholds[s.name] = s.failThreshold;
|
_failThresholds[s.name] = s.failThreshold;
|
||||||
@ -59,7 +59,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
setState(() => _weights[name] = value);
|
setState(() => _weights[name] = value);
|
||||||
_weightTimers[name]?.cancel();
|
_weightTimers[name]?.cancel();
|
||||||
_weightTimers[name] = Timer(const Duration(milliseconds: 600), () {
|
_weightTimers[name] = Timer(const Duration(milliseconds: 600), () {
|
||||||
StorageService.instance.setExerciseWeight(name, value);
|
unawaited(StorageService.instance.setExerciseWeight(name, value));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,13 +92,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child:
|
child: const Text(
|
||||||
const Text('Cancel', style: TextStyle(color: Colors.white70)),
|
'Cancel',
|
||||||
|
style: TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child:
|
child: const Text(
|
||||||
const Text('Reset', style: TextStyle(color: Colors.redAccent)),
|
'Reset',
|
||||||
|
style: TextStyle(color: Colors.redAccent),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -113,10 +117,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
List<String> get _orderedNames {
|
List<String> get _orderedNames {
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
return [...workoutA, ...workoutB]
|
return [
|
||||||
.map((e) => e.name)
|
...workoutA,
|
||||||
.where(seen.add)
|
...workoutB,
|
||||||
.toList();
|
].map((e) => e.name).where(seen.add).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -3,26 +3,40 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:vibration/vibration.dart';
|
||||||
import 'package:workout_app/models/exercise.dart';
|
import 'package:workout_app/models/exercise.dart';
|
||||||
import 'package:workout_app/models/exercise_result.dart';
|
import 'package:workout_app/models/exercise_result.dart';
|
||||||
import 'package:workout_app/models/set_result.dart';
|
import 'package:workout_app/models/set_result.dart';
|
||||||
import 'package:workout_app/models/workout_session.dart';
|
import 'package:workout_app/models/workout_session.dart';
|
||||||
import 'package:workout_app/services/storage_service.dart';
|
import 'package:workout_app/services/storage_service.dart';
|
||||||
import 'package:workout_app/services/sync_service.dart';
|
import 'package:workout_app/services/sync_service.dart';
|
||||||
|
import 'package:workout_app/widgets/break_banner.dart';
|
||||||
import 'package:workout_app/widgets/exercise_tile.dart';
|
import 'package:workout_app/widgets/exercise_tile.dart';
|
||||||
import 'package:workout_app/widgets/workout_summary_dialog.dart';
|
import 'package:workout_app/widgets/workout_summary_dialog.dart';
|
||||||
|
|
||||||
|
const _successBreakSecs = 180; // 3 min after successful set
|
||||||
|
const _failBreakSecs = 300; // 5 min after failed set
|
||||||
|
const _warmupBreakSecs = 180; // 3 min after warmup
|
||||||
|
|
||||||
|
/// Screen that drives an active workout session with per-rep tracking.
|
||||||
class WorkoutScreen extends StatefulWidget {
|
class WorkoutScreen extends StatefulWidget {
|
||||||
|
/// Creates a [WorkoutScreen].
|
||||||
const WorkoutScreen({
|
const WorkoutScreen({
|
||||||
super.key,
|
|
||||||
required this.workoutType,
|
required this.workoutType,
|
||||||
required this.exercises,
|
required this.exercises,
|
||||||
|
super.key,
|
||||||
this.savedState,
|
this.savedState,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 'A' or 'B' — used for history and progression.
|
||||||
final String workoutType;
|
final String workoutType;
|
||||||
|
|
||||||
|
/// Ordered list of exercises for this session.
|
||||||
final List<Exercise> exercises;
|
final List<Exercise> exercises;
|
||||||
|
|
||||||
|
/// Serialized state to restore (crash-recovery); null for a fresh session.
|
||||||
final Map<String, dynamic>? savedState;
|
final Map<String, dynamic>? savedState;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -39,6 +53,18 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
|
|
||||||
Map<String, ExerciseState> _exerciseStates = {};
|
Map<String, ExerciseState> _exerciseStates = {};
|
||||||
|
|
||||||
|
// Break state
|
||||||
|
int _breakRemaining = 0;
|
||||||
|
int _breakDurationSecs = 0;
|
||||||
|
DateTime? _breakStartTime;
|
||||||
|
Timer? _breakTimer;
|
||||||
|
String _breakLabel = '';
|
||||||
|
int _breakForExIdx = -1;
|
||||||
|
int _breakForSetIdx = -1; // -1 = warmup break
|
||||||
|
|
||||||
|
bool get _inBreak => _breakRemaining > 0;
|
||||||
|
|
||||||
|
final _audio = AudioPlayer();
|
||||||
final _sync = SyncService();
|
final _sync = SyncService();
|
||||||
bool _finished = false;
|
bool _finished = false;
|
||||||
|
|
||||||
@ -54,7 +80,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
setState(() => _elapsed = DateTime.now().difference(_startTime));
|
setState(() => _elapsed = DateTime.now().difference(_startTime));
|
||||||
});
|
});
|
||||||
_loadExerciseStates();
|
unawaited(_loadExerciseStates());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initFresh() {
|
void _initFresh() {
|
||||||
@ -79,6 +105,22 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
.map((row) => (row as List).cast<int>())
|
.map((row) => (row as List).cast<int>())
|
||||||
.toList();
|
.toList();
|
||||||
_warmupTapped = (s['warmupTapped'] as List).cast<bool>();
|
_warmupTapped = (s['warmupTapped'] as List).cast<bool>();
|
||||||
|
|
||||||
|
final breakEndMs = s['breakEndMs'] as int? ?? 0;
|
||||||
|
final breakDur = s['breakDurationSecs'] as int? ?? 0;
|
||||||
|
if (breakEndMs > 0 && breakDur > 0) {
|
||||||
|
final endTime = DateTime.fromMillisecondsSinceEpoch(breakEndMs);
|
||||||
|
final remaining = endTime.difference(DateTime.now()).inSeconds;
|
||||||
|
if (remaining > 0) {
|
||||||
|
_breakForExIdx = s['breakForExIdx'] as int? ?? -1;
|
||||||
|
_breakForSetIdx = s['breakForSetIdx'] as int? ?? -1;
|
||||||
|
_breakLabel = s['breakLabel'] as String? ?? 'Rest';
|
||||||
|
_breakDurationSecs = breakDur;
|
||||||
|
_breakStartTime = endTime.subtract(Duration(seconds: breakDur));
|
||||||
|
_breakRemaining = remaining;
|
||||||
|
_breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadExerciseStates() async {
|
Future<void> _loadExerciseStates() async {
|
||||||
@ -93,6 +135,8 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_elapsedTimer.cancel();
|
_elapsedTimer.cancel();
|
||||||
|
_breakTimer?.cancel();
|
||||||
|
unawaited(_audio.dispose());
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,6 +149,15 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
'tapped': _tapped,
|
'tapped': _tapped,
|
||||||
'doneReps': _doneReps,
|
'doneReps': _doneReps,
|
||||||
'warmupTapped': _warmupTapped,
|
'warmupTapped': _warmupTapped,
|
||||||
|
'breakForExIdx': _breakForExIdx,
|
||||||
|
'breakForSetIdx': _breakForSetIdx,
|
||||||
|
'breakLabel': _breakLabel,
|
||||||
|
'breakDurationSecs': _breakDurationSecs,
|
||||||
|
'breakEndMs': _breakStartTime != null
|
||||||
|
? _breakStartTime!
|
||||||
|
.add(Duration(seconds: _breakDurationSecs))
|
||||||
|
.millisecondsSinceEpoch
|
||||||
|
: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,34 +171,143 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
|
|
||||||
bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t));
|
bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t));
|
||||||
|
|
||||||
|
/// True when [setIdx] is the last untapped set of exercise [exIdx].
|
||||||
|
bool _isLastSetOfExercise(int exIdx, int setIdx) {
|
||||||
|
final sets = widget.exercises[exIdx].sets;
|
||||||
|
for (var s = 0; s < sets; s++) {
|
||||||
|
if (s != setIdx && !_tapped[exIdx][s]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Interaction ────────────────────────────────────────────────────────────
|
// ── Interaction ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
void _tapCircle(int exIdx, int repIdx) {
|
void _tapCircle(int exIdx, int setIdx) {
|
||||||
if (_finished) return;
|
if (_finished) return;
|
||||||
|
|
||||||
|
final wasNotTapped = !_tapped[exIdx][setIdx];
|
||||||
|
if (wasNotTapped && _inBreak) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (!_tapped[exIdx][repIdx]) {
|
if (wasNotTapped) {
|
||||||
_tapped[exIdx][repIdx] = true;
|
_tapped[exIdx][setIdx] = true;
|
||||||
} else {
|
} else {
|
||||||
_doneReps[exIdx][repIdx] =
|
_doneReps[exIdx][setIdx] =
|
||||||
(_doneReps[exIdx][repIdx] - 1).clamp(0, 999);
|
(_doneReps[exIdx][setIdx] - 1).clamp(0, 999);
|
||||||
|
_recomputeBreakIfNeeded(exIdx, setIdx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_saveActiveSession();
|
|
||||||
|
if (wasNotTapped) {
|
||||||
|
final isLastSet = _isLastSetOfExercise(exIdx, setIdx);
|
||||||
|
if (!isLastSet) {
|
||||||
|
final succeeded =
|
||||||
|
_doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
|
||||||
|
_startBreak(
|
||||||
|
succeeded ? _successBreakSecs : _failBreakSecs,
|
||||||
|
succeeded
|
||||||
|
? 'Rest (3 min — well done!)'
|
||||||
|
: 'Rest (5 min — keep going!)',
|
||||||
|
exIdx,
|
||||||
|
setIdx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(_saveActiveSession());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _tapWarmup(int exIdx) {
|
void _tapWarmup(int exIdx) {
|
||||||
if (_finished || _warmupTapped[exIdx]) return;
|
if (_finished || _warmupTapped[exIdx]) return;
|
||||||
setState(() => _warmupTapped[exIdx] = true);
|
setState(() => _warmupTapped[exIdx] = true);
|
||||||
_saveActiveSession();
|
if (!_inBreak) {
|
||||||
|
_startBreak(_warmupBreakSecs, 'Warmup rest (3 min)', exIdx, -1);
|
||||||
|
}
|
||||||
|
unawaited(_saveActiveSession());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resetCircle(int exIdx, int repIdx) {
|
void _resetCircle(int exIdx, int setIdx) {
|
||||||
if (_finished) return;
|
if (_finished) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_tapped[exIdx][repIdx] = false;
|
_tapped[exIdx][setIdx] = false;
|
||||||
_doneReps[exIdx][repIdx] = widget.exercises[exIdx].reps;
|
_doneReps[exIdx][setIdx] = widget.exercises[exIdx].reps;
|
||||||
});
|
});
|
||||||
_saveActiveSession();
|
if (_breakForExIdx == exIdx && _breakForSetIdx == setIdx) {
|
||||||
|
_cancelBreak();
|
||||||
|
}
|
||||||
|
unawaited(_saveActiveSession());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Break management ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _startBreak(int secs, String label, int exIdx, int setIdx) {
|
||||||
|
_breakTimer?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_breakDurationSecs = secs;
|
||||||
|
_breakRemaining = secs;
|
||||||
|
_breakLabel = label;
|
||||||
|
_breakForExIdx = exIdx;
|
||||||
|
_breakForSetIdx = setIdx;
|
||||||
|
_breakStartTime = DateTime.now();
|
||||||
|
});
|
||||||
|
_breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tickBreak(Timer t) {
|
||||||
|
setState(() => _breakRemaining--);
|
||||||
|
if (_breakRemaining <= 0) {
|
||||||
|
t.cancel();
|
||||||
|
unawaited(_onBreakFinished());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelBreak() {
|
||||||
|
_breakTimer?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_breakRemaining = 0;
|
||||||
|
_breakForExIdx = -1;
|
||||||
|
_breakForSetIdx = -1;
|
||||||
|
_breakStartTime = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _skipBreak() {
|
||||||
|
_cancelBreak();
|
||||||
|
unawaited(_saveActiveSession());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the user decrements reps on the set that triggered the current break,
|
||||||
|
/// switch between 3-min (success) and 5-min (fail) durations.
|
||||||
|
void _recomputeBreakIfNeeded(int exIdx, int setIdx) {
|
||||||
|
if (!_inBreak) return;
|
||||||
|
if (_breakForExIdx != exIdx || _breakForSetIdx != setIdx) return;
|
||||||
|
if (_breakForSetIdx == -1) return; // warmup break, never recompute
|
||||||
|
|
||||||
|
final succeeded =
|
||||||
|
_doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
|
||||||
|
final newDuration = succeeded ? _successBreakSecs : _failBreakSecs;
|
||||||
|
if (newDuration == _breakDurationSecs) return;
|
||||||
|
|
||||||
|
final elapsed = DateTime.now().difference(_breakStartTime!).inSeconds;
|
||||||
|
final newRemaining = (newDuration - elapsed).clamp(0, newDuration);
|
||||||
|
|
||||||
|
_breakDurationSecs = newDuration;
|
||||||
|
_breakRemaining = newRemaining;
|
||||||
|
_breakLabel =
|
||||||
|
succeeded ? 'Rest (3 min — well done!)' : 'Rest (5 min — keep going!)';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onBreakFinished() async {
|
||||||
|
await _audio.play(AssetSource('sounds/break_end.mp3')).catchError((_) {});
|
||||||
|
if (await Vibration.hasVibrator()) {
|
||||||
|
unawaited(Vibration.vibrate(duration: 800));
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_breakForExIdx = -1;
|
||||||
|
_breakForSetIdx = -1;
|
||||||
|
_breakStartTime = null;
|
||||||
|
});
|
||||||
|
unawaited(_saveActiveSession());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onThresholdChanged(
|
Future<void> _onThresholdChanged(
|
||||||
@ -191,8 +353,10 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child:
|
child: const Text(
|
||||||
const Text('Cancel', style: TextStyle(color: Colors.white70)),
|
'Cancel',
|
||||||
|
style: TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
@ -223,13 +387,17 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child:
|
child: const Text(
|
||||||
const Text('Cancel', style: TextStyle(color: Colors.white70)),
|
'Cancel',
|
||||||
|
style: TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child:
|
child: const Text(
|
||||||
const Text('Reset', style: TextStyle(color: Colors.redAccent)),
|
'Reset',
|
||||||
|
style: TextStyle(color: Colors.redAccent),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -242,25 +410,28 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
|
|
||||||
Future<void> _finishWorkout() async {
|
Future<void> _finishWorkout() async {
|
||||||
_elapsedTimer.cancel();
|
_elapsedTimer.cancel();
|
||||||
|
_breakTimer?.cancel();
|
||||||
setState(() => _finished = true);
|
setState(() => _finished = true);
|
||||||
|
|
||||||
final endTime = DateTime.now();
|
final endTime = DateTime.now();
|
||||||
final results = <ExerciseResult>[];
|
final results = <ExerciseResult>[];
|
||||||
|
|
||||||
for (int i = 0; i < widget.exercises.length; i++) {
|
for (var i = 0; i < widget.exercises.length; i++) {
|
||||||
final ex = widget.exercises[i];
|
final ex = widget.exercises[i];
|
||||||
results.add(ExerciseResult(
|
results.add(
|
||||||
exercise: ex,
|
ExerciseResult(
|
||||||
warmupDone: _warmupTapped[i],
|
exercise: ex,
|
||||||
sets: List.generate(
|
warmupDone: _warmupTapped[i],
|
||||||
ex.sets,
|
sets: List.generate(
|
||||||
(s) => SetResult(
|
ex.sets,
|
||||||
targetReps: ex.reps,
|
(s) => SetResult(
|
||||||
doneReps: _tapped[i][s] ? _doneReps[i][s] : 0,
|
targetReps: ex.reps,
|
||||||
weight: ex.weight,
|
doneReps: _tapped[i][s] ? _doneReps[i][s] : 0,
|
||||||
|
weight: ex.weight,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final session = WorkoutSession(
|
final session = WorkoutSession(
|
||||||
@ -293,21 +464,25 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
final syncResult = await _sync.writeWorkoutResult(session);
|
final syncResult = await _sync.writeWorkoutResult(session);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showDialog<void>(
|
unawaited(
|
||||||
context: context,
|
showDialog<void>(
|
||||||
barrierDismissible: false,
|
context: context,
|
||||||
builder: (_) => WorkoutSummaryDialog(
|
barrierDismissible: false,
|
||||||
session: session,
|
builder: (_) => WorkoutSummaryDialog(
|
||||||
syncResult: syncResult,
|
session: session,
|
||||||
|
syncResult: syncResult,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build ──────────────────────────────────────────────────────────────────
|
// ── Build ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PopScope(
|
return PopScope(
|
||||||
|
// Explicit `canPop: true` makes it clear this scope never blocks the back
|
||||||
|
// button — a future reader must not assume the default silently.
|
||||||
// ignore: avoid_redundant_argument_values
|
// ignore: avoid_redundant_argument_values
|
||||||
canPop: true,
|
canPop: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
@ -322,7 +497,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
if (!_finished)
|
if (!_finished)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => _confirmReset(),
|
onPressed: _confirmReset,
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Reset',
|
'Reset',
|
||||||
style: TextStyle(color: Colors.redAccent),
|
style: TextStyle(color: Colors.redAccent),
|
||||||
@ -334,35 +509,46 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
'Finish',
|
'Finish',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color:
|
color: _allSetsCompleted ? Colors.greenAccent : Colors.grey,
|
||||||
_allSetsCompleted ? Colors.greenAccent : Colors.grey,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView.separated(
|
body: Column(
|
||||||
padding: const EdgeInsets.all(12),
|
children: [
|
||||||
itemCount: widget.exercises.length,
|
if (_inBreak)
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
BreakBanner(
|
||||||
itemBuilder: (_, i) {
|
breakRemaining: _breakRemaining,
|
||||||
final exName = widget.exercises[i].name;
|
breakLabel: _breakLabel,
|
||||||
final state = _exerciseStates[exName];
|
onSkip: _skipBreak,
|
||||||
return ExerciseTile(
|
),
|
||||||
exercise: widget.exercises[i],
|
Expanded(
|
||||||
tapped: _tapped[i],
|
child: ListView.separated(
|
||||||
doneReps: _doneReps[i],
|
padding: const EdgeInsets.all(12),
|
||||||
warmupTapped: _warmupTapped[i],
|
itemCount: widget.exercises.length,
|
||||||
successThreshold: state?.successThreshold ?? 3,
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
failThreshold: state?.failThreshold ?? 2,
|
itemBuilder: (_, i) {
|
||||||
onTapCircle: (s) => _tapCircle(i, s),
|
final exName = widget.exercises[i].name;
|
||||||
onLongPressCircle: (s) => _resetCircle(i, s),
|
final state = _exerciseStates[exName];
|
||||||
onTapWarmup: () => _tapWarmup(i),
|
return ExerciseTile(
|
||||||
onThresholdChanged: (success, fail) =>
|
exercise: widget.exercises[i],
|
||||||
_onThresholdChanged(exName, success, fail),
|
tapped: _tapped[i],
|
||||||
);
|
doneReps: _doneReps[i],
|
||||||
},
|
warmupTapped: _warmupTapped[i],
|
||||||
|
successThreshold: state?.successThreshold ?? 3,
|
||||||
|
failThreshold: state?.failThreshold ?? 2,
|
||||||
|
onTapCircle: (s) => _tapCircle(i, s),
|
||||||
|
onLongPressCircle: (s) => _resetCircle(i, s),
|
||||||
|
onTapWarmup: () => _tapWarmup(i),
|
||||||
|
onThresholdChanged: (success, fail) =>
|
||||||
|
_onThresholdChanged(exName, success, fail),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,8 +16,11 @@ import 'package:workout_app/services/sync_service.dart';
|
|||||||
/// Port the HTTP server listens on. Must match the constant on the PC side.
|
/// Port the HTTP server listens on. Must match the constant on the PC side.
|
||||||
const int kWorkoutServerPort = 8765;
|
const int kWorkoutServerPort = 8765;
|
||||||
|
|
||||||
|
/// Singleton HTTP server that serves the latest workout JSON over LAN.
|
||||||
class HttpServerService {
|
class HttpServerService {
|
||||||
HttpServerService._();
|
HttpServerService._();
|
||||||
|
|
||||||
|
/// Singleton instance.
|
||||||
static final HttpServerService instance = HttpServerService._();
|
static final HttpServerService instance = HttpServerService._();
|
||||||
|
|
||||||
HttpServer? _server;
|
HttpServer? _server;
|
||||||
@ -38,14 +41,22 @@ class HttpServerService {
|
|||||||
return addrs;
|
return addrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateLatestWorkout(String json) => _latestJson = json;
|
/// The most recent workout JSON served at /workout, or null if none.
|
||||||
|
String? get latestWorkout => _latestJson;
|
||||||
|
|
||||||
|
/// Updates the JSON payload served at /workout.
|
||||||
|
set latestWorkout(String json) => _latestJson = json;
|
||||||
|
|
||||||
|
/// Starts the HTTP server, loading the last saved workout from disk first.
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
if (_server != null) return; // already running
|
if (_server != null) return; // already running
|
||||||
await _loadFromDisk();
|
await _loadFromDisk();
|
||||||
try {
|
try {
|
||||||
_server = await HttpServer.bind(InternetAddress.anyIPv4, kWorkoutServerPort);
|
_server = await HttpServer.bind(
|
||||||
_serve();
|
InternetAddress.anyIPv4,
|
||||||
|
kWorkoutServerPort,
|
||||||
|
);
|
||||||
|
unawaited(_serve());
|
||||||
} on SocketException {
|
} on SocketException {
|
||||||
// Port already in use or binding failed — not fatal.
|
// Port already in use or binding failed — not fatal.
|
||||||
_server = null;
|
_server = null;
|
||||||
@ -64,7 +75,7 @@ class HttpServerService {
|
|||||||
}
|
}
|
||||||
for (final path in candidates) {
|
for (final path in candidates) {
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
if (await file.exists()) {
|
if (file.existsSync()) {
|
||||||
try {
|
try {
|
||||||
_latestJson = await file.readAsString();
|
_latestJson = await file.readAsString();
|
||||||
return;
|
return;
|
||||||
@ -97,6 +108,7 @@ class HttpServerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stops the HTTP server.
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
await _server?.close(force: true);
|
await _server?.close(force: true);
|
||||||
_server = null;
|
_server = null;
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
/// Persistent storage for exercise progression state using SQLite.
|
/// Persistent storage for exercise progression state using SQLite.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:workout_app/models/exercise.dart';
|
import 'package:workout_app/models/exercise.dart';
|
||||||
import 'package:workout_app/models/workout_plan.dart';
|
import 'package:workout_app/models/workout_plan.dart';
|
||||||
|
import 'package:workout_app/services/backup_service.dart';
|
||||||
|
|
||||||
/// Per-exercise progression state stored in SQLite.
|
/// Per-exercise progression state stored in SQLite.
|
||||||
class ExerciseState {
|
class ExerciseState {
|
||||||
|
/// Creates an [ExerciseState] with all required progression fields.
|
||||||
ExerciseState({
|
ExerciseState({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.weight,
|
required this.weight,
|
||||||
@ -20,23 +24,42 @@ class ExerciseState {
|
|||||||
required this.failThreshold,
|
required this.failThreshold,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Exercise name (matches [Exercise.name], used as primary key).
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
|
/// Current working weight in kg.
|
||||||
double weight;
|
double weight;
|
||||||
|
|
||||||
|
/// Current target reps per set.
|
||||||
int reps;
|
int reps;
|
||||||
|
|
||||||
|
/// Consecutive successful workouts since last progression.
|
||||||
int successStreak;
|
int successStreak;
|
||||||
|
|
||||||
|
/// Consecutive failed workouts since last regression.
|
||||||
int failStreak;
|
int failStreak;
|
||||||
|
|
||||||
|
/// Weight cap; reps increase instead of weight when this is reached.
|
||||||
final double maxWeight;
|
final double maxWeight;
|
||||||
|
|
||||||
|
/// Successes needed in a row before weight/reps increase.
|
||||||
int successThreshold;
|
int successThreshold;
|
||||||
|
|
||||||
|
/// Failures needed in a row before weight decreases.
|
||||||
int failThreshold;
|
int failThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Singleton SQLite service for workout data persistence.
|
||||||
class StorageService {
|
class StorageService {
|
||||||
StorageService._();
|
StorageService._();
|
||||||
static StorageService? _instance;
|
static StorageService? _instance;
|
||||||
|
|
||||||
|
/// Returns the initialized singleton; throws if [init] was not called first.
|
||||||
static StorageService get instance => _instance!;
|
static StorageService get instance => _instance!;
|
||||||
|
|
||||||
late Database _db;
|
late Database _db;
|
||||||
|
|
||||||
|
/// Initializes the singleton and opens the database (idempotent).
|
||||||
static Future<StorageService> init() async {
|
static Future<StorageService> init() async {
|
||||||
if (_instance != null) return _instance!;
|
if (_instance != null) return _instance!;
|
||||||
final svc = StorageService._();
|
final svc = StorageService._();
|
||||||
@ -45,8 +68,22 @@ class StorageService {
|
|||||||
return svc;
|
return svc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overrides the DB path for unit tests (set by resetForTesting).
|
||||||
|
static String? _testDbPath;
|
||||||
|
|
||||||
|
/// Resets the singleton so [init] can be called again in tests.
|
||||||
|
///
|
||||||
|
/// Also switches to an in-memory database so each test starts with a clean
|
||||||
|
/// slate and file-based data from other tests does not leak in.
|
||||||
|
@visibleForTesting
|
||||||
|
static void resetForTesting() {
|
||||||
|
_instance = null;
|
||||||
|
_testDbPath = ':memory:';
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _open() async {
|
Future<void> _open() async {
|
||||||
final dbPath = p.join(await getDatabasesPath(), 'workout_app.db');
|
final dbPath =
|
||||||
|
_testDbPath ?? p.join(await getDatabasesPath(), 'workout_app.db');
|
||||||
_db = await openDatabase(
|
_db = await openDatabase(
|
||||||
dbPath,
|
dbPath,
|
||||||
version: 3,
|
version: 3,
|
||||||
@ -100,15 +137,18 @@ class StorageService {
|
|||||||
) async {
|
) async {
|
||||||
if (oldVersion < 2) {
|
if (oldVersion < 2) {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'ALTER TABLE exercise_state ADD COLUMN success_threshold INTEGER NOT NULL DEFAULT 3',
|
'ALTER TABLE exercise_state '
|
||||||
|
'ADD COLUMN success_threshold INTEGER NOT NULL DEFAULT 3',
|
||||||
);
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'ALTER TABLE exercise_state ADD COLUMN fail_threshold INTEGER NOT NULL DEFAULT 2',
|
'ALTER TABLE exercise_state '
|
||||||
|
'ADD COLUMN fail_threshold INTEGER NOT NULL DEFAULT 2',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (oldVersion < 3) {
|
if (oldVersion < 3) {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
'CREATE TABLE IF NOT EXISTS settings '
|
||||||
|
'(key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
||||||
);
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'CREATE TABLE IF NOT EXISTS active_session '
|
'CREATE TABLE IF NOT EXISTS active_session '
|
||||||
@ -147,7 +187,7 @@ class StorageService {
|
|||||||
where: 'key = ?',
|
where: 'key = ?',
|
||||||
whereArgs: [key],
|
whereArgs: [key],
|
||||||
);
|
);
|
||||||
return rows.isEmpty ? null : rows.first['value'] as String;
|
return rows.isEmpty ? null : rows.first['value']! as String;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setSetting(String key, String value) async {
|
Future<void> _setSetting(String key, String value) async {
|
||||||
@ -158,17 +198,21 @@ class StorageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns 'A' or 'B' — the type that should be done next.
|
||||||
Future<String> getNextWorkoutType() async {
|
Future<String> getNextWorkoutType() async {
|
||||||
final last = await _getSetting('last_workout_type');
|
final last = await _getSetting('last_workout_type');
|
||||||
return last == 'A' ? 'B' : 'A';
|
return last == 'A' ? 'B' : 'A';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists [type] as the most recently completed workout type.
|
||||||
Future<void> setLastWorkoutType(String type) async {
|
Future<void> setLastWorkoutType(String type) async {
|
||||||
await _setSetting('last_workout_type', type);
|
await _setSetting('last_workout_type', type);
|
||||||
|
unawaited(_backupNow());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Active session (crash / exit recovery) ─────────────────────────────────
|
// ── Active session (crash / exit recovery) ─────────────────────────────────
|
||||||
|
|
||||||
|
/// Persists [data] as the currently active (in-progress) session.
|
||||||
Future<void> saveActiveSession(Map<String, dynamic> data) async {
|
Future<void> saveActiveSession(Map<String, dynamic> data) async {
|
||||||
await _db.insert(
|
await _db.insert(
|
||||||
'active_session',
|
'active_session',
|
||||||
@ -177,18 +221,21 @@ class StorageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the saved active session, or null if none exists.
|
||||||
Future<Map<String, dynamic>?> loadActiveSession() async {
|
Future<Map<String, dynamic>?> loadActiveSession() async {
|
||||||
final rows = await _db.query('active_session', where: 'id = 1');
|
final rows = await _db.query('active_session', where: 'id = 1');
|
||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
return jsonDecode(rows.first['json'] as String) as Map<String, dynamic>;
|
return jsonDecode(rows.first['json']! as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes the active session record (called after a session is committed).
|
||||||
Future<void> clearActiveSession() async {
|
Future<void> clearActiveSession() async {
|
||||||
await _db.delete('active_session', where: 'id = 1');
|
await _db.delete('active_session', where: 'id = 1');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Exercise state ─────────────────────────────────────────────────────────
|
// ── Exercise state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns the progression state for [name], or null if not found.
|
||||||
Future<ExerciseState?> getExerciseState(String name) async {
|
Future<ExerciseState?> getExerciseState(String name) async {
|
||||||
final rows = await _db.query(
|
final rows = await _db.query(
|
||||||
'exercise_state',
|
'exercise_state',
|
||||||
@ -198,17 +245,18 @@ class StorageService {
|
|||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
final r = rows.first;
|
final r = rows.first;
|
||||||
return ExerciseState(
|
return ExerciseState(
|
||||||
name: r['name'] as String,
|
name: r['name']! as String,
|
||||||
weight: r['weight'] as double,
|
weight: r['weight']! as double,
|
||||||
reps: r['reps'] as int,
|
reps: r['reps']! as int,
|
||||||
successStreak: r['success_streak'] as int,
|
successStreak: r['success_streak']! as int,
|
||||||
failStreak: r['fail_streak'] as int,
|
failStreak: r['fail_streak']! as int,
|
||||||
maxWeight: r['max_weight'] as double,
|
maxWeight: r['max_weight']! as double,
|
||||||
successThreshold: r['success_threshold'] as int? ?? 3,
|
successThreshold: r['success_threshold'] as int? ?? 3,
|
||||||
failThreshold: r['fail_threshold'] as int? ?? 2,
|
failThreshold: r['fail_threshold'] as int? ?? 2,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns progression states for every exercise across both plans.
|
||||||
Future<List<ExerciseState>> getAllExerciseStates() async {
|
Future<List<ExerciseState>> getAllExerciseStates() async {
|
||||||
final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet();
|
final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet();
|
||||||
final states = <ExerciseState>[];
|
final states = <ExerciseState>[];
|
||||||
@ -219,6 +267,7 @@ class StorageService {
|
|||||||
return states;
|
return states;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the streak thresholds for exercise [name].
|
||||||
Future<void> setExerciseThresholds(
|
Future<void> setExerciseThresholds(
|
||||||
String name, {
|
String name, {
|
||||||
required int successThreshold,
|
required int successThreshold,
|
||||||
@ -235,6 +284,7 @@ class StorageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the working weight for [name], resetting streaks.
|
||||||
Future<void> setExerciseWeight(String name, double weight) async {
|
Future<void> setExerciseWeight(String name, double weight) async {
|
||||||
await _db.update(
|
await _db.update(
|
||||||
'exercise_state',
|
'exercise_state',
|
||||||
@ -242,8 +292,10 @@ class StorageService {
|
|||||||
where: 'name = ?',
|
where: 'name = ?',
|
||||||
whereArgs: [name],
|
whereArgs: [name],
|
||||||
);
|
);
|
||||||
|
unawaited(_backupNow());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns exercises for [workoutType] with weights/reps from stored state.
|
||||||
Future<List<Exercise>> getCurrentExercises(String workoutType) async {
|
Future<List<Exercise>> getCurrentExercises(String workoutType) async {
|
||||||
final template = workoutType == 'A' ? workoutA : workoutB;
|
final template = workoutType == 'A' ? workoutA : workoutB;
|
||||||
final result = <Exercise>[];
|
final result = <Exercise>[];
|
||||||
@ -258,6 +310,7 @@ class StorageService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Applies progressive overload or regression based on [succeededExercises].
|
||||||
Future<void> applyProgression({
|
Future<void> applyProgression({
|
||||||
required Map<String, bool> succeededExercises,
|
required Map<String, bool> succeededExercises,
|
||||||
required DateTime lastWorkoutDate,
|
required DateTime lastWorkoutDate,
|
||||||
@ -270,8 +323,10 @@ class StorageService {
|
|||||||
if (state == null) continue;
|
if (state == null) continue;
|
||||||
|
|
||||||
if (hadBreak) {
|
if (hadBreak) {
|
||||||
final newWeight =
|
final newWeight = (state.weight - kWeightIncrement).clamp(
|
||||||
(state.weight - kWeightIncrement).clamp(0.0, state.maxWeight);
|
0.0,
|
||||||
|
state.maxWeight,
|
||||||
|
);
|
||||||
await _db.update(
|
await _db.update(
|
||||||
'exercise_state',
|
'exercise_state',
|
||||||
{'weight': newWeight, 'success_streak': 0, 'fail_streak': 0},
|
{'weight': newWeight, 'success_streak': 0, 'fail_streak': 0},
|
||||||
@ -284,15 +339,17 @@ class StorageService {
|
|||||||
if (entry.value) {
|
if (entry.value) {
|
||||||
final newStreak = state.successStreak + 1;
|
final newStreak = state.successStreak + 1;
|
||||||
final shouldProgress = newStreak >= state.successThreshold;
|
final shouldProgress = newStreak >= state.successThreshold;
|
||||||
double newWeight = state.weight;
|
var newWeight = state.weight;
|
||||||
int newReps = state.reps;
|
var newReps = state.reps;
|
||||||
|
|
||||||
if (shouldProgress) {
|
if (shouldProgress) {
|
||||||
if (state.weight >= state.maxWeight) {
|
if (state.weight >= state.maxWeight) {
|
||||||
newReps = state.reps + 1;
|
newReps = state.reps + 1;
|
||||||
} else {
|
} else {
|
||||||
newWeight =
|
newWeight = (state.weight + kWeightIncrement).clamp(
|
||||||
(state.weight + kWeightIncrement).clamp(0.0, state.maxWeight);
|
0.0,
|
||||||
|
state.maxWeight,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -328,6 +385,7 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists a completed session to the workout history table.
|
||||||
Future<void> saveSession({
|
Future<void> saveSession({
|
||||||
required String date,
|
required String date,
|
||||||
required String workoutType,
|
required String workoutType,
|
||||||
@ -342,16 +400,19 @@ class StorageService {
|
|||||||
'succeeded': succeeded ? 1 : 0,
|
'succeeded': succeeded ? 1 : 0,
|
||||||
'json': json,
|
'json': json,
|
||||||
});
|
});
|
||||||
|
unawaited(_backupNow());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the date of the most recent completed session, or null.
|
||||||
Future<DateTime?> getLastWorkoutDate() async {
|
Future<DateTime?> getLastWorkoutDate() async {
|
||||||
final rows = await _db.rawQuery(
|
final rows = await _db.rawQuery(
|
||||||
'SELECT date FROM workout_history ORDER BY date DESC LIMIT 1',
|
'SELECT date FROM workout_history ORDER BY date DESC LIMIT 1',
|
||||||
);
|
);
|
||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
return DateTime.tryParse(rows.first['date'] as String);
|
return DateTime.tryParse(rows.first['date']! as String);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns up to [limit] rows from workout history, newest first.
|
||||||
Future<List<Map<String, dynamic>>> getWorkoutHistory({
|
Future<List<Map<String, dynamic>>> getWorkoutHistory({
|
||||||
int limit = 60,
|
int limit = 60,
|
||||||
}) async {
|
}) async {
|
||||||
@ -362,13 +423,15 @@ class StorageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all distinct workout dates (YYYY-MM-DD), newest first.
|
||||||
Future<List<String>> getAllWorkoutDates() async {
|
Future<List<String>> getAllWorkoutDates() async {
|
||||||
final rows = await _db.rawQuery(
|
final rows = await _db.rawQuery(
|
||||||
'SELECT DISTINCT date FROM workout_history ORDER BY date DESC',
|
'SELECT DISTINCT date FROM workout_history ORDER BY date DESC',
|
||||||
);
|
);
|
||||||
return rows.map((r) => r['date'] as String).toList();
|
return rows.map((r) => r['date']! as String).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resets [name] to its default weight and thresholds, clearing streaks.
|
||||||
Future<void> resetExerciseToDefaults(String name) async {
|
Future<void> resetExerciseToDefaults(String name) async {
|
||||||
final defaults = [...workoutA, ...workoutB].firstWhere(
|
final defaults = [...workoutA, ...workoutB].firstWhere(
|
||||||
(e) => e.name == name,
|
(e) => e.name == name,
|
||||||
@ -386,5 +449,62 @@ class StorageService {
|
|||||||
where: 'name = ?',
|
where: 'name = ?',
|
||||||
whereArgs: [name],
|
whereArgs: [name],
|
||||||
);
|
);
|
||||||
|
unawaited(_backupNow());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backup / restore ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Exports all persistent data to external storage as a JSON snapshot.
|
||||||
|
Future<void> _backupNow() async {
|
||||||
|
final exerciseRows = await _db.query('exercise_state');
|
||||||
|
final historyRows = await _db.query('workout_history');
|
||||||
|
final settingsRows = await _db.query('settings');
|
||||||
|
await BackupService.instance.export({
|
||||||
|
'exercise_state': exerciseRows,
|
||||||
|
'workout_history': historyRows,
|
||||||
|
'settings': settingsRows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores from backup if the local DB is empty (fresh install).
|
||||||
|
///
|
||||||
|
/// "Empty" means no workout history and no [last_workout_type] setting.
|
||||||
|
Future<void> restoreFromBackupIfNeeded() async {
|
||||||
|
final hasHistory =
|
||||||
|
(await _db.rawQuery('SELECT COUNT(*) AS c FROM workout_history'))
|
||||||
|
.first['c'] as int? ??
|
||||||
|
0;
|
||||||
|
final hasType = await _getSetting('last_workout_type');
|
||||||
|
if (hasHistory > 0 || hasType != null) return; // DB has real data
|
||||||
|
|
||||||
|
final backup = await BackupService.instance.readBackup();
|
||||||
|
if (backup == null) return;
|
||||||
|
|
||||||
|
await _db.transaction((txn) async {
|
||||||
|
for (final row in (backup['exercise_state'] as List? ?? [])
|
||||||
|
.cast<Map<String, dynamic>>()) {
|
||||||
|
await txn.insert(
|
||||||
|
'exercise_state',
|
||||||
|
row,
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final row in (backup['workout_history'] as List? ?? [])
|
||||||
|
.cast<Map<String, dynamic>>()) {
|
||||||
|
await txn.insert(
|
||||||
|
'workout_history',
|
||||||
|
row,
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final row in (backup['settings'] as List? ?? [])
|
||||||
|
.cast<Map<String, dynamic>>()) {
|
||||||
|
await txn.insert(
|
||||||
|
'settings',
|
||||||
|
row,
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
/// Writes workout result JSON to external storage (ADB) and the in-app HTTP server.
|
/// Writes workout result JSON to external storage (ADB) and the HTTP server.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@ -6,9 +6,10 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:workout_app/models/workout_session.dart';
|
import 'package:workout_app/models/workout_session.dart';
|
||||||
import 'package:workout_app/services/http_server_service.dart';
|
import 'package:workout_app/services/http_server_service.dart';
|
||||||
|
|
||||||
/// Path on the phone's external storage where the PC reads workout data via ADB.
|
/// Path on the phone's external storage where the PC reads workout data.
|
||||||
const String kSyncFilePath = '/sdcard/workout_result.json';
|
const String kSyncFilePath = '/sdcard/workout_result.json';
|
||||||
|
|
||||||
|
/// Handles writing completed workout sessions to disk and the HTTP server.
|
||||||
class SyncService {
|
class SyncService {
|
||||||
/// Writes [session] as JSON to external storage and updates the HTTP server.
|
/// Writes [session] as JSON to external storage and updates the HTTP server.
|
||||||
///
|
///
|
||||||
@ -17,13 +18,13 @@ class SyncService {
|
|||||||
final json = session.toJsonString();
|
final json = session.toJsonString();
|
||||||
|
|
||||||
// Always update the in-app HTTP server so the PC can read via WiFi.
|
// Always update the in-app HTTP server so the PC can read via WiFi.
|
||||||
HttpServerService.instance.updateLatestWorkout(json);
|
HttpServerService.instance.latestWorkout = json;
|
||||||
|
|
||||||
// Try the primary path first (/sdcard/ — ADB-accessible without root).
|
// Try the primary path first (/sdcard/ — ADB-accessible without root).
|
||||||
try {
|
try {
|
||||||
final file = File(kSyncFilePath);
|
final file = File(kSyncFilePath);
|
||||||
await file.writeAsString(json);
|
await file.writeAsString(json);
|
||||||
return SyncResult(success: true, path: kSyncFilePath);
|
return const SyncResult(success: true, path: kSyncFilePath);
|
||||||
} on Exception {
|
} on Exception {
|
||||||
// Fallback: app-specific external directory (still ADB accessible).
|
// Fallback: app-specific external directory (still ADB accessible).
|
||||||
}
|
}
|
||||||
@ -39,14 +40,25 @@ class SyncService {
|
|||||||
// Fallback failed.
|
// Fallback failed.
|
||||||
}
|
}
|
||||||
|
|
||||||
return SyncResult(success: false, path: null, error: 'No writable external path');
|
return const SyncResult(
|
||||||
|
success: false,
|
||||||
|
path: null,
|
||||||
|
error: 'No writable external path',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of a [SyncService.writeWorkoutResult] call.
|
||||||
class SyncResult {
|
class SyncResult {
|
||||||
|
/// Creates a sync result.
|
||||||
const SyncResult({required this.success, required this.path, this.error});
|
const SyncResult({required this.success, required this.path, this.error});
|
||||||
|
|
||||||
|
/// Whether the write succeeded.
|
||||||
final bool success;
|
final bool success;
|
||||||
|
|
||||||
|
/// Absolute path where the file was written, or null on failure.
|
||||||
final String? path;
|
final String? path;
|
||||||
|
|
||||||
|
/// Human-readable error message on failure.
|
||||||
final String? error;
|
final String? error;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,16 +3,23 @@ library;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Banner widget showing a break countdown and a skip button.
|
||||||
class BreakBanner extends StatelessWidget {
|
class BreakBanner extends StatelessWidget {
|
||||||
|
/// Creates a [BreakBanner].
|
||||||
const BreakBanner({
|
const BreakBanner({
|
||||||
super.key,
|
|
||||||
required this.breakRemaining,
|
required this.breakRemaining,
|
||||||
required this.breakLabel,
|
required this.breakLabel,
|
||||||
required this.onSkip,
|
required this.onSkip,
|
||||||
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Seconds remaining in the current break.
|
||||||
final int breakRemaining;
|
final int breakRemaining;
|
||||||
|
|
||||||
|
/// Display label for the break (e.g. 'Rest' or 'Warmup rest').
|
||||||
final String breakLabel;
|
final String breakLabel;
|
||||||
|
|
||||||
|
/// Called when the user taps the Skip button.
|
||||||
final VoidCallback onSkip;
|
final VoidCallback onSkip;
|
||||||
|
|
||||||
String _fmt(int secs) {
|
String _fmt(int secs) {
|
||||||
|
|||||||
@ -3,27 +3,44 @@ library;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Monthly calendar widget that highlights days with completed workouts.
|
||||||
class WorkoutCalendar extends StatelessWidget {
|
class WorkoutCalendar extends StatelessWidget {
|
||||||
|
/// Creates a [WorkoutCalendar].
|
||||||
const WorkoutCalendar({
|
const WorkoutCalendar({
|
||||||
super.key,
|
|
||||||
required this.workoutDates,
|
required this.workoutDates,
|
||||||
required this.month,
|
required this.month,
|
||||||
required this.onPrevMonth,
|
required this.onPrevMonth,
|
||||||
required this.onNextMonth,
|
required this.onNextMonth,
|
||||||
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Set of YYYY-MM-DD date strings that had at least one workout.
|
||||||
final Set<String> workoutDates;
|
final Set<String> workoutDates;
|
||||||
|
|
||||||
/// Only the year and month of this DateTime are used.
|
/// Only the year and month of this DateTime are used.
|
||||||
final DateTime month;
|
final DateTime month;
|
||||||
|
|
||||||
|
/// Called when the user taps the previous-month chevron.
|
||||||
final VoidCallback onPrevMonth;
|
final VoidCallback onPrevMonth;
|
||||||
|
|
||||||
|
/// Called when the user taps the next-month chevron.
|
||||||
final VoidCallback onNextMonth;
|
final VoidCallback onNextMonth;
|
||||||
|
|
||||||
static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||||
|
|
||||||
static const _monthNames = [
|
static const _monthNames = [
|
||||||
'January', 'February', 'March', 'April', 'May', 'June',
|
'January',
|
||||||
'July', 'August', 'September', 'October', 'November', 'December',
|
'February',
|
||||||
|
'March',
|
||||||
|
'April',
|
||||||
|
'May',
|
||||||
|
'June',
|
||||||
|
'July',
|
||||||
|
'August',
|
||||||
|
'September',
|
||||||
|
'October',
|
||||||
|
'November',
|
||||||
|
'December',
|
||||||
];
|
];
|
||||||
|
|
||||||
String _dateKey(int year, int m, int day) =>
|
String _dateKey(int year, int m, int day) =>
|
||||||
@ -35,7 +52,7 @@ class WorkoutCalendar extends StatelessWidget {
|
|||||||
final m = month.month;
|
final m = month.month;
|
||||||
final daysInMonth = DateTime(year, m + 1, 0).day;
|
final daysInMonth = DateTime(year, m + 1, 0).day;
|
||||||
// weekday: 1=Mon..7=Sun → offset 0..6
|
// weekday: 1=Mon..7=Sun → offset 0..6
|
||||||
final firstWeekday = DateTime(year, m, 1).weekday - 1;
|
final firstWeekday = DateTime(year, m).weekday - 1;
|
||||||
final totalCells = firstWeekday + daysInMonth;
|
final totalCells = firstWeekday + daysInMonth;
|
||||||
final rows = (totalCells / 7).ceil();
|
final rows = (totalCells / 7).ceil();
|
||||||
|
|
||||||
@ -123,8 +140,7 @@ class WorkoutCalendar extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: worked ? Colors.white : Colors.white38,
|
color: worked ? Colors.white : Colors.white38,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight:
|
fontWeight: worked ? FontWeight.bold : FontWeight.normal,
|
||||||
worked ? FontWeight.bold : FontWeight.normal,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,9 +5,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:workout_app/models/exercise.dart';
|
import 'package:workout_app/models/exercise.dart';
|
||||||
import 'package:workout_app/widgets/rep_circle.dart';
|
import 'package:workout_app/widgets/rep_circle.dart';
|
||||||
|
|
||||||
|
/// Card widget displaying warmup and working-set rep circles for one exercise.
|
||||||
class ExerciseTile extends StatelessWidget {
|
class ExerciseTile extends StatelessWidget {
|
||||||
|
/// Creates an [ExerciseTile].
|
||||||
const ExerciseTile({
|
const ExerciseTile({
|
||||||
super.key,
|
|
||||||
required this.exercise,
|
required this.exercise,
|
||||||
required this.tapped,
|
required this.tapped,
|
||||||
required this.doneReps,
|
required this.doneReps,
|
||||||
@ -18,19 +19,37 @@ class ExerciseTile extends StatelessWidget {
|
|||||||
required this.onLongPressCircle,
|
required this.onLongPressCircle,
|
||||||
required this.onTapWarmup,
|
required this.onTapWarmup,
|
||||||
required this.onThresholdChanged,
|
required this.onThresholdChanged,
|
||||||
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The exercise definition to display.
|
||||||
final Exercise exercise;
|
final Exercise exercise;
|
||||||
|
|
||||||
|
/// Per-set tap state; true when a set circle has been tapped.
|
||||||
final List<bool> tapped;
|
final List<bool> tapped;
|
||||||
|
|
||||||
|
/// Per-set rep count; may be less than target after repeated taps.
|
||||||
final List<int> doneReps;
|
final List<int> doneReps;
|
||||||
|
|
||||||
|
/// Whether the warmup circle has been tapped.
|
||||||
final bool warmupTapped;
|
final bool warmupTapped;
|
||||||
|
|
||||||
|
/// Success streak threshold shown in the inline settings row.
|
||||||
final int successThreshold;
|
final int successThreshold;
|
||||||
|
|
||||||
|
/// Fail streak threshold shown in the inline settings row.
|
||||||
final int failThreshold;
|
final int failThreshold;
|
||||||
|
|
||||||
|
/// Called when a working-set circle is tapped.
|
||||||
final void Function(int setIdx) onTapCircle;
|
final void Function(int setIdx) onTapCircle;
|
||||||
|
|
||||||
|
/// Called when a working-set circle is long-pressed (resets to neutral).
|
||||||
final void Function(int setIdx) onLongPressCircle;
|
final void Function(int setIdx) onLongPressCircle;
|
||||||
|
|
||||||
|
/// Called when the warmup circle is tapped.
|
||||||
final VoidCallback onTapWarmup;
|
final VoidCallback onTapWarmup;
|
||||||
|
|
||||||
/// Called when user changes thresholds inline; args are (newSuccess, newFail).
|
/// Called when the user changes thresholds inline (newSuccess, newFail).
|
||||||
final void Function(int success, int fail) onThresholdChanged;
|
final void Function(int success, int fail) onThresholdChanged;
|
||||||
|
|
||||||
bool get _allCompleted => tapped.every((t) => t);
|
bool get _allCompleted => tapped.every((t) => t);
|
||||||
@ -40,10 +59,9 @@ class ExerciseTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Color headerColor = Colors.grey.shade800;
|
var headerColor = Colors.grey.shade800;
|
||||||
if (_allCompleted) {
|
if (_allCompleted) {
|
||||||
headerColor =
|
headerColor = _allSucceeded ? Colors.green.shade800 : Colors.red.shade900;
|
||||||
_allSucceeded ? Colors.green.shade800 : Colors.red.shade900;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
@ -72,12 +90,14 @@ class ExerciseTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_WarmupRow(
|
if (exercise.hasWarmup) ...[
|
||||||
warmupWeight: exercise.warmupWeight,
|
_WarmupRow(
|
||||||
tapped: warmupTapped,
|
warmupWeight: exercise.warmupWeight,
|
||||||
onTap: onTapWarmup,
|
tapped: warmupTapped,
|
||||||
),
|
onTap: onTapWarmup,
|
||||||
const SizedBox(height: 10),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
@ -190,22 +210,22 @@ class _MiniStepper extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _btn(IconData icon, VoidCallback? onTap) => GestureDetector(
|
Widget _btn(IconData icon, VoidCallback? onTap) => GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 22,
|
height: 22,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade700,
|
color: Colors.grey.shade700,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: onTap != null ? Colors.white : Colors.white24,
|
color: onTap != null ? Colors.white : Colors.white24,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WarmupRow extends StatelessWidget {
|
class _WarmupRow extends StatelessWidget {
|
||||||
|
|||||||
@ -7,34 +7,53 @@
|
|||||||
/// failed – red, shows 0 (all reps deducted)
|
/// failed – red, shows 0 (all reps deducted)
|
||||||
///
|
///
|
||||||
/// Interaction:
|
/// Interaction:
|
||||||
/// single tap → neutral→success, success→partial(-1 rep), partial→partial(-1 rep),
|
/// single tap → neutral→success, success→partial(-1 rep),
|
||||||
/// failed stays failed
|
/// partial→partial(-1 rep), failed stays failed
|
||||||
/// long press → reset to neutral
|
/// long press → reset to neutral
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
enum RepCircleState { neutral, success, partial, failed }
|
/// Visual state of a [RepCircle].
|
||||||
|
enum RepCircleState {
|
||||||
|
/// Not yet tapped; shows target reps.
|
||||||
|
neutral,
|
||||||
|
|
||||||
|
/// All reps completed; green.
|
||||||
|
success,
|
||||||
|
|
||||||
|
/// Some reps completed; orange, shows actual count.
|
||||||
|
partial,
|
||||||
|
|
||||||
|
/// All reps deducted; red.
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tappable circle representing one working set of an exercise.
|
||||||
class RepCircle extends StatelessWidget {
|
class RepCircle extends StatelessWidget {
|
||||||
|
/// Creates a [RepCircle].
|
||||||
const RepCircle({
|
const RepCircle({
|
||||||
super.key,
|
|
||||||
required this.targetReps,
|
required this.targetReps,
|
||||||
required this.doneReps,
|
required this.doneReps,
|
||||||
required this.tapped,
|
required this.tapped,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onLongPress,
|
required this.onLongPress,
|
||||||
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Number of reps the user is aiming for this set.
|
||||||
final int targetReps;
|
final int targetReps;
|
||||||
|
|
||||||
/// Reps currently registered (may be < targetReps if user tapped multiple times).
|
/// Reps currently registered (may be < targetReps after repeated taps).
|
||||||
final int doneReps;
|
final int doneReps;
|
||||||
|
|
||||||
/// Whether this circle has been tapped at all (distinguishes neutral from success).
|
/// Whether this circle has been tapped at all (neutral vs success).
|
||||||
final bool tapped;
|
final bool tapped;
|
||||||
|
|
||||||
|
/// Called on a single tap.
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
/// Called on a long press (resets to neutral).
|
||||||
final VoidCallback onLongPress;
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
RepCircleState get _state {
|
RepCircleState get _state {
|
||||||
|
|||||||
@ -5,14 +5,19 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:workout_app/models/workout_session.dart';
|
import 'package:workout_app/models/workout_session.dart';
|
||||||
import 'package:workout_app/services/sync_service.dart';
|
import 'package:workout_app/services/sync_service.dart';
|
||||||
|
|
||||||
|
/// Dialog that summarises a completed workout and reports the sync status.
|
||||||
class WorkoutSummaryDialog extends StatelessWidget {
|
class WorkoutSummaryDialog extends StatelessWidget {
|
||||||
|
/// Creates a [WorkoutSummaryDialog].
|
||||||
const WorkoutSummaryDialog({
|
const WorkoutSummaryDialog({
|
||||||
super.key,
|
|
||||||
required this.session,
|
required this.session,
|
||||||
required this.syncResult,
|
required this.syncResult,
|
||||||
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The completed workout session to summarise.
|
||||||
final WorkoutSession session;
|
final WorkoutSession session;
|
||||||
|
|
||||||
|
/// Result of writing the session to disk/HTTP server.
|
||||||
final SyncResult syncResult;
|
final SyncResult syncResult;
|
||||||
|
|
||||||
String _fmt(Duration d) {
|
String _fmt(Duration d) {
|
||||||
|
|||||||
@ -200,6 +200,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -304,6 +312,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.18.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.19.1"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -545,10 +561,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_common
|
name: sqflite_common
|
||||||
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
|
sha256: cce558075afe2a83f3fd7fc123acd6b090683e4f23910d44fbb31ecd7800b014
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.8"
|
version: "2.5.9"
|
||||||
|
sqflite_common_ffi:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: sqflite_common_ffi
|
||||||
|
sha256: "3ddad0ec96ad411d5fea45b4912c3cd5743436c9e1890c26a6e688a32d901cae"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -565,6 +589,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
version: "2.4.0"
|
||||||
|
sqlite3:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlite3
|
||||||
|
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.3.2"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -593,10 +625,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: synchronized
|
name: synchronized
|
||||||
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
|
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.0+1"
|
version: "3.4.1"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -637,6 +669,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
very_good_analysis:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: very_good_analysis
|
||||||
|
sha256: d1cb1d66a5aae2c702d68caca6c8347306d35e728fd94555fa21fa0448a972e0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.2.0"
|
||||||
vibration:
|
vibration:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -8,20 +8,22 @@ environment:
|
|||||||
sdk: ^3.12.0
|
sdk: ^3.12.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
audioplayers: ^6.4.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
sqflite: ^2.4.2
|
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
shared_preferences: ^2.5.3
|
|
||||||
audioplayers: ^6.4.0
|
|
||||||
vibration: ^3.1.0
|
|
||||||
permission_handler: ^12.0.0
|
permission_handler: ^12.0.0
|
||||||
|
shared_preferences: ^2.5.3
|
||||||
|
sqflite: ^2.4.2
|
||||||
|
vibration: ^3.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
sqflite_common_ffi: ^2.4.1
|
||||||
|
very_good_analysis: ^10.2.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,2 +1,26 @@
|
|||||||
// Tests are written after the user approves functionality per project rules.
|
import 'package:flutter/material.dart';
|
||||||
void main() {}
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:workout_app/main.dart';
|
||||||
|
import 'package:workout_app/services/storage_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() {
|
||||||
|
sqfliteFfiInit();
|
||||||
|
databaseFactory = databaseFactoryFfi;
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
StorageService.resetForTesting();
|
||||||
|
await StorageService.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('WorkoutApp renders HomeScreen', (tester) async {
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.pumpWidget(const WorkoutApp());
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
});
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Workout Tracker'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user