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
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ class _ProgramDesignerScreenState extends State<ProgramDesignerScreen> {
decoration: const InputDecoration(
labelText: 'Day of Week (optional)',
),
value: dow,
initialValue: dow,
items: [
const DropdownMenuItem(value: null, child: Text('Unscheduled')),
...List.generate(7, (i) => i + 1).map((d) => DropdownMenuItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'dart:ui' show FontFeature;

import '../../models/models.dart';
import '../../services/workout_provider.dart';
Expand Down
90 changes: 77 additions & 13 deletions workout-logger/lib/services/managers/analytics_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ class AnalyticsManager extends ChangeNotifier {
// Growth models for each exercise
final Map<String, GrowthModel> _growthModels = {};

// Reference to track the sessions list identity and detect stale usage
List<WorkoutSession>? _lastIndexedSessions;

// Pre-computed exerciseId → sorted-newest-first ExerciseLog and date index.
// Rebuilt via [buildSessionIndex] whenever the session list changes.
Map<String, List<({ExerciseLog log, DateTime date})>> _sessionIndex = {};

// Callback to update targets with new growth models
final void Function(String exerciseId, GrowthModel model)?
onGrowthModelUpdated;
Expand All @@ -35,6 +42,32 @@ class AnalyticsManager extends ChangeNotifier {
// Getters
Map<String, GrowthModel> get growthModels => Map.unmodifiable(_growthModels);

/// Build (or rebuild) the exerciseId → sorted log index from [sessions].
///
/// Callers MUST call this after any session mutation (add, delete, update).
/// The index maps each exercise id to a list of [ExerciseLog] instances
/// ordered newest-first, which lets recommendation and progression APIs
/// avoid repeated O(N log N) sorts and O(N²) scans on every render.
void buildSessionIndex(List<WorkoutSession> sessions) {
_lastIndexedSessions = sessions;

final index = <String, List<({ExerciseLog log, DateTime date})>>{};

// Sort sessions newest-first once and iterate
final sorted = List<WorkoutSession>.from(sessions)
..sort((a, b) => b.date.compareTo(a.date));

for (final session in sorted) {
for (final log in session.exercises) {
if (log.sets.isNotEmpty) {
index.putIfAbsent(log.exerciseId, () => []).add((log: log, date: session.date));
}
}
}

_sessionIndex = index;
}

/// Get growth model for a specific exercise
GrowthModel? getGrowthModel(String exerciseId) => _growthModels[exerciseId];

Expand Down Expand Up @@ -96,12 +129,20 @@ class AnalyticsManager extends ChangeNotifier {

/// Get set recommendations for an exercise.
///
/// Uses the most-recently-dated session containing this exercise.
/// Uses the most-recently-dated session containing this exercise as the
/// basis for the recommendation. Reads from the pre-built session index
/// so no sort is required at call time — O(1).
List<SetRecommendation> getRecommendations(
String exerciseId,
List<WorkoutSession> sessions,
) {
final lastLog = findMostRecentExerciseLog(exerciseId, sessions);
final isFresh = identical(_lastIndexedSessions, sessions);

// Fast path: use pre-built index if available and fresh.
final logs = isFresh ? _sessionIndex[exerciseId] : null;
final lastLog = (logs != null && logs.isNotEmpty)
? logs.first.log // already sorted newest-first
: findMostRecentExerciseLog(exerciseId, sessions); // fallback

if (lastLog == null || lastLog.sets.isEmpty) {
return _mlService.getDefaultRecommendations(3);
Expand All @@ -113,14 +154,34 @@ class AnalyticsManager extends ChangeNotifier {
);
}

/// Get volume progression for an exercise
/// Get volume progression for an exercise.
///
/// Reads from the pre-built session index (sorted oldest-first by
/// reversing the newest-first index list), so no allocation or sort is
/// required at call time — O(K) where K is the number of logs for this
/// exercise.
List<({DateTime date, double volume})> getVolumeProgression(
String exerciseId,
List<WorkoutSession> sessions,
) {
final data = <({DateTime date, double volume})>[];
final isFresh = identical(_lastIndexedSessions, sessions);

if (isFresh) {
if (_sessionIndex.containsKey(exerciseId)) {
// Fast path: use pre-built index
final logs = _sessionIndex[exerciseId]!;
// Index is newest-first; progression needs oldest-first.
return [
for (final entry in logs.reversed)
(date: entry.date, volume: entry.log.totalVolume),
];
}
// Index is fresh but key is missing, meaning history is truly empty
return [];
}

// Process sessions in chronological order (oldest first)
// Fallback (index not built yet or stale) — sort + scan.
final data = <({DateTime date, double volume})>[];
final sortedSessions = List<WorkoutSession>.from(sessions)
..sort((a, b) => a.date.compareTo(b.date));

Expand All @@ -132,27 +193,33 @@ class AnalyticsManager extends ChangeNotifier {
}
}
}

return data;
}

/// Get weekly volume by muscle group


/// Get weekly volume by muscle group.
///
/// [now] parameter allows test injection of a fixed timestamp for deterministic testing.
/// [exerciseMap] is a pre-built id → Exercise map for O(1) lookups,
/// eliminating the inner O(N) scan from the original [_findExercise].
/// [now] parameter allows test injection of a fixed timestamp.
Map<String, double> getWeeklyVolumeByMuscle(
List<WorkoutSession> sessions,
List<Exercise> exercises, {
DateTime? now,
Map<String, Exercise>? exerciseMap,
}) {
final volumeByMuscle = <String, double>{};
final currentTime = now ?? DateTime.now();
final weekAgo = currentTime.subtract(const Duration(days: 7));
final Map<String, Exercise> lookup =
exerciseMap ?? {for (final e in exercises) e.id: e};

for (var session in sessions) {
if (session.date.isBefore(weekAgo)) continue;

for (var log in session.exercises) {
final exercise = _findExercise(log.exerciseId, exercises);
final exercise = lookup[log.exerciseId];
if (exercise == null) continue;

for (var activation in exercise.muscleActivations) {
Expand All @@ -167,10 +234,7 @@ class AnalyticsManager extends ChangeNotifier {
return volumeByMuscle;
}

Exercise? _findExercise(String id, List<Exercise> exercises) {
final index = exercises.indexWhere((e) => e.id == id);
return index != -1 ? exercises[index] : null;
}


/// Get quick stats for dashboard
Future<Map<String, dynamic>> getQuickStats() async {
Expand Down
14 changes: 9 additions & 5 deletions workout-logger/lib/services/managers/exercise_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class ExerciseManager extends ChangeNotifier {

List<Exercise> _allExercises = [];

/// Memoized id → Exercise map for O(1) lookups.
/// Rebuilt whenever [_allExercises] changes.
Map<String, Exercise> _exerciseIndex = {};

/// Allowed category values for exercises
static const Set<String> allowedCategories = {'compound', 'isolation'};

Expand All @@ -39,17 +43,15 @@ class ExerciseManager extends ChangeNotifier {
/// Load all exercises from storage and database
Future<void> loadExercises() async {
_allExercises = await _storage.getAllExercises();
_exerciseIndex = {for (final e in _allExercises) e.id: e};
notifyListeners();
}

/// Get an exercise by ID
/// Get an exercise by ID in O(1) via the memoized index.
///
/// Returns null if not found. Caller should ensure loadExercises() has been
/// called first to populate the in-memory exercise list.
Exercise? getExercise(String id) {
final index = _allExercises.indexWhere((e) => e.id == id);
return index != -1 ? _allExercises[index] : null;
}
Exercise? getExercise(String id) => _exerciseIndex[id];

/// Get exercise name by ID
String getExerciseName(String id) {
Expand Down Expand Up @@ -109,6 +111,7 @@ class ExerciseManager extends ChangeNotifier {

await _storage.saveCustomExercise(exercise);
_allExercises = List.from(_allExercises)..add(exercise);
_exerciseIndex[id] = exercise;
notifyListeners();

return exercise;
Expand All @@ -135,6 +138,7 @@ class ExerciseManager extends ChangeNotifier {
await _storage.deleteCustomExercise(exerciseId);
_allExercises = List.from(_allExercises)
..removeWhere((e) => e.id == exerciseId);
_exerciseIndex.remove(exerciseId);
notifyListeners();

return true;
Expand Down
10 changes: 4 additions & 6 deletions workout-logger/lib/services/strategies/target_calculator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,10 @@ class RepsTargetCalculator implements TargetCalculatorStrategy {
double calculate(String exerciseId, List<WorkoutSession> sessions) {
double bestValue = 0;

for (var log in _getExerciseLogsForExercise(exerciseId, sessions)) {
final maxReps = log.sets
.map((s) => s.reps)
.reduce((a, b) => a > b ? a : b);
if (maxReps > bestValue) {
bestValue = maxReps.toDouble();
for (final log in _getExerciseLogsForExercise(exerciseId, sessions)) {
// In-place max scan: skips intermediate map()/reduce() allocation.
for (final set in log.sets) {
if (set.reps > bestValue) bestValue = set.reps.toDouble();
}
}

Expand Down
76 changes: 38 additions & 38 deletions workout-logger/lib/services/workout_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -208,43 +208,31 @@ class WorkoutProvider extends ChangeNotifier {
return false;
}

// Check for references in Sessions
for (var session in _sessions) {
if (session.exercises.any((e) => e.exerciseId == exerciseId)) {
debugPrint(
'Cannot delete custom exercise: Used in session ${session.id}',
);
return false;
}
}
// Build a set of referenced exercise IDs in a single pass over
// sessions, routines, targets, and the active workout.
final referencedIds = <String>{};
final referenceReasons = <String, Set<String>>{};

// Check for references in Routines
for (var routine in _routines) {
if (routine.exerciseIds.contains(exerciseId)) {
debugPrint(
'Cannot delete custom exercise: Used in routine ${routine.name}',
);
return false;
}
void addReference(String id, String reason) {
referencedIds.add(id);
referenceReasons.putIfAbsent(id, () => {}).add(reason);
}

// Check for references in Targets
for (var target in _targets) {
if (target.exerciseId == exerciseId) {
debugPrint(
'Cannot delete custom exercise: Used in target ${target.id}',
);
return false;
}
for (final s in _sessions) {
for (final e in s.exercises) addReference(e.exerciseId, '_sessions');
}

// Check for references in active workout
if (_currentExerciseLogs.any((l) => l.exerciseId == exerciseId)) {
debugPrint('Cannot delete custom exercise: Used in active workout');
return false;
for (final r in _routines) {
for (final id in r.exerciseIds) addReference(id, '_routines');
}
for (final t in _targets) addReference(t.exerciseId, '_targets');
for (final l in _currentExerciseLogs) addReference(l.exerciseId, '_currentExerciseLogs');
if (_activeRoutine != null) {
for (final id in _activeRoutine!.exerciseIds) addReference(id, '_activeRoutine');
Comment on lines +222 to +230

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Reason labels expose private field names in debug output.

'_sessions', '_routines', '_targets', '_currentExerciseLogs', '_activeRoutine' are internal field names; the prior per-collection messages used user-facing labels (e.g. "sessions", "active workout"). The current strings will surface as still referenced in _sessions, _activeRoutine in logs, which leaks implementation detail and reads worse than the previous variant.

-      for (final e in s.exercises) addReference(e.exerciseId, '_sessions');
+      for (final e in s.exercises) addReference(e.exerciseId, 'sessions');
...
-      for (final id in r.exerciseIds) addReference(id, '_routines');
+      for (final id in r.exerciseIds) addReference(id, 'routines');
...
-    for (final t in _targets) addReference(t.exerciseId, '_targets');
-    for (final l in _currentExerciseLogs) addReference(l.exerciseId, '_currentExerciseLogs');
+    for (final t in _targets) addReference(t.exerciseId, 'targets');
+    for (final l in _currentExerciseLogs) addReference(l.exerciseId, 'active workout');
...
-      for (final id in _activeRoutine!.exerciseIds) addReference(id, '_activeRoutine');
+      for (final id in _activeRoutine!.exerciseIds) addReference(id, 'active routine');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@workout-logger/lib/services/workout_provider.dart` around lines 222 - 230,
The debug labels currently pass private field names (e.g., '_sessions',
'_routines', '_targets', '_currentExerciseLogs', '_activeRoutine') to
addReference; replace those strings with user-facing, non-private labels (for
example "sessions", "routines", "targets", "current exercise logs", "active
routine" or "active workout") so logs no longer expose implementation details;
update the calls around addReference(e.exerciseId, '_sessions'),
addReference(id, '_routines'), addReference(t.exerciseId, '_targets'),
addReference(l.exerciseId, '_currentExerciseLogs'), and addReference(id,
'_activeRoutine') to use the new public labels.

}
Comment on lines +221 to 231

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify flutter_lints / analysis_options enables curly_braces_in_flow_control_structures
fd -e yaml analysis_options
fd -e yaml analysis_options --exec cat {}
rg -n 'curly_braces_in_flow_control_structures' || true

Repository: Devasy/Workout-logger

Length of output: 1704


🏁 Script executed:

# Read the specific lines in workout_provider.dart to verify the violations
sed -n '220,232p' workout-logger/lib/services/workout_provider.dart

Repository: Devasy/Workout-logger

Length of output: 582


Add curly braces around single-statement flow-control bodies.

flutter_lints enforces curly_braces_in_flow_control_structures, which requires braces around the body of flow-control statements even for single statements. Lines 222, 225, 227, 228, and 230 violate this rule and will fail lint analysis.

🔧 Fix
     for (final s in _sessions) {
-      for (final e in s.exercises) addReference(e.exerciseId, '_sessions');
+      for (final e in s.exercises) {
+        addReference(e.exerciseId, '_sessions');
+      }
     }
     for (final r in _routines) {
-      for (final id in r.exerciseIds) addReference(id, '_routines');
+      for (final id in r.exerciseIds) {
+        addReference(id, '_routines');
+      }
     }
-    for (final t in _targets) addReference(t.exerciseId, '_targets');
-    for (final l in _currentExerciseLogs) addReference(l.exerciseId, '_currentExerciseLogs');
+    for (final t in _targets) {
+      addReference(t.exerciseId, '_targets');
+    }
+    for (final l in _currentExerciseLogs) {
+      addReference(l.exerciseId, '_currentExerciseLogs');
+    }
     if (_activeRoutine != null) {
-      for (final id in _activeRoutine!.exerciseIds) addReference(id, '_activeRoutine');
+      for (final id in _activeRoutine!.exerciseIds) {
+        addReference(id, '_activeRoutine');
+      }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@workout-logger/lib/services/workout_provider.dart` around lines 221 - 231,
The for-loops that call addReference without braces violate the
curly_braces_in_flow_control_structures lint; update each loop in
workout_provider.dart so their bodies are wrapped in braces — specifically the
loops iterating over _sessions (for final s in _sessions), _routines (for final
r in _routines), _targets (for final t in _targets), _currentExerciseLogs (for
final l in _currentExerciseLogs), and the _activeRoutine exerciseIds loop (for
final id in _activeRoutine!.exerciseIds) — keeping the addReference(...) calls
unchanged but enclosed in { ... } to satisfy the lint.

if (_activeRoutine?.exerciseIds.contains(exerciseId) ?? false) {
debugPrint('Cannot delete custom exercise: Used in active routine');

if (referencedIds.contains(exerciseId)) {
final reasons = referenceReasons[exerciseId]?.join(', ') ?? 'unknown';
debugPrint('Cannot delete custom exercise $exerciseId: still referenced in $reasons');
return false;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down Expand Up @@ -693,19 +681,31 @@ class WorkoutProvider extends ChangeNotifier {
return data;
}

/// Get weekly volume by muscle group
/// Get weekly volume by muscle group.
///
/// Sessions are traversed newest-first and the loop breaks once we reach
/// a session older than 7 days, so only in-range sessions are visited.
/// An exercise map is built once at the top for O(1) per-set lookup.
Map<String, double> getWeeklyVolumeByMuscle() {
final volumeByMuscle = <String, double>{};
final weekAgo = DateTime.now().subtract(const Duration(days: 7));

for (var session in _sessions) {
if (session.date.isBefore(weekAgo)) continue;
// Pre-build exercise map for O(1) lookup
final exerciseMap = <String, Exercise>{
for (final e in _allExercises) e.id: e,
};

for (var log in session.exercises) {
final exercise = getExercise(log.exerciseId);
// Iterate directly since _sessions is maintained newest-first.
// We use continue rather than break in case of external imports
// that might temporarily violate the newest-first invariant.
for (final session in _sessions) {
if (session.date.isBefore(weekAgo)) continue;

for (final log in session.exercises) {
final exercise = exerciseMap[log.exerciseId];
if (exercise == null) continue;

for (var activation in exercise.muscleActivations) {
for (final activation in exercise.muscleActivations) {
final muscleVolume =
log.totalVolume * (activation.activationPercentage / 100);
volumeByMuscle[activation.muscleGroupId] =
Expand Down
2 changes: 1 addition & 1 deletion workout-logger/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.15+16

environment:
sdk: ^3.9.2
sdk: ^3.11.4
flutter: 3.41.6

# Dependencies specify other packages that your package needs in order to work.
Expand Down
Loading