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