Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion workout-logger/lib/screens/history_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,33 @@ class HistoryScreen extends StatelessWidget {
// Watch HistoryManager so the list rebuilds when hcSyncedAt changes.
final historyManager = context.watch<HistoryManager>();
final provider = context.read<WorkoutProvider>();
final settings = context.watch<SettingsProvider>();
final sessions = historyManager.sessions;

final hasUnsynced = settings.healthConnectEnabled &&
sessions.any((s) => s.hcSyncedAt == null);

return Scaffold(
appBar: AppBar(title: const Text('Workout History')),
appBar: AppBar(
title: const Text('Workout History'),
actions: [
if (hasUnsynced)
IconButton(
icon: const Icon(Icons.monitor_heart_outlined, color: _hcColor),
tooltip: 'Sync all to Health Connect',
onPressed: () {
historyManager.syncAllUnsynced();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Syncing all unsynced workouts…'),
backgroundColor: AppTheme.cardColor,
duration: Duration(seconds: 2),
),
);
},
),
],
),
body: sessions.isEmpty
? _buildEmptyState(context)
: _buildSessionList(context, sessions, provider, historyManager),
Expand Down
22 changes: 12 additions & 10 deletions workout-logger/lib/services/health_connect_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -167,17 +167,19 @@ class HealthConnectService implements IHealthConnectService {
})
.toList();

// Fall back to evenly-spaced distribution whenever clamped timestamps are
// not fully unique. Duplicate timestamps arise when:
// • All sets share the same instant (legacy data / unit-test stubs).
// • Two or more sets were logged within the same DateTime resolution tick
// (common on devices where DateTime.now() resolution is ~1 ms).
// • One or more timestamps were clamped to the same boundary value.
// In any of these cases the real-timestamp path would produce overlapping or
// zero-duration segments, which ExerciseSessionRecord's constructor rejects
// with an ArgumentError, silently aborting the sync.
// Fall back to evenly-spaced distribution whenever the clamped timestamps
// would produce an invalid segment layout. This happens when:
// • Timestamps are not fully unique (legacy data, sub-ms resolution, or
// multiple sets clamped to the same boundary).
// • The last clamped timestamp equals sessionEnd, which makes the final
// segment zero-duration (start == end == sessionEnd) — the common case
// where exactly one set was logged a few seconds after the stored
// duration ended and was clamped to sessionEnd.
// Zero-duration / overlapping segments cause ExerciseSessionRecord's
// constructor to throw an ArgumentError, silently aborting the sync.
final uniqueTimestamps = clampedSets.map((s) => s.$3).toSet();
if (uniqueTimestamps.length < clampedSets.length) {
if (uniqueTimestamps.length < clampedSets.length ||
clampedSets.last.$3 == sessionEnd) {
final totalMs = sessionEnd.difference(sessionStart).inMilliseconds;
final slotMs = totalMs ~/ clampedSets.length;
return List.generate(clampedSets.length, (i) {
Expand Down
14 changes: 14 additions & 0 deletions workout-logger/lib/services/managers/history_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ class HistoryManager extends ChangeNotifier {
);
}

/// Trigger a Health Connect sync for every session that has not yet been synced.
///
/// Fires all syncs concurrently (fire-and-forget). Each successful sync
/// stamps hcSyncedAt and persists via the normal _onHcSynced path.
/// No-op when [healthSyncManager] was not provided.
void syncAllUnsynced() {
if (_healthSync == null) return;
for (final session in _sessions) {
if (session.hcSyncedAt == null) {
_healthSync.syncSession(session, onSynced: _onHcSynced);
}
}
}

// Called by HealthSyncManager on successful sync.
// Merges only hcSyncedAt into the current in-memory session so that any
// edits made between sync being triggered and this callback firing are not
Expand Down
9 changes: 3 additions & 6 deletions workout-logger/lib/services/strategies/target_calculator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,9 @@ class WeightTargetCalculator implements TargetCalculatorStrategy {
double calculate(String exerciseId, List<WorkoutSession> sessions) {
double bestValue = 0;

for (var log in _getExerciseLogsForExercise(exerciseId, sessions)) {
final maxWeight = log.sets
.map((s) => s.weight)
.reduce((a, b) => a > b ? a : b);
if (maxWeight > bestValue) {
bestValue = maxWeight;
for (final log in _getExerciseLogsForExercise(exerciseId, sessions)) {
for (final set in log.sets) {
if (set.weight > bestValue) bestValue = set.weight;
}
}

Expand Down
229 changes: 229 additions & 0 deletions workout-logger/test/active_workout_manager_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:repforge/models/models.dart';
import 'package:repforge/services/managers/active_workout_manager.dart';
import 'test_utils/mock_storage_service.dart';

void main() {
late MockStorageService mockStorage;
late ActiveWorkoutManager manager;

setUp(() {
mockStorage = MockStorageService();
manager = ActiveWorkoutManager(mockStorage);
});

group('ActiveWorkoutManager - startWorkout', () {
test('starts with exerciseIds and sets initial state', () {
manager.startWorkout(exerciseIds: ['ex1', 'ex2', 'ex3']);

expect(manager.hasActiveWorkout, isTrue);
expect(manager.totalExercises, 3);
expect(manager.currentExerciseIndex, 0);
expect(manager.currentExerciseId, 'ex1');
expect(manager.isFirstExercise, isTrue);
expect(manager.isLastExercise, isFalse);
expect(manager.workoutStartTime, isNotNull);
});

test('starts with a routine and uses its exerciseIds', () {
final routine = Routine(
id: 'r1',
name: 'Push Day',
exerciseIds: ['ex1', 'ex2'],
);

manager.startWorkout(routine: routine);

expect(manager.hasActiveWorkout, isTrue);
expect(manager.activeRoutine, same(routine));
expect(manager.totalExercises, 2);
expect(manager.currentExerciseId, 'ex1');
});

test('throws StateError if workout already in progress', () {
manager.startWorkout(exerciseIds: ['ex1']);
expect(
() => manager.startWorkout(exerciseIds: ['ex2']),
throwsA(isA<StateError>()),
);
});

test('throws StateError with no exercises', () {
expect(
() => manager.startWorkout(exerciseIds: []),
throwsA(isA<StateError>()),
);
});
});

group('ActiveWorkoutManager - addSet / removeLastSet', () {
setUp(() => manager.startWorkout(exerciseIds: ['ex1']));

test('addSet appends to current exercise log', () {
final set = WorkoutSet(weight: 100, reps: 8);
manager.addSet(set);

expect(manager.currentExerciseLog!.sets.length, 1);
expect(manager.currentExerciseLog!.sets.first.weight, 100);
expect(manager.currentExerciseLog!.sets.first.reps, 8);
});

test('addSet accumulates multiple sets', () {
manager.addSet(WorkoutSet(weight: 80, reps: 12));
manager.addSet(WorkoutSet(weight: 85, reps: 10));
manager.addSet(WorkoutSet(weight: 90, reps: 8));

expect(manager.currentExerciseLog!.sets.length, 3);
});

test('removeLastSet removes the last added set', () {
manager.addSet(WorkoutSet(weight: 80, reps: 12));
manager.addSet(WorkoutSet(weight: 85, reps: 10));
manager.removeLastSet();

expect(manager.currentExerciseLog!.sets.length, 1);
expect(manager.currentExerciseLog!.sets.first.weight, 80);
});

test('removeLastSet is a no-op when no sets logged', () {
manager.removeLastSet();
expect(manager.currentExerciseLog!.sets, isEmpty);
});
});

group('ActiveWorkoutManager - updateCurrentExerciseNotes', () {
setUp(() => manager.startWorkout(exerciseIds: ['ex1']));

test('sets notes on current exercise', () {
manager.updateCurrentExerciseNotes('Focus on form');
expect(manager.currentExerciseLog!.notes, 'Focus on form');
});

test('clears notes when null is passed', () {
manager.updateCurrentExerciseNotes('Some note');
manager.updateCurrentExerciseNotes(null);
expect(manager.currentExerciseLog!.notes, isNull);
});
});

group('ActiveWorkoutManager - exercise navigation', () {
setUp(() => manager.startWorkout(exerciseIds: ['ex1', 'ex2', 'ex3']));

test('nextExercise advances index and returns true', () {
final moved = manager.nextExercise();

expect(moved, isTrue);
expect(manager.currentExerciseIndex, 1);
expect(manager.currentExerciseId, 'ex2');
expect(manager.isFirstExercise, isFalse);
});

test('nextExercise returns false at last exercise', () {
manager.nextExercise();
manager.nextExercise();
final moved = manager.nextExercise();

expect(moved, isFalse);
expect(manager.currentExerciseIndex, 2);
expect(manager.isLastExercise, isTrue);
});

test('previousExercise returns false at first exercise', () {
final moved = manager.previousExercise();
expect(moved, isFalse);
expect(manager.currentExerciseIndex, 0);
});

test('previousExercise moves back and returns true', () {
manager.nextExercise();
final moved = manager.previousExercise();

expect(moved, isTrue);
expect(manager.currentExerciseIndex, 0);
expect(manager.isFirstExercise, isTrue);
});

test('goToExercise jumps to specified index', () {
manager.goToExercise(2);
expect(manager.currentExerciseIndex, 2);
expect(manager.currentExerciseId, 'ex3');
expect(manager.isLastExercise, isTrue);
});

test('single-exercise workout: isFirst and isLast both true', () {
final mgr = ActiveWorkoutManager(mockStorage);
mgr.startWorkout(exerciseIds: ['solo']);

expect(mgr.isFirstExercise, isTrue);
expect(mgr.isLastExercise, isTrue);
});
});

group('ActiveWorkoutManager - finishWorkout', () {
test('saves session to storage and clears state', () async {
manager.startWorkout(exerciseIds: ['ex1', 'ex2']);
manager.addSet(WorkoutSet(weight: 100, reps: 5));
manager.nextExercise();
manager.addSet(WorkoutSet(weight: 60, reps: 12));

final session = await manager.finishWorkout(notes: 'Good session');

expect(manager.hasActiveWorkout, isFalse);
expect(manager.currentExerciseLogs, isEmpty);
expect(session.exercises.length, 2);
expect(session.notes, 'Good session');
expect(mockStorage.sessions, isNotEmpty);
expect(mockStorage.sessions.first.id, session.id);
});

test('filters out exercises with no sets', () async {
manager.startWorkout(exerciseIds: ['ex1', 'ex2']);
// Only log sets on ex1; ex2 has none
manager.addSet(WorkoutSet(weight: 80, reps: 10));

final session = await manager.finishWorkout();

expect(session.exercises.length, 1);
expect(session.exercises.first.exerciseId, 'ex1');
});

test('calls onWorkoutSaved callback with the session', () async {
WorkoutSession? received;
final mgr = ActiveWorkoutManager(
mockStorage,
onWorkoutSaved: (s) => received = s,
);
mgr.startWorkout(exerciseIds: ['ex1']);
mgr.addSet(WorkoutSet(weight: 50, reps: 10));
final session = await mgr.finishWorkout();

expect(received, isNotNull);
expect(received!.id, session.id);
});

test('records routine id when started with a routine', () async {
final routine = Routine(
id: 'r42',
name: 'Leg Day',
exerciseIds: ['ex1'],
);
manager.startWorkout(routine: routine);
manager.addSet(WorkoutSet(weight: 120, reps: 6));
final session = await manager.finishWorkout();

expect(session.routineId, 'r42');
});
});

group('ActiveWorkoutManager - cancelWorkout', () {
test('clears state without saving', () {
manager.startWorkout(exerciseIds: ['ex1']);
manager.addSet(WorkoutSet(weight: 80, reps: 10));
manager.cancelWorkout();

expect(manager.hasActiveWorkout, isFalse);
expect(manager.currentExerciseLogs, isEmpty);
expect(mockStorage.sessions, isEmpty);
});
});
}
Loading
Loading