From 9496a674cd9f6f87ef31d8099017d5359663c6bf Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:35:53 +0530 Subject: [PATCH 1/6] feat: Implement HistoryManager for managing workout session history - Added HistoryManager class to handle loading, saving, updating, and deleting workout sessions. - Introduced methods for querying session history, including filtering by exercise and date range. feat: Create RoutineManager for managing workout routines - Added RoutineManager class to manage workout routines, including creating, updating, and deleting routines. - Implemented methods to load routines from storage and check exercise usage in routines. feat: Develop TargetManager for managing workout targets/goals - Introduced TargetManager class to handle target creation, updating, and progress tracking. - Implemented growth model updates and target completion predictions using a strategy pattern. refactor: Update MLService to implement IMLService interface - Refactored MLService to follow Dependency Inversion Principle, allowing for easier swapping of ML algorithms. - Enhanced methods for training growth models and generating set recommendations. refactor: Implement StorageService with IStorageService interface - Created StorageService class for local persistence using Hive, adhering to Dependency Inversion Principle. - Added methods for managing workout sessions, routines, targets, and muscle groups. feat: Introduce TargetCalculator strategy pattern for target value calculations - Implemented TargetCalculatorStrategy interface and various calculators for different target types. - Added factory for creating target calculators, allowing for easy extension of target types. refactor: Update WorkoutProvider to use individual managers - Refactored WorkoutProvider to utilize new managers for active workouts, history, routines, targets, and analytics. - Maintained backward compatibility while improving code structure and adherence to SOLID principles. test: Add mock services for ML and storage for testing - Created MockMLService and MockStorageService to facilitate testing of the application. - Implemented methods to simulate behavior of real services, allowing for isolated unit tests. --- SOLID_ANALYSIS_REPORT.md | 45 +++- workout-logger/lib/main.dart | 45 ++-- .../lib/services/interfaces/interfaces.dart | 8 + .../interfaces/ml_service_interface.dart | 48 ++++ .../interfaces/storage_service_interface.dart | 71 ++++++ .../managers/active_workout_manager.dart | 210 ++++++++++++++++++ .../services/managers/analytics_manager.dart | 177 +++++++++++++++ .../services/managers/exercise_manager.dart | 166 ++++++++++++++ .../services/managers/history_manager.dart | 145 ++++++++++++ .../lib/services/managers/managers.dart | 17 ++ .../services/managers/routine_manager.dart | 85 +++++++ .../lib/services/managers/target_manager.dart | 162 ++++++++++++++ workout-logger/lib/services/ml_service.dart | 91 +++++--- .../lib/services/storage_service.dart | 67 ++++-- .../strategies/target_calculator.dart | 129 +++++++++++ .../lib/services/workout_provider.dart | 52 ++++- .../test/test_utils/mock_ml_service.dart | 133 +++++++++++ .../test/test_utils/mock_storage_service.dart | 200 ++++++++++++++--- 18 files changed, 1737 insertions(+), 114 deletions(-) create mode 100644 workout-logger/lib/services/interfaces/interfaces.dart create mode 100644 workout-logger/lib/services/interfaces/ml_service_interface.dart create mode 100644 workout-logger/lib/services/interfaces/storage_service_interface.dart create mode 100644 workout-logger/lib/services/managers/active_workout_manager.dart create mode 100644 workout-logger/lib/services/managers/analytics_manager.dart create mode 100644 workout-logger/lib/services/managers/exercise_manager.dart create mode 100644 workout-logger/lib/services/managers/history_manager.dart create mode 100644 workout-logger/lib/services/managers/managers.dart create mode 100644 workout-logger/lib/services/managers/routine_manager.dart create mode 100644 workout-logger/lib/services/managers/target_manager.dart create mode 100644 workout-logger/lib/services/strategies/target_calculator.dart create mode 100644 workout-logger/test/test_utils/mock_ml_service.dart diff --git a/SOLID_ANALYSIS_REPORT.md b/SOLID_ANALYSIS_REPORT.md index 15bbb1e..a9d2683 100644 --- a/SOLID_ANALYSIS_REPORT.md +++ b/SOLID_ANALYSIS_REPORT.md @@ -1,6 +1,49 @@ # SOLID Principles Analysis Report -This report provides a detailed analysis of the current Flutter codebase ("Workout Logger") against the SOLID principles. The analysis identifies areas where the code adheres to these principles and, more importantly, where it violates them, offering a roadmap for refactoring. +This report provides a detailed analysis of the Flutter codebase ("Workout Logger") against the SOLID principles. + +## ✅ SOLID Refactoring Complete + +The following refactoring has been implemented to address SOLID violations: + +### Changes Made + +#### 1. Dependency Inversion Principle (DIP) +- Created `IStorageService` interface ([storage_service_interface.dart](workout-logger/lib/services/interfaces/storage_service_interface.dart)) +- Created `IMLService` interface ([ml_service_interface.dart](workout-logger/lib/services/interfaces/ml_service_interface.dart)) +- Updated `StorageService` to implement `IStorageService` +- Updated `MLService` to implement `IMLService` (injectable, not static-only) +- Updated `WorkoutProvider` to depend on abstractions via constructor injection +- Updated `main.dart` to use composition root pattern for DI + +#### 2. Single Responsibility Principle (SRP) +Created focused managers in `lib/services/managers/`: +- `ActiveWorkoutManager` - Current workout session state only +- `HistoryManager` - Past workout sessions only +- `RoutineManager` - Workout routines only +- `ExerciseManager` - Exercise library (built-in + custom) +- `TargetManager` - Goals and targets only +- `AnalyticsManager` - Statistics and recommendations only + +#### 3. Open/Closed Principle (OCP) +- Created `TargetCalculatorStrategy` pattern ([target_calculator.dart](workout-logger/lib/services/strategies/target_calculator.dart)) +- New target types can be added by implementing `TargetCalculatorStrategy` without modifying existing code +- New ML algorithms can be added by implementing `IMLService` + +#### 4. Interface Segregation Principle (ISP) +- Split the monolithic `WorkoutProvider` into focused managers +- Each screen can now depend on only the managers it needs + +#### 5. Liskov Substitution Principle (LSP) +- Created `MockStorageService` implementing `IStorageService` for testing +- Created `MockMLService` implementing `IMLService` for testing +- Both mocks can be substituted for their real implementations + +--- + +## Original Analysis (for reference) + +This section contains the original analysis that guided the refactoring. ## 1. Single Responsibility Principle (SRP) diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index d52ba11..02c10b3 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -1,17 +1,23 @@ // Main App Entry Point +// +// Following Dependency Inversion Principle: we create concrete implementations +// here at the composition root and inject them into high-level modules. import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'services/storage_service.dart'; +import 'services/ml_service.dart'; +import 'services/interfaces/storage_service_interface.dart'; +import 'services/interfaces/ml_service_interface.dart'; import 'services/workout_provider.dart'; import 'theme/app_theme.dart'; import 'screens/home_screen.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - + // Set preferred orientations await SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, @@ -19,12 +25,14 @@ void main() async { ]); // Set system overlay style - SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.light, - systemNavigationBarColor: AppTheme.backgroundColor, - systemNavigationBarIconBrightness: Brightness.light, - )); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: AppTheme.backgroundColor, + systemNavigationBarIconBrightness: Brightness.light, + ), + ); runApp(const WorkoutLoggerApp()); } @@ -34,10 +42,21 @@ class WorkoutLoggerApp extends StatelessWidget { @override Widget build(BuildContext context) { + // Composition Root: Create concrete implementations and inject them + // This is the only place where we reference concrete implementations. + // All other code depends on abstractions (interfaces). + final IStorageService storageService = StorageService(); + final IMLService mlService = MLService(); + return MultiProvider( providers: [ + // Provide the storage service interface for direct access if needed + Provider.value(value: storageService), + // Provide the ML service interface for direct access if needed + Provider.value(value: mlService), + // WorkoutProvider receives dependencies via constructor injection ChangeNotifierProvider( - create: (_) => WorkoutProvider(StorageService()), + create: (_) => WorkoutProvider(storageService, mlService: mlService), ), ], child: MaterialApp( @@ -85,11 +104,7 @@ class _AppInitializerState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon( - Icons.error_outline, - size: 64, - color: AppTheme.error, - ), + const Icon(Icons.error_outline, size: 64, color: AppTheme.error), const SizedBox(height: 16), Text( 'Failed to initialize app', @@ -138,9 +153,7 @@ class _AppInitializerState extends State { style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 32), - const CircularProgressIndicator( - color: AppTheme.primaryColor, - ), + const CircularProgressIndicator(color: AppTheme.primaryColor), ], ), ), diff --git a/workout-logger/lib/services/interfaces/interfaces.dart b/workout-logger/lib/services/interfaces/interfaces.dart new file mode 100644 index 0000000..fdea987 --- /dev/null +++ b/workout-logger/lib/services/interfaces/interfaces.dart @@ -0,0 +1,8 @@ +// Interfaces barrel export +// +// Following Dependency Inversion Principle, all service abstractions +// are defined here. High-level modules depend on these interfaces, +// not concrete implementations. + +export 'storage_service_interface.dart'; +export 'ml_service_interface.dart'; diff --git a/workout-logger/lib/services/interfaces/ml_service_interface.dart b/workout-logger/lib/services/interfaces/ml_service_interface.dart new file mode 100644 index 0000000..2a237bf --- /dev/null +++ b/workout-logger/lib/services/interfaces/ml_service_interface.dart @@ -0,0 +1,48 @@ +// Abstract ML Service Interface (Dependency Inversion Principle) +// +// This interface defines the contract for machine learning operations. +// By depending on this abstraction, we can: +// - Swap ML algorithms without modifying consumers +// - Easily mock the ML service in tests +// - Follow the Open/Closed principle for new ML strategies + +import '../../models/models.dart'; + +/// Data point for ML training +class DataPoint { + final double x; // Session number or time + final double y; // Volume or performance metric + + DataPoint({required this.x, required this.y}); +} + +/// Abstract interface for ML operations +/// +/// Implements Dependency Inversion Principle by allowing high-level modules +/// to depend on this abstraction rather than concrete ML implementations. +abstract class IMLService { + /// Train a growth model using data points + GrowthModel trainGrowthModel(List dataPoints); + + /// Extract data points from workout history for a specific exercise + List extractExerciseDataPoints( + String exerciseId, + List sessions, + ); + + /// Get recommended sets based on last session and growth model + List recommendSets({ + required List lastSession, + GrowthModel? growthModel, + }); + + /// Get default recommendations when no history exists + List getDefaultRecommendations(int setCount); + + /// Predict when a target will be completed based on growth model + DateTime? predictTargetCompletion({ + required double currentValue, + required double targetValue, + required GrowthModel growthModel, + }); +} diff --git a/workout-logger/lib/services/interfaces/storage_service_interface.dart b/workout-logger/lib/services/interfaces/storage_service_interface.dart new file mode 100644 index 0000000..c8eb6fc --- /dev/null +++ b/workout-logger/lib/services/interfaces/storage_service_interface.dart @@ -0,0 +1,71 @@ +// Abstract Storage Service Interface (Dependency Inversion Principle) +// +// This interface defines the contract for storage operations. +// High-level modules should depend on this abstraction, not concrete implementations. +// This allows swapping storage backends (Hive, SQL, Firebase, etc.) without modifying consumers. + +import '../../models/models.dart'; + +/// Abstract interface for storage operations +/// +/// Implements Interface Segregation Principle by being focused on storage concerns only. +/// Clients depend on this abstraction rather than concrete StorageService. +abstract class IStorageService { + /// Initialize the storage backend + Future init(); + + // ==================== WORKOUT SESSIONS ==================== + + Future saveWorkoutSession(WorkoutSession session); + Future> getAllWorkoutSessions(); + Future getWorkoutSession(String id); + Future deleteWorkoutSession(String id); + Future> getSessionsForExercise(String exerciseId); + Future> getSessionsInDateRange( + DateTime start, + DateTime end, + ); + + // ==================== ROUTINES ==================== + + Future saveRoutine(Routine routine); + Future> getAllRoutines(); + Future getRoutine(String id); + Future deleteRoutine(String id); + + // ==================== TARGETS ==================== + + Future saveTarget(Target target); + Future> getAllTargets(); + Future getTarget(String id); + Future deleteTarget(String id); + Future> getTargetsForExercise(String exerciseId); + + // ==================== MUSCLE GROUPS ==================== + + Future updateMuscleGroupGrowthRate(String muscleGroupId, double rate); + Future> getAllMuscleGroups(); + Future getMuscleGroup(String id); + + // ==================== CUSTOM EXERCISES ==================== + + Future saveCustomExercise(Exercise exercise); + Future> getCustomExercises(); + Future deleteCustomExercise(String id); + Future> getAllExercises(); + Future getExercise(String id); + + // ==================== SETTINGS ==================== + + Future saveSetting(String key, String value); + Future getSetting(String key); + + // ==================== EXPORT / IMPORT ==================== + + Future exportAllData(); + Future importData(String jsonData); + + // ==================== STATS ==================== + + Future> getQuickStats(); +} diff --git a/workout-logger/lib/services/managers/active_workout_manager.dart b/workout-logger/lib/services/managers/active_workout_manager.dart new file mode 100644 index 0000000..99b0abe --- /dev/null +++ b/workout-logger/lib/services/managers/active_workout_manager.dart @@ -0,0 +1,210 @@ +// Active Workout Manager (Single Responsibility Principle) +// +// This class is responsible ONLY for managing the state of the currently +// active workout session. It handles: +// - Starting/stopping workouts +// - Current exercise index navigation +// - Adding/removing sets to the current exercise +// +// It does NOT handle persistence (that's delegated to storage service) +// or history management (that's a separate concern). + +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; + +/// Manages the state of an active workout session. +/// +/// Following Single Responsibility Principle: this class only handles +/// the live workout flow, not history, analytics, or persistence concerns. +class ActiveWorkoutManager extends ChangeNotifier { + final IStorageService _storage; + final Uuid _uuid = const Uuid(); + + // Active workout state + Routine? _activeRoutine; + int _currentExerciseIndex = 0; + List _currentExerciseLogs = []; + DateTime? _workoutStartTime; + + // Callback for when workout is saved (to notify other managers) + final void Function(WorkoutSession)? onWorkoutSaved; + + ActiveWorkoutManager(this._storage, {this.onWorkoutSaved}); + + // Getters + bool get hasActiveWorkout => _workoutStartTime != null; + Routine? get activeRoutine => _activeRoutine; + int get currentExerciseIndex => _currentExerciseIndex; + List get currentExerciseLogs => + List.unmodifiable(_currentExerciseLogs); + DateTime? get workoutStartTime => _workoutStartTime; + int get totalExercises => _currentExerciseLogs.length; + bool get isLastExercise => + _currentExerciseIndex >= _currentExerciseLogs.length - 1; + bool get isFirstExercise => _currentExerciseIndex == 0; + + /// Get current exercise log + ExerciseLog? get currentExerciseLog { + if (_currentExerciseLogs.isEmpty || + _currentExerciseIndex >= _currentExerciseLogs.length) { + return null; + } + return _currentExerciseLogs[_currentExerciseIndex]; + } + + /// Get current exercise ID + String? get currentExerciseId => currentExerciseLog?.exerciseId; + + /// Start a new workout with a routine or list of exercises + void startWorkout({Routine? routine, List? exerciseIds}) { + if (hasActiveWorkout) { + throw StateError( + 'A workout is already in progress. Cancel or finish it first.', + ); + } + + _workoutStartTime = DateTime.now(); + _activeRoutine = routine; + _currentExerciseIndex = 0; + _currentExerciseLogs = []; + + // Initialize exercise logs based on routine or provided exercise IDs + final ids = routine?.exerciseIds ?? exerciseIds ?? []; + for (var id in ids) { + _currentExerciseLogs.add(ExerciseLog(exerciseId: id, sets: [])); + } + + notifyListeners(); + } + + /// Add a set to current exercise + void addSet(WorkoutSet set) { + if (!hasActiveWorkout) { + throw StateError('No active workout. Start a workout first.'); + } + + if (_currentExerciseIndex < _currentExerciseLogs.length) { + final currentLog = _currentExerciseLogs[_currentExerciseIndex]; + _currentExerciseLogs[_currentExerciseIndex] = ExerciseLog( + exerciseId: currentLog.exerciseId, + sets: [...currentLog.sets, set], + notes: currentLog.notes, + ); + notifyListeners(); + } + } + + /// Remove last set from current exercise + void removeLastSet() { + if (!hasActiveWorkout) return; + + if (_currentExerciseIndex < _currentExerciseLogs.length) { + final currentLog = _currentExerciseLogs[_currentExerciseIndex]; + if (currentLog.sets.isNotEmpty) { + final newSets = List.from(currentLog.sets)..removeLast(); + _currentExerciseLogs[_currentExerciseIndex] = ExerciseLog( + exerciseId: currentLog.exerciseId, + sets: newSets, + notes: currentLog.notes, + ); + notifyListeners(); + } + } + } + + /// Update notes for current exercise + void updateCurrentExerciseNotes(String? notes) { + if (!hasActiveWorkout) return; + + if (_currentExerciseIndex < _currentExerciseLogs.length) { + final currentLog = _currentExerciseLogs[_currentExerciseIndex]; + _currentExerciseLogs[_currentExerciseIndex] = ExerciseLog( + exerciseId: currentLog.exerciseId, + sets: currentLog.sets, + notes: notes, + ); + notifyListeners(); + } + } + + /// Move to next exercise + /// Returns true if moved, false if already at last exercise + bool nextExercise() { + if (_currentExerciseIndex < _currentExerciseLogs.length - 1) { + _currentExerciseIndex++; + notifyListeners(); + return true; + } + return false; + } + + /// Move to previous exercise + /// Returns true if moved, false if already at first exercise + bool previousExercise() { + if (_currentExerciseIndex > 0) { + _currentExerciseIndex--; + notifyListeners(); + return true; + } + return false; + } + + /// Jump to a specific exercise by index + void goToExercise(int index) { + if (index >= 0 && index < _currentExerciseLogs.length) { + _currentExerciseIndex = index; + notifyListeners(); + } + } + + /// Finish workout and save + Future finishWorkout({String? notes}) async { + if (!hasActiveWorkout) { + throw StateError('No active workout to finish.'); + } + + final duration = _workoutStartTime != null + ? DateTime.now().difference(_workoutStartTime!).inMinutes + : 0; + + // Filter out exercises with no sets + final completedExercises = _currentExerciseLogs + .where((log) => log.sets.isNotEmpty) + .toList(); + + final session = WorkoutSession( + id: _uuid.v4(), + date: _workoutStartTime ?? DateTime.now(), + routineId: _activeRoutine?.id, + exercises: completedExercises, + duration: duration, + notes: notes, + ); + + await _storage.saveWorkoutSession(session); + + // Notify callback if provided + onWorkoutSaved?.call(session); + + // Clear active workout state + _resetState(); + notifyListeners(); + + return session; + } + + /// Cancel workout without saving + void cancelWorkout() { + _resetState(); + notifyListeners(); + } + + void _resetState() { + _activeRoutine = null; + _currentExerciseIndex = 0; + _currentExerciseLogs = []; + _workoutStartTime = null; + } +} diff --git a/workout-logger/lib/services/managers/analytics_manager.dart b/workout-logger/lib/services/managers/analytics_manager.dart new file mode 100644 index 0000000..0c46ab8 --- /dev/null +++ b/workout-logger/lib/services/managers/analytics_manager.dart @@ -0,0 +1,177 @@ +// Analytics Manager (Single Responsibility Principle) +// +// This class is responsible ONLY for analytics and statistics. +// It handles: +// - Volume progression calculations +// - Weekly stats by muscle group +// - Growth model training +// - Set recommendations +// +// It does NOT handle data persistence or workout execution. + +import 'package:flutter/foundation.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; +import '../interfaces/ml_service_interface.dart'; + +/// Manages analytics and statistics for workouts. +/// +/// Following Single Responsibility Principle: this class only handles +/// analytics calculations, not data persistence or workout execution. +class AnalyticsManager extends ChangeNotifier { + final IStorageService _storage; + final IMLService _mlService; + + // Growth models for each exercise + final Map _growthModels = {}; + + // Callback to update targets with new growth models + final void Function(String exerciseId, GrowthModel model)? + onGrowthModelUpdated; + + AnalyticsManager(this._storage, this._mlService, {this.onGrowthModelUpdated}); + + // Getters + Map get growthModels => Map.unmodifiable(_growthModels); + + /// Get growth model for a specific exercise + GrowthModel? getGrowthModel(String exerciseId) => _growthModels[exerciseId]; + + /// Train all growth models from session history + Future trainAllGrowthModels(List sessions) async { + final exerciseIds = {}; + + // Get all unique exercise IDs from sessions + for (var session in sessions) { + for (var log in session.exercises) { + exerciseIds.add(log.exerciseId); + } + } + + // Train model for each exercise + for (var exerciseId in exerciseIds) { + await updateGrowthModel(exerciseId, sessions); + } + } + + /// Update growth model for a specific exercise + Future updateGrowthModel( + String exerciseId, + List sessions, + ) async { + final dataPoints = _mlService.extractExerciseDataPoints( + exerciseId, + sessions, + ); + + if (dataPoints.length >= 2) { + final model = _mlService.trainGrowthModel(dataPoints); + _growthModels[exerciseId] = model; + onGrowthModelUpdated?.call(exerciseId, model); + } else { + // Remove stale model if not enough data + _growthModels.remove(exerciseId); + } + } + + /// Update growth models for multiple exercises + Future updateGrowthModelsForExercises( + Set exerciseIds, + List sessions, + ) async { + for (var exerciseId in exerciseIds) { + await updateGrowthModel(exerciseId, sessions); + } + notifyListeners(); + } + + /// Get set recommendations for an exercise + List getRecommendations( + String exerciseId, + List sessions, + ) { + // Find last session with this exercise + ExerciseLog? lastLog; + for (var session in sessions) { + for (var log in session.exercises) { + if (log.exerciseId == exerciseId) { + lastLog = log; + break; + } + } + if (lastLog != null) break; + } + + if (lastLog == null || lastLog.sets.isEmpty) { + return _mlService.getDefaultRecommendations(3); + } + + return _mlService.recommendSets( + lastSession: lastLog.sets, + growthModel: _growthModels[exerciseId], + ); + } + + /// Get volume progression for an exercise + List<({DateTime date, double volume})> getVolumeProgression( + String exerciseId, + List sessions, + ) { + final data = <({DateTime date, double volume})>[]; + + // Process sessions in chronological order (oldest first) + final sortedSessions = List.from(sessions) + ..sort((a, b) => a.date.compareTo(b.date)); + + for (var session in sortedSessions) { + for (var log in session.exercises) { + if (log.exerciseId == exerciseId) { + data.add((date: session.date, volume: log.totalVolume)); + break; + } + } + } + + return data; + } + + /// Get weekly volume by muscle group + Map getWeeklyVolumeByMuscle( + List sessions, + List exercises, + ) { + final volumeByMuscle = {}; + final weekAgo = DateTime.now().subtract(const Duration(days: 7)); + + for (var session in sessions) { + if (session.date.isBefore(weekAgo)) continue; + + for (var log in session.exercises) { + final exercise = _findExercise(log.exerciseId, exercises); + if (exercise == null) continue; + + for (var activation in exercise.muscleActivations) { + final muscleVolume = + log.totalVolume * (activation.activationPercentage / 100); + volumeByMuscle[activation.muscleGroupId] = + (volumeByMuscle[activation.muscleGroupId] ?? 0) + muscleVolume; + } + } + } + + return volumeByMuscle; + } + + Exercise? _findExercise(String id, List exercises) { + try { + return exercises.firstWhere((e) => e.id == id); + } catch (_) { + return null; + } + } + + /// Get quick stats for dashboard + Future> getQuickStats() async { + return await _storage.getQuickStats(); + } +} diff --git a/workout-logger/lib/services/managers/exercise_manager.dart b/workout-logger/lib/services/managers/exercise_manager.dart new file mode 100644 index 0000000..3aaaae9 --- /dev/null +++ b/workout-logger/lib/services/managers/exercise_manager.dart @@ -0,0 +1,166 @@ +// Exercise Manager (Single Responsibility Principle) +// +// This class is responsible ONLY for managing exercises (built-in and custom). +// It handles: +// - Loading exercises from storage and database +// - Adding/deleting custom exercises +// - Exercise lookups +// +// It does NOT handle workout sessions or analytics. + +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; +import '../../data/exercise_database.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; + +/// Manages exercises (built-in and custom). +/// +/// Following Single Responsibility Principle: this class only handles +/// exercise management, not workout execution or history. +class ExerciseManager extends ChangeNotifier { + final IStorageService _storage; + final Uuid _uuid = const Uuid(); + + List _allExercises = []; + + /// Allowed category values for exercises + static const Set allowedCategories = {'compound', 'isolation'}; + + ExerciseManager(this._storage); + + // Getters + List get allExercises => List.unmodifiable(_allExercises); + List get customExercises => + _allExercises.where((e) => e.isCustom).toList(); + List get builtInExercises => + _allExercises.where((e) => !e.isCustom).toList(); + + /// Load all exercises from storage and database + Future loadExercises() async { + _allExercises = await _storage.getAllExercises(); + notifyListeners(); + } + + /// Get an exercise by ID + Exercise? getExercise(String id) { + try { + return _allExercises.firstWhere((e) => e.id == id); + } catch (_) { + // Fallback to built-in database + return ExerciseDatabase.getById(id); + } + } + + /// Get exercise name by ID + String getExerciseName(String id) { + return getExercise(id)?.name ?? 'Unknown Exercise'; + } + + /// Get muscle group name by ID + String getMuscleGroupName(String id) { + return MuscleGroups.names[id] ?? 'Unknown'; + } + + /// Add a custom exercise + /// + /// Throws [ArgumentError] if inputs are invalid. + Future addCustomExercise({ + required String name, + required String category, + required String primaryMuscleGroupId, + }) async { + // Validate and normalize name + final normalizedName = name.trim().replaceAll(RegExp(r'\s+'), ' '); + if (normalizedName.isEmpty) { + throw ArgumentError('Exercise name cannot be empty'); + } + + // Validate primaryMuscleGroupId + if (primaryMuscleGroupId.isEmpty) { + throw ArgumentError('Primary muscle group is required'); + } + + // Normalize and validate category + final normalizedCategory = category.toLowerCase().trim(); + if (!allowedCategories.contains(normalizedCategory)) { + throw ArgumentError( + 'Invalid category "$category". Must be one of: ${allowedCategories.join(", ")}', + ); + } + + // Generate unique ID + final id = 'custom_${_uuid.v4()}'; + + // Create muscle activation (100% for primary muscle in v1) + final muscleActivations = [ + MuscleActivation( + muscleGroupId: primaryMuscleGroupId, + activationPercentage: 100, + ), + ]; + + final exercise = Exercise( + id: id, + name: normalizedName, + muscleActivations: muscleActivations, + category: normalizedCategory, + isCustom: true, + ); + + await _storage.saveCustomExercise(exercise); + _allExercises = List.from(_allExercises)..add(exercise); + notifyListeners(); + + return exercise; + } + + /// Delete a custom exercise + /// + /// [canDelete] callback should check if the exercise is used elsewhere. + /// Returns false if exercise not found, not custom, or canDelete returns false. + Future deleteCustomExercise( + String exerciseId, { + bool Function(String exerciseId)? canDelete, + }) async { + final exercise = getExercise(exerciseId); + if (exercise == null || !exercise.isCustom) { + return false; + } + + // Check if deletion is allowed + if (canDelete != null && !canDelete(exerciseId)) { + return false; + } + + await _storage.deleteCustomExercise(exerciseId); + _allExercises = List.from(_allExercises) + ..removeWhere((e) => e.id == exerciseId); + notifyListeners(); + + return true; + } + + /// Get exercises by muscle group + List getExercisesByMuscleGroup(String muscleGroupId) { + return _allExercises + .where( + (e) => + e.muscleActivations.any((m) => m.muscleGroupId == muscleGroupId), + ) + .toList(); + } + + /// Get exercises by category + List getExercisesByCategory(String category) { + return _allExercises.where((e) => e.category == category).toList(); + } + + /// Search exercises by name + List searchExercises(String query) { + final lowerQuery = query.toLowerCase(); + return _allExercises + .where((e) => e.name.toLowerCase().contains(lowerQuery)) + .toList(); + } +} diff --git a/workout-logger/lib/services/managers/history_manager.dart b/workout-logger/lib/services/managers/history_manager.dart new file mode 100644 index 0000000..4d3bc39 --- /dev/null +++ b/workout-logger/lib/services/managers/history_manager.dart @@ -0,0 +1,145 @@ +// History Manager (Single Responsibility Principle) +// +// This class is responsible ONLY for managing workout history (past sessions). +// It handles: +// - Loading/saving workout sessions +// - Updating and deleting sessions +// - Querying session history +// +// It does NOT handle active workout state or analytics calculations. + +import 'package:flutter/foundation.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; + +/// Manages workout session history. +/// +/// Following Single Responsibility Principle: this class only handles +/// historical session data, not active workouts or analytics. +class HistoryManager extends ChangeNotifier { + final IStorageService _storage; + + List _sessions = []; + + // Callback for when sessions change (to notify other managers like AnalyticsManager) + final void Function(Set affectedExerciseIds)? onSessionsChanged; + + HistoryManager(this._storage, {this.onSessionsChanged}); + + // Getters + List get sessions => List.unmodifiable(_sessions); + int get totalSessions => _sessions.length; + + /// Load all sessions from storage + Future loadSessions() async { + _sessions = await _storage.getAllWorkoutSessions(); + notifyListeners(); + } + + /// Add a new session to history + void addSession(WorkoutSession session) { + _sessions.insert(0, session); + final exerciseIds = session.exercises.map((e) => e.exerciseId).toSet(); + onSessionsChanged?.call(exerciseIds); + notifyListeners(); + } + + /// Get a session by ID + WorkoutSession? getSession(String sessionId) { + try { + return _sessions.firstWhere((s) => s.id == sessionId); + } catch (_) { + return null; + } + } + + /// Get sessions for a specific exercise + List getSessionsForExercise(String exerciseId) { + return _sessions + .where( + (session) => session.exercises.any((e) => e.exerciseId == exerciseId), + ) + .toList(); + } + + /// Get sessions within a date range + List getSessionsInDateRange(DateTime start, DateTime end) { + return _sessions + .where( + (session) => + session.date.isAfter(start) && session.date.isBefore(end), + ) + .toList(); + } + + /// Get sessions from the last N days + List getRecentSessions(int days) { + final cutoff = DateTime.now().subtract(Duration(days: days)); + return _sessions.where((s) => s.date.isAfter(cutoff)).toList(); + } + + /// Delete a workout session + Future deleteSession(String sessionId) async { + final sessionIndex = _sessions.indexWhere((s) => s.id == sessionId); + + if (sessionIndex == -1) { + debugPrint('Session $sessionId not found, skipping deletion'); + return; + } + + final session = _sessions[sessionIndex]; + final affectedExerciseIds = session.exercises + .map((e) => e.exerciseId) + .toSet(); + + await _storage.deleteWorkoutSession(sessionId); + _sessions = List.from(_sessions)..removeAt(sessionIndex); + + onSessionsChanged?.call(affectedExerciseIds); + notifyListeners(); + } + + /// Update an existing workout session + Future updateSession(WorkoutSession updatedSession) async { + final previousSession = _sessions.firstWhere( + (s) => s.id == updatedSession.id, + orElse: () => updatedSession, + ); + + // Gather affected exercise IDs from both old and new versions + final previousExerciseIds = previousSession.exercises + .map((e) => e.exerciseId) + .toSet(); + final updatedExerciseIds = updatedSession.exercises + .map((e) => e.exerciseId) + .toSet(); + final allAffectedExerciseIds = previousExerciseIds.union( + updatedExerciseIds, + ); + + await _storage.saveWorkoutSession(updatedSession); + + final index = _sessions.indexWhere((s) => s.id == updatedSession.id); + if (index != -1) { + _sessions = List.from(_sessions)..[index] = updatedSession; + } + + // Keep sorted by date + _sessions.sort((a, b) => b.date.compareTo(a.date)); + + onSessionsChanged?.call(allAffectedExerciseIds); + notifyListeners(); + } + + /// Get last session containing a specific exercise + ExerciseLog? getLastSessionForExercise(String exerciseId) { + for (var session in _sessions) { + for (var log in session.exercises) { + if (log.exerciseId == exerciseId) { + return log; + } + } + } + return null; + } +} diff --git a/workout-logger/lib/services/managers/managers.dart b/workout-logger/lib/services/managers/managers.dart new file mode 100644 index 0000000..ebf68c3 --- /dev/null +++ b/workout-logger/lib/services/managers/managers.dart @@ -0,0 +1,17 @@ +// Managers barrel export +// +// Following Single Responsibility Principle, the WorkoutProvider has been +// split into these focused managers: +// - ActiveWorkoutManager: Current workout session state +// - HistoryManager: Past workout sessions +// - RoutineManager: Workout routines +// - ExerciseManager: Exercise library (built-in + custom) +// - TargetManager: Goals and targets +// - AnalyticsManager: Statistics and recommendations + +export 'active_workout_manager.dart'; +export 'history_manager.dart'; +export 'routine_manager.dart'; +export 'exercise_manager.dart'; +export 'target_manager.dart'; +export 'analytics_manager.dart'; diff --git a/workout-logger/lib/services/managers/routine_manager.dart b/workout-logger/lib/services/managers/routine_manager.dart new file mode 100644 index 0000000..7391835 --- /dev/null +++ b/workout-logger/lib/services/managers/routine_manager.dart @@ -0,0 +1,85 @@ +// Routine Manager (Single Responsibility Principle) +// +// This class is responsible ONLY for managing workout routines. +// It handles: +// - Creating/updating/deleting routines +// - Loading routines from storage +// +// It does NOT handle workout execution or session history. + +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; + +/// Manages workout routines. +/// +/// Following Single Responsibility Principle: this class only handles +/// routine management, not workout execution or history. +class RoutineManager extends ChangeNotifier { + final IStorageService _storage; + final Uuid _uuid = const Uuid(); + + List _routines = []; + + RoutineManager(this._storage); + + // Getters + List get routines => List.unmodifiable(_routines); + int get totalRoutines => _routines.length; + + /// Load all routines from storage + Future loadRoutines() async { + _routines = await _storage.getAllRoutines(); + notifyListeners(); + } + + /// Get a routine by ID + Routine? getRoutine(String id) { + try { + return _routines.firstWhere((r) => r.id == id); + } catch (_) { + return null; + } + } + + /// Create a new routine + Future createRoutine(String name, List exerciseIds) async { + final routine = Routine( + id: _uuid.v4(), + name: name, + exerciseIds: exerciseIds, + ); + await _storage.saveRoutine(routine); + _routines.add(routine); + notifyListeners(); + return routine; + } + + /// Update an existing routine + Future updateRoutine(Routine routine) async { + await _storage.saveRoutine(routine); + final index = _routines.indexWhere((r) => r.id == routine.id); + if (index != -1) { + _routines[index] = routine; + } + notifyListeners(); + } + + /// Delete a routine + Future deleteRoutine(String id) async { + await _storage.deleteRoutine(id); + _routines.removeWhere((r) => r.id == id); + notifyListeners(); + } + + /// Check if an exercise is used in any routine + bool isExerciseUsedInRoutines(String exerciseId) { + return _routines.any((r) => r.exerciseIds.contains(exerciseId)); + } + + /// Get all routines containing a specific exercise + List getRoutinesWithExercise(String exerciseId) { + return _routines.where((r) => r.exerciseIds.contains(exerciseId)).toList(); + } +} diff --git a/workout-logger/lib/services/managers/target_manager.dart b/workout-logger/lib/services/managers/target_manager.dart new file mode 100644 index 0000000..eb1a0d0 --- /dev/null +++ b/workout-logger/lib/services/managers/target_manager.dart @@ -0,0 +1,162 @@ +// Target Manager (Single Responsibility Principle) +// +// This class is responsible ONLY for managing workout targets/goals. +// It handles: +// - Creating/updating/deleting targets +// - Tracking progress towards targets +// - Predicting target completion +// +// It uses the Strategy Pattern for calculating target values, +// following the Open/Closed Principle. + +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; +import '../interfaces/ml_service_interface.dart'; +import '../strategies/target_calculator.dart'; + +/// Manages workout targets and goals. +/// +/// Following Single Responsibility Principle: this class only handles +/// target management, not workout execution or history. +/// +/// Following Open/Closed Principle: uses TargetCalculatorStrategy for +/// calculating target values, allowing new target types to be added +/// without modifying this class. +class TargetManager extends ChangeNotifier { + final IStorageService _storage; + final IMLService _mlService; + final Uuid _uuid = const Uuid(); + + List _targets = []; + + // Growth models for prediction (keyed by exerciseId) + final Map _growthModels = {}; + + TargetManager(this._storage, this._mlService); + + // Getters + List get targets => List.unmodifiable(_targets); + int get totalTargets => _targets.length; + List get activeTargets => + _targets.where((t) => !t.isCompleted).toList(); + List get completedTargets => + _targets.where((t) => t.isCompleted).toList(); + + /// Load all targets from storage + Future loadTargets() async { + _targets = await _storage.getAllTargets(); + notifyListeners(); + } + + /// Get targets for a specific exercise + List getTargetsForExercise(String exerciseId) { + return _targets.where((t) => t.exerciseId == exerciseId).toList(); + } + + /// Update growth model for an exercise + void updateGrowthModel(String exerciseId, GrowthModel model) { + _growthModels[exerciseId] = model; + } + + /// Get growth model for an exercise + GrowthModel? getGrowthModel(String exerciseId) => _growthModels[exerciseId]; + + /// Create a new target + Future createTarget({ + required String exerciseId, + required String type, + required double targetValue, + required List sessions, + }) async { + // Calculate current value using strategy pattern + final currentValue = TargetCalculatorFactory.calculateCurrentValue( + exerciseId, + type, + sessions, + ); + + // Predict completion date using growth model + DateTime? estimatedDate; + final growthModel = _growthModels[exerciseId]; + if (growthModel != null) { + estimatedDate = _mlService.predictTargetCompletion( + currentValue: currentValue, + targetValue: targetValue, + growthModel: growthModel, + ); + } + + final target = Target( + id: _uuid.v4(), + exerciseId: exerciseId, + targetType: type, + targetValue: targetValue, + currentValue: currentValue, + estimatedCompletionDate: estimatedDate, + isCompleted: currentValue >= targetValue, + ); + + await _storage.saveTarget(target); + _targets.add(target); + notifyListeners(); + + return target; + } + + /// Recalculate targets for affected exercises + Future recalculateTargets( + Set exerciseIds, + List sessions, + ) async { + for (var exerciseId in exerciseIds) { + final relevantTargets = _targets + .where((t) => t.exerciseId == exerciseId) + .toList(); + + for (var target in relevantTargets) { + // Recalculate current value using strategy pattern + final newValue = TargetCalculatorFactory.calculateCurrentValue( + exerciseId, + target.targetType, + sessions, + ); + + target.currentValue = newValue; + target.isCompleted = newValue >= target.targetValue; + + // Update prediction if not completed + if (!target.isCompleted) { + final growthModel = _growthModels[exerciseId]; + if (growthModel != null) { + target.estimatedCompletionDate = _mlService.predictTargetCompletion( + currentValue: newValue, + targetValue: target.targetValue, + growthModel: growthModel, + ); + } else { + target.estimatedCompletionDate = null; + } + } else { + target.estimatedCompletionDate = null; + } + + await _storage.saveTarget(target); + } + } + notifyListeners(); + } + + /// Delete a target + Future deleteTarget(String id) async { + await _storage.deleteTarget(id); + _targets.removeWhere((t) => t.id == id); + notifyListeners(); + } + + /// Check if an exercise is used in any target + bool isExerciseUsedInTargets(String exerciseId) { + return _targets.any((t) => t.exerciseId == exerciseId); + } +} diff --git a/workout-logger/lib/services/ml_service.dart b/workout-logger/lib/services/ml_service.dart index d262522..47f8997 100644 --- a/workout-logger/lib/services/ml_service.dart +++ b/workout-logger/lib/services/ml_service.dart @@ -1,16 +1,36 @@ // ML Service - Linear Regression for Growth Rate Prediction // and Progressive Overload Recommendations +// +// This is a concrete implementation of IMLService. +// Following Dependency Inversion Principle: high-level modules depend on +// the IMLService abstraction, not this concrete class. +// Following Open/Closed Principle: new ML algorithms can be added by +// creating new implementations of IMLService. import 'dart:math'; import '../models/models.dart'; +import 'interfaces/ml_service_interface.dart'; -class MLService { +// Re-export DataPoint from interface for backward compatibility +export 'interfaces/ml_service_interface.dart' show DataPoint; + +/// Linear regression based implementation of the ML service. +/// +/// This class implements IMLService, allowing it to be swapped +/// for other ML algorithms without modifying the consuming code. +class MLService implements IMLService { // ==================== LINEAR REGRESSION ==================== - + /// Train a growth model using simple linear regression /// x = session number (0, 1, 2, ...) /// y = volume or performance metric - static GrowthModel trainGrowthModel(List dataPoints) { + @override + GrowthModel trainGrowthModel(List dataPoints) { + return MLService.trainGrowthModelStatic(dataPoints); + } + + /// Static version for backward compatibility + static GrowthModel trainGrowthModelStatic(List dataPoints) { if (dataPoints.isEmpty) { return GrowthModel( slope: 0, @@ -63,7 +83,9 @@ class MLService { ssResidual += pow(point.y - predicted, 2); } - final double r2Value = ssTotal > 0 ? (1 - (ssResidual / ssTotal)).toDouble() : 0.0; + final double r2Value = ssTotal > 0 + ? (1 - (ssResidual / ssTotal)).toDouble() + : 0.0; return GrowthModel( slope: slope, @@ -74,7 +96,8 @@ class MLService { } /// Extract data points from workout history for a specific exercise - static List extractExerciseDataPoints( + @override + List extractExerciseDataPoints( String exerciseId, List sessions, ) { @@ -88,10 +111,9 @@ class MLService { for (var session in sorted) { for (var exerciseLog in session.exercises) { if (exerciseLog.exerciseId == exerciseId) { - dataPoints.add(DataPoint( - x: sessionIndex.toDouble(), - y: exerciseLog.totalVolume, - )); + dataPoints.add( + DataPoint(x: sessionIndex.toDouble(), y: exerciseLog.totalVolume), + ); sessionIndex++; break; } @@ -104,9 +126,10 @@ class MLService { // ==================== RECOMMENDATIONS ==================== /// Generate set recommendations based on previous performance - static List recommendSets({ + @override + List recommendSets({ required List lastSession, - required GrowthModel? growthModel, + GrowthModel? growthModel, double targetProgressPercent = 5.0, // Default 5% increase }) { if (lastSession.isEmpty) { @@ -115,9 +138,10 @@ class MLService { // Calculate target volume increase final lastVolume = lastSession.fold( - 0, (sum, set) => sum + set.volume + 0, + (sum, set) => sum + set.volume, ); - + // Use growth model slope if available, otherwise use default percentage double targetVolumeIncrease; if (growthModel != null && growthModel.r2 > 0.3) { @@ -154,7 +178,7 @@ class MLService { if (currentReps < 12) { final newReps = currentReps + 1; final newVolume = currentWeight * newReps; - + if (newVolume >= targetVolume * 0.95) { return SetRecommendation( weight: currentWeight, @@ -168,7 +192,7 @@ class MLService { if (currentReps < 11) { final twoMoreReps = currentReps + 2; final volumeWith2Reps = currentWeight * twoMoreReps; - + if (volumeWith2Reps >= targetVolume * 0.95) { return SetRecommendation( weight: currentWeight, @@ -183,7 +207,7 @@ class MLService { // Strategy 2: Increase weight final weightIncrement = currentWeight < 40 ? 2.5 : 5.0; final newWeight = currentWeight + weightIncrement; - + // When increasing weight, maintain or slightly reduce reps int newReps = currentReps; if (currentReps >= 10) { @@ -192,7 +216,7 @@ class MLService { newReps = newReps.clamp(6, 15); final newVolume = newWeight * newReps; - + String confidence; if (newVolume >= targetVolume * 0.9 && newVolume <= targetVolume * 1.1) { confidence = 'high'; @@ -211,19 +235,24 @@ class MLService { } /// Fill in default recommendations for a new exercise - static List getDefaultRecommendations(int setCount) { - return List.generate(setCount, (index) => SetRecommendation( - weight: 0, - reps: 10, - confidence: 'low', - reasoning: 'No previous data - adjust based on feel', - )); + @override + List getDefaultRecommendations(int setCount) { + return List.generate( + setCount, + (index) => SetRecommendation( + weight: 0, + reps: 10, + confidence: 'low', + reasoning: 'No previous data - adjust based on feel', + ), + ); } // ==================== TARGET PREDICTIONS ==================== /// Predict when a target will be achieved - static DateTime? predictTargetCompletion({ + @override + DateTime? predictTargetCompletion({ required double currentValue, required double targetValue, required GrowthModel growthModel, @@ -253,7 +282,9 @@ class MLService { required GrowthModel growthModel, double sessionsPerWeek = 3.0, }) { - final expected = predictTargetCompletion( + // Create instance to call the non-static method + final mlService = MLService(); + final expected = mlService.predictTargetCompletion( currentValue: currentValue, targetValue: targetValue, growthModel: growthModel, @@ -273,11 +304,3 @@ class MLService { ); } } - -/// Simple data point for regression -class DataPoint { - final double x; - final double y; - - DataPoint({required this.x, required this.y}); -} diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index 8b91bc6..aa9c8fb 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -1,11 +1,21 @@ // Storage Service - Hive-based local persistence +// +// This is a concrete implementation of IStorageService using Hive. +// Following Dependency Inversion Principle: high-level modules depend on +// the IStorageService abstraction, not this concrete class. import 'dart:convert'; import 'package:hive_flutter/hive_flutter.dart'; import '../models/models.dart'; import '../data/exercise_database.dart'; - -class StorageService { +import 'interfaces/storage_service_interface.dart'; + +/// Hive-based implementation of the storage service. +/// +/// This class implements IStorageService, allowing it to be swapped +/// for other storage backends (SQL, Firebase, etc.) without modifying +/// the consuming code. +class StorageService implements IStorageService { static const String _workoutSessionsBox = 'workout_sessions'; static const String _routinesBox = 'routines'; static const String _targetsBox = 'targets'; @@ -32,7 +42,9 @@ class StorageService { _routinesBoxInstance = await Hive.openBox(_routinesBox); _targetsBoxInstance = await Hive.openBox(_targetsBox); _muscleGroupsBoxInstance = await Hive.openBox(_muscleGroupsBox); - _customExercisesBoxInstance = await Hive.openBox(_customExercisesBox); + _customExercisesBoxInstance = await Hive.openBox( + _customExercisesBox, + ); _settingsBoxInstance = await Hive.openBox(_settingsBox); // Initialize default muscle groups if empty @@ -77,16 +89,24 @@ class StorageService { Future> getSessionsForExercise(String exerciseId) async { final allSessions = await getAllWorkoutSessions(); - return allSessions.where((session) => - session.exercises.any((e) => e.exerciseId == exerciseId) - ).toList(); + return allSessions + .where( + (session) => session.exercises.any((e) => e.exerciseId == exerciseId), + ) + .toList(); } - Future> getSessionsInDateRange(DateTime start, DateTime end) async { + Future> getSessionsInDateRange( + DateTime start, + DateTime end, + ) async { final allSessions = await getAllWorkoutSessions(); - return allSessions.where((session) => - session.date.isAfter(start) && session.date.isBefore(end) - ).toList(); + return allSessions + .where( + (session) => + session.date.isAfter(start) && session.date.isBefore(end), + ) + .toList(); } // ==================== ROUTINES ==================== @@ -144,13 +164,19 @@ class StorageService { // ==================== MUSCLE GROUPS ==================== - Future updateMuscleGroupGrowthRate(String muscleGroupId, double rate) async { + Future updateMuscleGroupGrowthRate( + String muscleGroupId, + double rate, + ) async { final json = _muscleGroupsBoxInstance.get(muscleGroupId); if (json != null) { final mg = MuscleGroup.fromJson(jsonDecode(json)); mg.growthRate = rate; mg.lastUpdated = DateTime.now(); - await _muscleGroupsBoxInstance.put(muscleGroupId, jsonEncode(mg.toJson())); + await _muscleGroupsBoxInstance.put( + muscleGroupId, + jsonEncode(mg.toJson()), + ); } } @@ -171,7 +197,10 @@ class StorageService { // ==================== CUSTOM EXERCISES ==================== Future saveCustomExercise(Exercise exercise) async { - await _customExercisesBoxInstance.put(exercise.id, jsonEncode(exercise.toJson())); + await _customExercisesBoxInstance.put( + exercise.id, + jsonEncode(exercise.toJson()), + ); } Future> getCustomExercises() async { @@ -234,7 +263,7 @@ class StorageService { Future importData(String jsonData) async { final data = jsonDecode(jsonData) as Map; - + // Import sessions if (data['sessions'] != null) { for (var json in data['sessions']) { @@ -266,12 +295,14 @@ class StorageService { final sessions = await getAllWorkoutSessions(); final now = DateTime.now(); final weekAgo = now.subtract(const Duration(days: 7)); - - final weekSessions = sessions.where((s) => s.date.isAfter(weekAgo)).toList(); - + + final weekSessions = sessions + .where((s) => s.date.isAfter(weekAgo)) + .toList(); + double weeklyVolume = 0; int exercisesCompleted = 0; - + for (var session in weekSessions) { weeklyVolume += session.totalVolume; exercisesCompleted += session.exercises.length; diff --git a/workout-logger/lib/services/strategies/target_calculator.dart b/workout-logger/lib/services/strategies/target_calculator.dart new file mode 100644 index 0000000..f483501 --- /dev/null +++ b/workout-logger/lib/services/strategies/target_calculator.dart @@ -0,0 +1,129 @@ +// Target Calculator Strategy Pattern (Open/Closed Principle) +// +// This module implements the Strategy Pattern for calculating target values. +// Following Open/Closed Principle: new target types can be added by creating +// new strategy implementations without modifying existing code. +// Following Single Responsibility Principle: each strategy handles one type of calculation. + +import '../../models/models.dart'; + +/// Abstract strategy for calculating target values from workout history. +/// +/// New target types (reps, weight, volume, duration, etc.) can be added +/// by implementing this interface without modifying existing calculators. +abstract class TargetCalculatorStrategy { + /// Calculate the current best/max value for this target type + double calculate(String exerciseId, List sessions); +} + +/// Calculator for maximum reps achieved +class RepsTargetCalculator implements TargetCalculatorStrategy { + @override + double calculate(String exerciseId, List sessions) { + double bestValue = 0; + + for (var session in sessions) { + for (var log in session.exercises) { + if (log.exerciseId == exerciseId && log.sets.isNotEmpty) { + final maxReps = log.sets + .map((s) => s.reps) + .reduce((a, b) => a > b ? a : b); + if (maxReps > bestValue) { + bestValue = maxReps.toDouble(); + } + } + } + } + + return bestValue; + } +} + +/// Calculator for maximum weight lifted +class WeightTargetCalculator implements TargetCalculatorStrategy { + @override + double calculate(String exerciseId, List sessions) { + double bestValue = 0; + + for (var session in sessions) { + for (var log in session.exercises) { + if (log.exerciseId == exerciseId && log.sets.isNotEmpty) { + final maxWeight = log.sets + .map((s) => s.weight) + .reduce((a, b) => a > b ? a : b); + if (maxWeight > bestValue) { + bestValue = maxWeight; + } + } + } + } + + return bestValue; + } +} + +/// Calculator for total volume (weight × reps) +class VolumeTargetCalculator implements TargetCalculatorStrategy { + @override + double calculate(String exerciseId, List sessions) { + double bestValue = 0; + + for (var session in sessions) { + for (var log in session.exercises) { + if (log.exerciseId == exerciseId && log.sets.isNotEmpty) { + if (log.totalVolume > bestValue) { + bestValue = log.totalVolume; + } + } + } + } + + return bestValue; + } +} + +/// Factory for creating target calculators based on target type. +/// +/// This factory centralizes the creation logic and makes it easy to +/// add new target types without modifying the main business logic. +class TargetCalculatorFactory { + static final Map _strategies = { + 'reps': RepsTargetCalculator(), + 'weight': WeightTargetCalculator(), + 'volume': VolumeTargetCalculator(), + }; + + /// Get a calculator for the specified target type + /// + /// Returns null if the target type is not supported. + static TargetCalculatorStrategy? getCalculator(String targetType) { + return _strategies[targetType.toLowerCase()]; + } + + /// Register a new target calculator strategy + /// + /// This allows extending the system with new target types + /// without modifying existing code (Open/Closed Principle). + static void registerCalculator( + String targetType, + TargetCalculatorStrategy strategy, + ) { + _strategies[targetType.toLowerCase()] = strategy; + } + + /// Get all supported target types + static List get supportedTypes => _strategies.keys.toList(); + + /// Calculate current value for a target using the appropriate strategy + static double calculateCurrentValue( + String exerciseId, + String targetType, + List sessions, + ) { + final calculator = getCalculator(targetType); + if (calculator == null) { + throw ArgumentError('Unsupported target type: $targetType'); + } + return calculator.calculate(exerciseId, sessions); + } +} diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index bc8fa70..4929edd 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -1,14 +1,29 @@ // Workout Provider - State Management for the App +// +// NOTE: This class is maintained for backward compatibility. +// For new code, consider using the individual managers: +// - ActiveWorkoutManager: Current workout state +// - HistoryManager: Past sessions +// - RoutineManager: Workout routines +// - ExerciseManager: Exercise library +// - TargetManager: Goals and targets +// - AnalyticsManager: Statistics and recommendations +// +// Following Dependency Inversion Principle: this class now depends on +// abstractions (IStorageService, IMLService) rather than concrete implementations. import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; import '../models/models.dart'; import '../data/exercise_database.dart'; -import 'storage_service.dart'; +import 'interfaces/storage_service_interface.dart'; +import 'interfaces/ml_service_interface.dart'; import 'ml_service.dart'; +import 'strategies/target_calculator.dart'; class WorkoutProvider extends ChangeNotifier { - final StorageService _storage; + final IStorageService _storage; + final IMLService _mlService; final Uuid _uuid = const Uuid(); // State @@ -41,7 +56,12 @@ class WorkoutProvider extends ChangeNotifier { List get currentExerciseLogs => _currentExerciseLogs; DateTime? get workoutStartTime => _workoutStartTime; - WorkoutProvider(this._storage); + /// Create WorkoutProvider with dependency injection. + /// + /// Following Dependency Inversion Principle: accepts abstractions + /// rather than concrete implementations. + WorkoutProvider(this._storage, {IMLService? mlService}) + : _mlService = mlService ?? MLService(); // ==================== INITIALIZATION ==================== @@ -77,12 +97,12 @@ class WorkoutProvider extends ChangeNotifier { } Future _updateGrowthModel(String exerciseId) async { - final dataPoints = MLService.extractExerciseDataPoints( + final dataPoints = _mlService.extractExerciseDataPoints( exerciseId, _sessions, ); if (dataPoints.length >= 2) { - _growthModels[exerciseId] = MLService.trainGrowthModel(dataPoints); + _growthModels[exerciseId] = _mlService.trainGrowthModel(dataPoints); } else { // Remove stale model if not enough data to train (e.g. after deletion) _growthModels.remove(exerciseId); @@ -387,10 +407,10 @@ class WorkoutProvider extends ChangeNotifier { } if (lastLog == null || lastLog.sets.isEmpty) { - return MLService.getDefaultRecommendations(3); + return _mlService.getDefaultRecommendations(3); } - return MLService.recommendSets( + return _mlService.recommendSets( lastSession: lastLog.sets, growthModel: _growthModels[exerciseId], ); @@ -524,14 +544,14 @@ class WorkoutProvider extends ChangeNotifier { required String type, required double targetValue, }) async { - // Get current value from history + // Get current value from history using Strategy Pattern (Open/Closed Principle) double currentValue = _calculateCurrentTargetValue(exerciseId, type); - // Predict completion date + // Predict completion date using injected ML service DateTime? estimatedDate; final growthModel = _growthModels[exerciseId]; if (growthModel != null) { - estimatedDate = MLService.predictTargetCompletion( + estimatedDate = _mlService.predictTargetCompletion( currentValue: currentValue, targetValue: targetValue, growthModel: growthModel, @@ -573,11 +593,11 @@ class WorkoutProvider extends ChangeNotifier { // If it wasn't completed but now is (unlikely on delete, but possible on edit), complete it target.isCompleted = newValue >= target.targetValue; - // Update prediction if not completed + // Update prediction if not completed using injected ML service if (!target.isCompleted) { final growthModel = _growthModels[exerciseId]; if (growthModel != null) { - target.estimatedCompletionDate = MLService.predictTargetCompletion( + target.estimatedCompletionDate = _mlService.predictTargetCompletion( currentValue: newValue, targetValue: target.targetValue, growthModel: growthModel, @@ -595,7 +615,15 @@ class WorkoutProvider extends ChangeNotifier { } /// Calculate the current best value for a target type from all history + /// Uses Strategy Pattern (Open/Closed Principle) via TargetCalculatorFactory double _calculateCurrentTargetValue(String exerciseId, String targetType) { + // Try to use the strategy pattern first + final calculator = TargetCalculatorFactory.getCalculator(targetType); + if (calculator != null) { + return calculator.calculate(exerciseId, _sessions); + } + + // Fallback for backward compatibility with unknown types double bestValue = 0; for (var session in _sessions) { diff --git a/workout-logger/test/test_utils/mock_ml_service.dart b/workout-logger/test/test_utils/mock_ml_service.dart new file mode 100644 index 0000000..998bbfd --- /dev/null +++ b/workout-logger/test/test_utils/mock_ml_service.dart @@ -0,0 +1,133 @@ +// Mock ML Service for testing +// +// This mock implements the IMLService interface for testing. +// Following Dependency Inversion Principle: tests can inject this mock +// instead of the real MLService. + +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/ml_service_interface.dart'; + +/// Mock implementation of IMLService for testing. +/// +/// Following Liskov Substitution Principle: this mock can be used +/// wherever IMLService is expected without breaking the tests. +class MockMLService implements IMLService { + // Configurable return values for testing + GrowthModel? mockGrowthModel; + List? mockRecommendations; + DateTime? mockPrediction; + + // Track method calls for verification + int trainGrowthModelCallCount = 0; + int extractDataPointsCallCount = 0; + int recommendSetsCallCount = 0; + int predictTargetCompletionCallCount = 0; + + // Last parameters received + String? lastExtractedExerciseId; + List? lastRecommendedLastSession; + + @override + GrowthModel trainGrowthModel(List dataPoints) { + trainGrowthModelCallCount++; + return mockGrowthModel ?? + GrowthModel( + slope: 0.1, + intercept: 100, + r2: 0.8, + lastTrained: DateTime.now(), + ); + } + + @override + List extractExerciseDataPoints( + String exerciseId, + List sessions, + ) { + extractDataPointsCallCount++; + lastExtractedExerciseId = exerciseId; + + // Return realistic data points based on sessions + final dataPoints = []; + int sessionIndex = 0; + + for (var session in sessions) { + for (var log in session.exercises) { + if (log.exerciseId == exerciseId) { + dataPoints.add( + DataPoint(x: sessionIndex.toDouble(), y: log.totalVolume), + ); + sessionIndex++; + break; + } + } + } + + return dataPoints; + } + + @override + List recommendSets({ + required List lastSession, + GrowthModel? growthModel, + }) { + recommendSetsCallCount++; + lastRecommendedLastSession = lastSession; + + return mockRecommendations ?? + [ + SetRecommendation( + weight: 50, + reps: 10, + confidence: 'high', + reasoning: 'Mock recommendation', + ), + ]; + } + + @override + List getDefaultRecommendations(int setCount) { + return mockRecommendations ?? + List.generate( + setCount, + (index) => SetRecommendation( + weight: 0, + reps: 10, + confidence: 'low', + reasoning: 'Default mock recommendation', + ), + ); + } + + @override + DateTime? predictTargetCompletion({ + required double currentValue, + required double targetValue, + required GrowthModel growthModel, + }) { + predictTargetCompletionCallCount++; + + if (mockPrediction != null) { + return mockPrediction; + } + + // Default: predict 30 days from now if not completed + if (currentValue >= targetValue) { + return DateTime.now(); + } + return DateTime.now().add(const Duration(days: 30)); + } + + /// Reset all tracking state for fresh test runs + void reset() { + trainGrowthModelCallCount = 0; + extractDataPointsCallCount = 0; + recommendSetsCallCount = 0; + predictTargetCompletionCallCount = 0; + lastExtractedExerciseId = null; + lastRecommendedLastSession = null; + mockGrowthModel = null; + mockRecommendations = null; + mockPrediction = null; + } +} diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index ce5d81e..5657d35 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -1,28 +1,59 @@ // Shared Mock Storage Service for testing +// +// This mock implements the IStorageService interface for testing. +// Following Dependency Inversion Principle: tests can inject this mock +// instead of the real StorageService. + import 'package:repforge/models/models.dart'; -import 'package:repforge/services/storage_service.dart'; +import 'package:repforge/services/interfaces/storage_service_interface.dart'; -// Mock StorageService that works as a manual fake/stub -class MockStorageService implements StorageService { +/// Mock implementation of IStorageService for testing. +/// +/// Following Liskov Substitution Principle: this mock can be used +/// wherever IStorageService is expected without breaking the tests. +class MockStorageService implements IStorageService { final List _customExercises = []; + final List _sessions = []; + final List _routines = []; + final List _targets = []; + final List _muscleGroups = []; + final Map _settings = {}; + bool saveCustomExerciseCalled = false; Exercise? lastSavedExercise; - // Public getter to access the hidden list in tests + // Public getters for test assertions List get customExercises => _customExercises; + List get sessions => _sessions; + List get routines => _routines; + List get targets => _targets; + // Test helpers void addMockCustomExercise(Exercise exercise) { _customExercises.add(exercise); } + void addMockSession(WorkoutSession session) { + _sessions.add(session); + } + + void addMockRoutine(Routine routine) { + _routines.add(routine); + } + + void addMockTarget(Target target) { + _targets.add(target); + } + @override Future init() async {} @override - Future> getAllExercises() async => _customExercises; + Future> getAllExercises() async => List.from(_customExercises); @override - Future> getCustomExercises() async => _customExercises; + Future> getCustomExercises() async => + List.from(_customExercises); @override Future saveCustomExercise(Exercise exercise) async { @@ -37,63 +68,166 @@ class MockStorageService implements StorageService { } @override - Future> getAllWorkoutSessions() async => []; + Future> getAllWorkoutSessions() async => + List.from(_sessions); @override - Future> getAllRoutines() async => []; + Future saveWorkoutSession(WorkoutSession session) async { + final index = _sessions.indexWhere((s) => s.id == session.id); + if (index >= 0) { + _sessions[index] = session; + } else { + _sessions.add(session); + } + } @override - Future> getAllMuscleGroups() async => []; + Future getWorkoutSession(String id) async { + try { + return _sessions.firstWhere((s) => s.id == id); + } catch (_) { + return null; + } + } @override - Future> getAllTargets() async => []; + Future deleteWorkoutSession(String id) async { + _sessions.removeWhere((s) => s.id == id); + } @override - Future saveWorkoutSession(WorkoutSession session) async {} - @override - Future getWorkoutSession(String id) async => null; - @override - Future deleteWorkoutSession(String id) async {} - @override - Future> getSessionsForExercise( - String exerciseId, - ) async => []; + Future> getSessionsForExercise(String exerciseId) async { + return _sessions + .where( + (session) => session.exercises.any((e) => e.exerciseId == exerciseId), + ) + .toList(); + } + @override Future> getSessionsInDateRange( DateTime start, DateTime end, - ) async => []; + ) async { + return _sessions + .where( + (session) => + session.date.isAfter(start) && session.date.isBefore(end), + ) + .toList(); + } + @override - Future saveRoutine(Routine routine) async {} + Future> getAllRoutines() async => List.from(_routines); + @override - Future getRoutine(String id) async => null; + Future saveRoutine(Routine routine) async { + final index = _routines.indexWhere((r) => r.id == routine.id); + if (index >= 0) { + _routines[index] = routine; + } else { + _routines.add(routine); + } + } + @override - Future deleteRoutine(String id) async {} + Future getRoutine(String id) async { + try { + return _routines.firstWhere((r) => r.id == id); + } catch (_) { + return null; + } + } + @override - Future saveTarget(Target target) async {} + Future deleteRoutine(String id) async { + _routines.removeWhere((r) => r.id == id); + } + @override - Future getTarget(String id) async => null; + Future> getAllTargets() async => List.from(_targets); + + @override + Future saveTarget(Target target) async { + final index = _targets.indexWhere((t) => t.id == target.id); + if (index >= 0) { + _targets[index] = target; + } else { + _targets.add(target); + } + } + + @override + Future getTarget(String id) async { + try { + return _targets.firstWhere((t) => t.id == id); + } catch (_) { + return null; + } + } + + @override + Future deleteTarget(String id) async { + _targets.removeWhere((t) => t.id == id); + } + @override - Future deleteTarget(String id) async {} + Future> getTargetsForExercise(String exerciseId) async { + return _targets.where((t) => t.exerciseId == exerciseId).toList(); + } + @override - Future> getTargetsForExercise(String exerciseId) async => []; + Future> getAllMuscleGroups() async => + List.from(_muscleGroups); + @override Future updateMuscleGroupGrowthRate( String muscleGroupId, double rate, - ) async {} + ) async { + final index = _muscleGroups.indexWhere((m) => m.id == muscleGroupId); + if (index >= 0) { + _muscleGroups[index].growthRate = rate; + } + } + @override - Future getMuscleGroup(String id) async => null; + Future getMuscleGroup(String id) async { + try { + return _muscleGroups.firstWhere((m) => m.id == id); + } catch (_) { + return null; + } + } + @override - Future getExercise(String id) async => null; + Future getExercise(String id) async { + try { + return _customExercises.firstWhere((e) => e.id == id); + } catch (_) { + return null; + } + } + @override - Future saveSetting(String key, String value) async {} + Future saveSetting(String key, String value) async { + _settings[key] = value; + } + @override - Future getSetting(String key) async => null; + Future getSetting(String key) async => _settings[key]; + @override Future exportAllData() async => '{}'; + @override Future importData(String jsonData) async {} + @override - Future> getQuickStats() async => {}; + Future> getQuickStats() async => { + 'totalWorkouts': _sessions.length, + 'weeklyWorkouts': 0, + 'weeklyVolume': 0.0, + 'exercisesThisWeek': 0, + }; } From 0a46e84de177a526a214bccfe7441a6f603e6dd4 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:47:56 +0530 Subject: [PATCH 2/6] refactor: Simplify widget constructors and formatting in routines and workout flow screens --- .../lib/screens/routines_screen.dart | 311 ++++++++++++------ .../lib/screens/workout_flow_screen.dart | 222 +++++++------ 2 files changed, 337 insertions(+), 196 deletions(-) diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 03726f4..eb3ef7f 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -18,9 +18,7 @@ class RoutinesScreen extends StatelessWidget { final routines = provider.routines; return Scaffold( - appBar: AppBar( - title: const Text('Routines'), - ), + appBar: AppBar(title: const Text('Routines')), body: routines.isEmpty ? _buildEmptyState(context) : _buildRoutineList(context, routines, provider), @@ -37,11 +35,7 @@ class RoutinesScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.list_alt, - size: 64, - color: AppTheme.textMuted, - ), + Icon(Icons.list_alt, size: 64, color: AppTheme.textMuted), const SizedBox(height: 16), Text( 'No Routines Yet', @@ -73,10 +67,7 @@ class RoutinesScreen extends StatelessWidget { itemCount: routines.length, itemBuilder: (context, index) { final routine = routines[index]; - return _RoutineCard( - routine: routine, - provider: provider, - ); + return _RoutineCard(routine: routine, provider: provider); }, ); } @@ -84,9 +75,7 @@ class RoutinesScreen extends StatelessWidget { void _showCreateRoutineDialog(BuildContext context) { Navigator.push( context, - MaterialPageRoute( - builder: (_) => const CreateRoutineScreen(), - ), + MaterialPageRoute(builder: (_) => const CreateRoutineScreen()), ); } } @@ -95,10 +84,7 @@ class _RoutineCard extends StatelessWidget { final Routine routine; final WorkoutProvider provider; - const _RoutineCard({ - required this.routine, - required this.provider, - }); + const _RoutineCard({required this.routine, required this.provider}); @override Widget build(BuildContext context) { @@ -171,10 +157,7 @@ class _RoutineCard extends StatelessWidget { children: routine.exerciseIds.take(5).map((id) { final name = provider.getExerciseName(id); return Chip( - label: Text( - name, - style: const TextStyle(fontSize: 11), - ), + label: Text(name, style: const TextStyle(fontSize: 11)), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, ); @@ -201,9 +184,7 @@ class _RoutineCard extends StatelessWidget { void _showRoutineDetails(BuildContext context) { Navigator.push( context, - MaterialPageRoute( - builder: (_) => RoutineDetailScreen(routine: routine), - ), + MaterialPageRoute(builder: (_) => RoutineDetailScreen(routine: routine)), ); } @@ -234,7 +215,10 @@ class _RoutineCard extends StatelessWidget { ), ListTile( leading: const Icon(Icons.delete, color: AppTheme.error), - title: const Text('Delete Routine', style: TextStyle(color: AppTheme.error)), + title: const Text( + 'Delete Routine', + style: TextStyle(color: AppTheme.error), + ), onTap: () { Navigator.pop(context); _confirmDelete(context); @@ -285,6 +269,7 @@ class CreateRoutineScreen extends StatefulWidget { class _CreateRoutineScreenState extends State { final _nameController = TextEditingController(); final List _selectedExerciseIds = []; + String _pickerSearchQuery = ''; @override void initState() { @@ -303,16 +288,15 @@ class _CreateRoutineScreenState extends State { @override Widget build(BuildContext context) { - final exercises = ExerciseDatabase.getAll(); + // Use provider's exercises list (includes custom exercises) + final provider = context.watch(); + final exercises = provider.allExercises; return Scaffold( appBar: AppBar( title: Text(widget.routine == null ? 'New Routine' : 'Edit Routine'), actions: [ - TextButton( - onPressed: _saveRoutine, - child: const Text('Save'), - ), + TextButton(onPressed: _saveRoutine, child: const Text('Save')), ], ), body: Column( @@ -338,7 +322,8 @@ class _CreateRoutineScreenState extends State { const Spacer(), if (_selectedExerciseIds.isNotEmpty) TextButton( - onPressed: () => setState(() => _selectedExerciseIds.clear()), + onPressed: () => + setState(() => _selectedExerciseIds.clear()), child: const Text('Clear All'), ), ], @@ -349,11 +334,11 @@ class _CreateRoutineScreenState extends State { padding: const EdgeInsets.all(AppSpacing.md), itemCount: _selectedExerciseIds.length + 1, onReorder: (oldIndex, newIndex) { - if (oldIndex >= _selectedExerciseIds.length || + if (oldIndex >= _selectedExerciseIds.length || newIndex >= _selectedExerciseIds.length + 1) { return; } - + setState(() { if (newIndex > oldIndex) newIndex--; final item = _selectedExerciseIds.removeAt(oldIndex); @@ -368,13 +353,13 @@ class _CreateRoutineScreenState extends State { child: OutlinedButton.icon( onPressed: () => _showExercisePicker(exercises), icon: const Icon(Icons.add), - label: const Text('Add Exercise'), + label: const Text('Add Exercises'), ), ); } final exerciseId = _selectedExerciseIds[index]; - final exercise = ExerciseDatabase.getById(exerciseId); + final exercise = provider.getExercise(exerciseId); return Card( key: ValueKey(exerciseId), @@ -382,7 +367,10 @@ class _CreateRoutineScreenState extends State { child: ListTile( leading: ReorderableDragStartListener( index: index, - child: const Icon(Icons.drag_handle, color: AppTheme.textMuted), + child: const Icon( + Icons.drag_handle, + color: AppTheme.textMuted, + ), ), title: Text(exercise?.name ?? 'Unknown'), subtitle: Text( @@ -390,7 +378,10 @@ class _CreateRoutineScreenState extends State { style: const TextStyle(fontSize: 12), ), trailing: IconButton( - icon: const Icon(Icons.remove_circle_outline, color: AppTheme.error), + icon: const Icon( + Icons.remove_circle_outline, + color: AppTheme.error, + ), onPressed: () { setState(() => _selectedExerciseIds.removeAt(index)); }, @@ -406,6 +397,10 @@ class _CreateRoutineScreenState extends State { } void _showExercisePicker(List allExercises) { + // Reset search when opening picker + _pickerSearchQuery = ''; + final Set tempSelectedIds = {}; + showModalBottomSheet( context: context, backgroundColor: AppTheme.cardColor, @@ -413,68 +408,196 @@ class _CreateRoutineScreenState extends State { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (context) => DraggableScrollableSheet( - initialChildSize: 0.8, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { + builder: (context) => StatefulBuilder( + builder: (context, setModalState) { + // Filter exercises by search and exclude already selected + var filteredExercises = allExercises.where((ex) { + if (_selectedExerciseIds.contains(ex.id)) return false; + if (_pickerSearchQuery.isEmpty) return true; + return ex.name.toLowerCase().contains( + _pickerSearchQuery.toLowerCase(), + ); + }).toList(); + // Group by muscle final grouped = >{}; - for (var ex in allExercises) { - if (!_selectedExerciseIds.contains(ex.id)) { - final primary = ex.primaryMuscle; - grouped.putIfAbsent(primary, () => []).add(ex); - } + for (var ex in filteredExercises) { + final primary = ex.primaryMuscle; + grouped.putIfAbsent(primary, () => []).add(ex); } - return ListView( - controller: scrollController, - padding: const EdgeInsets.all(AppSpacing.lg), - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.textMuted, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: AppSpacing.lg), - Text( - 'Add Exercise', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.md), - ...grouped.entries.map((entry) { - final muscleName = MuscleGroups.names[entry.key] ?? entry.key; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - child: Text( - muscleName, - style: const TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, + return DraggableScrollableSheet( + initialChildSize: 0.8, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + // Fixed header with search and done button + Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textMuted, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: Text( + 'Add Exercises', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + if (tempSelectedIds.isNotEmpty) + TextButton.icon( + onPressed: () { + setState(() { + _selectedExerciseIds.addAll( + tempSelectedIds, + ); + }); + Navigator.pop(context); + }, + icon: const Icon(Icons.check), + label: Text('Add ${tempSelectedIds.length}'), + ), + ], ), + const SizedBox(height: AppSpacing.md), + // Search field + TextField( + decoration: InputDecoration( + hintText: 'Search exercises...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _pickerSearchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setModalState( + () => _pickerSearchQuery = '', + ); + }, + ) + : null, + isDense: true, + ), + onChanged: (val) { + setModalState(() => _pickerSearchQuery = val); + }, + ), + if (tempSelectedIds.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + '${tempSelectedIds.length} exercise${tempSelectedIds.length > 1 ? 's' : ''} selected', + style: const TextStyle( + color: AppTheme.primaryColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + // Scrollable exercise list + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, ), + children: [ + ...grouped.entries.map((entry) { + final muscleName = + MuscleGroups.names[entry.key] ?? entry.key; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm, + ), + child: Text( + muscleName, + style: const TextStyle( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ...entry.value.map((exercise) { + final isSelected = tempSelectedIds.contains( + exercise.id, + ); + return ListTile( + leading: Checkbox( + value: isSelected, + onChanged: (val) { + setModalState(() { + if (val == true) { + tempSelectedIds.add(exercise.id); + } else { + tempSelectedIds.remove(exercise.id); + } + }); + }, + ), + title: Text( + exercise.name, + style: TextStyle( + color: isSelected + ? AppTheme.primaryColor + : null, + ), + ), + subtitle: exercise.isCustom + ? const Text( + 'Custom', + style: TextStyle( + color: AppTheme.primaryColor, + fontSize: 12, + ), + ) + : null, + trailing: isSelected + ? const Icon( + Icons.check_circle, + color: AppTheme.primaryColor, + ) + : const Icon( + Icons.add_circle_outline, + color: AppTheme.textMuted, + ), + onTap: () { + setModalState(() { + if (isSelected) { + tempSelectedIds.remove(exercise.id); + } else { + tempSelectedIds.add(exercise.id); + } + }); + }, + ); + }), + ], + ); + }), + const SizedBox(height: AppSpacing.xl), + ], ), - ...entry.value.map((exercise) => ListTile( - title: Text(exercise.name), - trailing: const Icon(Icons.add_circle_outline, color: AppTheme.primaryColor), - onTap: () { - setState(() => _selectedExerciseIds.add(exercise.id)); - Navigator.pop(context); - }, - )), - ], - ); - }), - ], + ), + ], + ); + }, ); }, ), diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index d95668a..6f714d7 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -14,11 +14,7 @@ class WorkoutFlowScreen extends StatefulWidget { final Routine? routine; final bool isQuickStart; - const WorkoutFlowScreen({ - super.key, - this.routine, - this.isQuickStart = false, - }); + const WorkoutFlowScreen({super.key, this.routine, this.isQuickStart = false}); @override State createState() => _WorkoutFlowScreenState(); @@ -36,7 +32,7 @@ class _WorkoutFlowScreenState extends State { int _currentReps = 10; bool _isDropset = false; final List _drops = []; - + // TextEditingControllers for dropset fields (following Flutter best practices) final TextEditingController _mainWeightController = TextEditingController(); final TextEditingController _mainRepsController = TextEditingController(); @@ -55,7 +51,7 @@ class _WorkoutFlowScreenState extends State { void _initializeWorkout() { final provider = context.read(); - + if (widget.routine != null) { provider.startWorkout(routine: widget.routine); _loadLastSessionData(); @@ -103,9 +99,7 @@ class _WorkoutFlowScreenState extends State { final provider = context.watch(); if (!provider.hasActiveWorkout) { - return const Scaffold( - body: Center(child: Text('No active workout')), - ); + return const Scaffold(body: Center(child: Text('No active workout'))); } // If no exercises yet (quick start), show exercise selector @@ -130,33 +124,35 @@ class _WorkoutFlowScreenState extends State { onPressed: _showCancelDialog, ), ), - body: const ExerciseSelectorScreen(selectionMode: true), - floatingActionButton: FloatingActionButton.extended( - onPressed: _startWithSelectedExercises, - label: const Text('Start Workout'), - icon: const Icon(Icons.play_arrow), + body: ExerciseSelectorScreen( + selectionMode: true, + onExercisesSelected: _startWithSelectedExercises, ), ); } - void _startWithSelectedExercises() { - // This will be handled by the exercise selector - Navigator.pop(context); + void _startWithSelectedExercises(List exerciseIds) { + if (exerciseIds.isEmpty) return; + + final provider = context.read(); + // Restart workout with selected exercises + provider.cancelWorkout(); + provider.startWorkout(exerciseIds: exerciseIds); } Widget _buildWorkoutView() { final provider = context.watch(); final currentExercise = provider.currentExercise; final currentLog = provider.currentExerciseLog; - final recommendations = currentExercise != null - ? provider.getRecommendations(currentExercise.id) + final recommendations = currentExercise != null + ? provider.getRecommendations(currentExercise.id) : []; return Column( children: [ // Header _buildHeader(provider, currentExercise), - + // Main content Expanded( child: SingleChildScrollView( @@ -166,31 +162,34 @@ class _WorkoutFlowScreenState extends State { children: [ // Recommendation card if (recommendations.isNotEmpty && currentLog != null) - _buildRecommendationCard(recommendations, currentLog.sets.length), - + _buildRecommendationCard( + recommendations, + currentLog.sets.length, + ), + const SizedBox(height: AppSpacing.lg), - + // Weight and reps input if (!_isDropset) _buildInputSection(), - + if (!_isDropset) const SizedBox(height: AppSpacing.md), - + // Dropset toggle _buildDropsetSection(), - + const SizedBox(height: AppSpacing.lg), - + // Set done button _buildSetDoneButton(), - + const SizedBox(height: AppSpacing.lg), - + // Previous sets if (currentLog != null && currentLog.sets.isNotEmpty) _buildPreviousSets(currentLog.sets), - + const SizedBox(height: AppSpacing.lg), - + // Last session info if (currentExercise != null) _buildLastSessionInfo(currentExercise.id), @@ -198,7 +197,7 @@ class _WorkoutFlowScreenState extends State { ), ), ), - + // Bottom actions _buildBottomActions(provider), ], @@ -273,12 +272,15 @@ class _WorkoutFlowScreenState extends State { ); } - Widget _buildRecommendationCard(List recommendations, int currentSetIndex) { + Widget _buildRecommendationCard( + List recommendations, + int currentSetIndex, + ) { if (currentSetIndex >= recommendations.length) return const SizedBox(); - + final rec = recommendations[currentSetIndex]; - final confidenceColor = rec.confidence == 'high' - ? AppTheme.success + final confidenceColor = rec.confidence == 'high' + ? AppTheme.success : (rec.confidence == 'medium' ? AppTheme.warning : AppTheme.textMuted); return Container( @@ -293,9 +295,7 @@ class _WorkoutFlowScreenState extends State { end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all( - color: AppTheme.primaryColor.withOpacity(0.3), - ), + border: Border.all(color: AppTheme.primaryColor.withOpacity(0.3)), ), child: Row( children: [ @@ -317,10 +317,7 @@ class _WorkoutFlowScreenState extends State { children: [ const Text( 'Suggested', - style: TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), + style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), ), Text( '${rec.weight}kg × ${rec.reps} reps', @@ -393,10 +390,7 @@ class _WorkoutFlowScreenState extends State { children: [ Text( label, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 14, - ), + style: const TextStyle(color: AppTheme.textSecondary, fontSize: 14), ), const SizedBox(height: AppSpacing.sm), Row( @@ -413,7 +407,9 @@ class _WorkoutFlowScreenState extends State { child: GestureDetector( onTap: () => _showNumberPicker(value, decimals, onChanged), child: Text( - decimals == 0 ? value.toInt().toString() : value.toStringAsFixed(decimals), + decimals == 0 + ? value.toInt().toString() + : value.toStringAsFixed(decimals), style: const TextStyle( fontSize: 32, fontWeight: FontWeight.bold, @@ -511,7 +507,9 @@ class _WorkoutFlowScreenState extends State { if (_isDropset) ...[ const SizedBox(height: AppSpacing.md), _buildMainSetEntry(), - ..._drops.asMap().entries.map((entry) => _buildDropEntry(entry.key)), + ..._drops.asMap().entries.map( + (entry) => _buildDropEntry(entry.key), + ), TextButton.icon( onPressed: _addDrop, icon: const Icon(Icons.add), @@ -530,10 +528,7 @@ class _WorkoutFlowScreenState extends State { padding: const EdgeInsets.only(bottom: AppSpacing.sm), child: Row( children: [ - const Text( - 'Start:', - style: TextStyle(color: AppTheme.textSecondary), - ), + const Text('Start:', style: TextStyle(color: AppTheme.textSecondary)), const SizedBox(width: 8), Expanded( child: Row( @@ -544,7 +539,10 @@ class _WorkoutFlowScreenState extends State { controller: _mainWeightController, decoration: const InputDecoration( hintText: 'kg', - contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), ), keyboardType: TextInputType.number, inputFormatters: [ @@ -559,19 +557,23 @@ class _WorkoutFlowScreenState extends State { }, ), ), - const Text(' × ', style: TextStyle(color: AppTheme.textSecondary)), + const Text( + ' × ', + style: TextStyle(color: AppTheme.textSecondary), + ), SizedBox( width: 50, child: TextFormField( controller: _mainRepsController, decoration: const InputDecoration( hintText: 'reps', - contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), ), keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], + inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (val) { final parsed = int.tryParse(val); if (parsed != null) { @@ -594,7 +596,7 @@ class _WorkoutFlowScreenState extends State { // Controllers are created and initialized in _addDrop() // Build method only READS from controllers, never creates or modifies them // This prevents cursor jumps and duplicate controller creation - + return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.sm), child: Row( @@ -613,7 +615,10 @@ class _WorkoutFlowScreenState extends State { controller: _dropWeightControllers[index], decoration: const InputDecoration( hintText: 'kg', - contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), ), keyboardType: TextInputType.number, inputFormatters: [ @@ -631,19 +636,23 @@ class _WorkoutFlowScreenState extends State { }, ), ), - const Text(' × ', style: TextStyle(color: AppTheme.textSecondary)), + const Text( + ' × ', + style: TextStyle(color: AppTheme.textSecondary), + ), SizedBox( width: 50, child: TextFormField( controller: _dropRepsControllers[index], decoration: const InputDecoration( hintText: 'reps', - contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), ), keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], + inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (val) { final parsed = int.tryParse(val); if (parsed != null) { @@ -683,15 +692,16 @@ class _WorkoutFlowScreenState extends State { setState(() { final lastWeight = _drops.isEmpty ? _currentWeight : _drops.last.weight; final newWeight = (lastWeight * 0.8).roundToDouble(); - - _drops.add(DropsetEntry( - weight: newWeight, - reps: _currentReps, - )); - + + _drops.add(DropsetEntry(weight: newWeight, reps: _currentReps)); + // Create controllers for the new drop (Flutter best practice) - _dropWeightControllers.add(TextEditingController(text: newWeight.toString())); - _dropRepsControllers.add(TextEditingController(text: _currentReps.toString())); + _dropWeightControllers.add( + TextEditingController(text: newWeight.toString()), + ); + _dropRepsControllers.add( + TextEditingController(text: _currentReps.toString()), + ); }); } @@ -714,10 +724,7 @@ class _WorkoutFlowScreenState extends State { SizedBox(width: 12), Text( 'SET DONE', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ], ), @@ -742,7 +749,10 @@ class _WorkoutFlowScreenState extends State { final set = entry.value; return Container( margin: const EdgeInsets.only(bottom: AppSpacing.sm), - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), decoration: BoxDecoration( color: AppTheme.cardColor, borderRadius: BorderRadius.circular(AppRadius.sm), @@ -765,9 +775,7 @@ class _WorkoutFlowScreenState extends State { const SizedBox(width: 12), Text( 'Set ${index + 1}', - style: const TextStyle( - color: AppTheme.textSecondary, - ), + style: const TextStyle(color: AppTheme.textSecondary), ), const Spacer(), Text( @@ -780,7 +788,10 @@ class _WorkoutFlowScreenState extends State { if (set.isDropset) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: AppTheme.warning.withOpacity(0.2), borderRadius: BorderRadius.circular(4), @@ -806,7 +817,7 @@ class _WorkoutFlowScreenState extends State { Widget _buildLastSessionInfo(String exerciseId) { final provider = context.read(); final lastSession = provider.getLastSessionForExercise(exerciseId); - + if (lastSession == null) { return Container( padding: const EdgeInsets.all(AppSpacing.md), @@ -867,7 +878,9 @@ class _WorkoutFlowScreenState extends State { Widget _buildBottomActions(WorkoutProvider provider) { final isFirst = provider.currentExerciseIndex == 0; - final isLast = provider.currentExerciseIndex >= provider.currentExerciseLogs.length - 1; + final isLast = + provider.currentExerciseIndex >= + provider.currentExerciseLogs.length - 1; return Container( padding: const EdgeInsets.all(AppSpacing.md), @@ -899,10 +912,12 @@ class _WorkoutFlowScreenState extends State { const SizedBox(width: AppSpacing.md), Expanded( child: ElevatedButton.icon( - onPressed: isLast ? _finishWorkout : () { - provider.nextExercise(); - _loadLastSessionData(); - }, + onPressed: isLast + ? _finishWorkout + : () { + provider.nextExercise(); + _loadLastSessionData(); + }, icon: Icon(isLast ? Icons.check : Icons.arrow_forward), label: Text(isLast ? 'Finish' : 'Next'), ), @@ -969,10 +984,7 @@ class _WorkoutFlowScreenState extends State { ), child: const Text( 'SKIP', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ), ), @@ -1015,7 +1027,7 @@ class _WorkoutFlowScreenState extends State { void _completeSet() { final provider = context.read(); - + final set = WorkoutSet( weight: _currentWeight, reps: _currentReps, @@ -1077,7 +1089,11 @@ class _WorkoutFlowScreenState extends State { HapticFeedback.selectionClick(); } - void _showNumberPicker(double currentValue, int decimals, Function(double) onChanged) { + void _showNumberPicker( + double currentValue, + int decimals, + Function(double) onChanged, + ) { showModalBottomSheet( context: context, backgroundColor: AppTheme.cardColor, @@ -1091,15 +1107,17 @@ class _WorkoutFlowScreenState extends State { children: [ TextField( autofocus: true, - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), ], - decoration: const InputDecoration( - labelText: 'Enter value', - ), + decoration: const InputDecoration(labelText: 'Enter value'), controller: TextEditingController( - text: decimals == 0 ? currentValue.toInt().toString() : currentValue.toString(), + text: decimals == 0 + ? currentValue.toInt().toString() + : currentValue.toString(), ), onSubmitted: (val) { final parsed = double.tryParse(val); @@ -1173,7 +1191,7 @@ class _WorkoutFlowScreenState extends State { children: [30, 60, 90, 120, 150, 180].map((seconds) { return ListTile( title: Text('$seconds seconds'), - trailing: _restSeconds == seconds + trailing: _restSeconds == seconds ? const Icon(Icons.check, color: AppTheme.primaryColor) : null, onTap: () { From 772b0a2211e6573431cdb3a66d08172a61a6a65c Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 1 Feb 2026 12:27:08 +0530 Subject: [PATCH 3/6] refactor: Improve code structure and readability across multiple files --- SOLID_ANALYSIS_REPORT.md | 2 +- workout-logger/lib/main.dart | 17 +-- .../lib/screens/routines_screen.dart | 15 ++- .../lib/screens/workout_flow_screen.dart | 107 ++++++++++++------ .../interfaces/ml_service_interface.dart | 12 ++ .../managers/active_workout_manager.dart | 13 ++- .../services/managers/analytics_manager.dart | 15 ++- .../services/managers/exercise_manager.dart | 18 +-- .../services/managers/history_manager.dart | 35 +++--- .../services/managers/routine_manager.dart | 11 +- .../lib/services/managers/target_manager.dart | 10 ++ .../lib/services/storage_service.dart | 2 +- .../strategies/target_calculator.dart | 11 ++ .../test/test_utils/mock_storage_service.dart | 7 +- 14 files changed, 187 insertions(+), 88 deletions(-) diff --git a/SOLID_ANALYSIS_REPORT.md b/SOLID_ANALYSIS_REPORT.md index a9d2683..daee914 100644 --- a/SOLID_ANALYSIS_REPORT.md +++ b/SOLID_ANALYSIS_REPORT.md @@ -1,6 +1,6 @@ # SOLID Principles Analysis Report -This report provides a detailed analysis of the Flutter codebase ("Workout Logger") against the SOLID principles. +This report provides a detailed analysis of the Flutter codebase ("Workout Logger") against the SOLID principles. ## ✅ SOLID Refactoring Complete diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 02c10b3..0df1048 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -38,25 +38,28 @@ void main() async { } class WorkoutLoggerApp extends StatelessWidget { + // Singleton instances created once at app startup + // This ensures the same instances are used throughout the app lifecycle + static final IStorageService _storageService = StorageService(); + static final IMLService _mlService = MLService(); + const WorkoutLoggerApp({super.key}); @override Widget build(BuildContext context) { - // Composition Root: Create concrete implementations and inject them + // Composition Root: Provide the singleton implementations // This is the only place where we reference concrete implementations. // All other code depends on abstractions (interfaces). - final IStorageService storageService = StorageService(); - final IMLService mlService = MLService(); - return MultiProvider( providers: [ // Provide the storage service interface for direct access if needed - Provider.value(value: storageService), + Provider.value(value: _storageService), // Provide the ML service interface for direct access if needed - Provider.value(value: mlService), + Provider.value(value: _mlService), // WorkoutProvider receives dependencies via constructor injection ChangeNotifierProvider( - create: (_) => WorkoutProvider(storageService, mlService: mlService), + create: (_) => + WorkoutProvider(_storageService, mlService: _mlService), ), ], child: MaterialApp( diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index eb3ef7f..598a84f 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -269,7 +269,6 @@ class CreateRoutineScreen extends StatefulWidget { class _CreateRoutineScreenState extends State { final _nameController = TextEditingController(); final List _selectedExerciseIds = []; - String _pickerSearchQuery = ''; @override void initState() { @@ -397,8 +396,8 @@ class _CreateRoutineScreenState extends State { } void _showExercisePicker(List allExercises) { - // Reset search when opening picker - _pickerSearchQuery = ''; + // Local state for picker search - scoped to this modal only + String pickerSearchQuery = ''; final Set tempSelectedIds = {}; showModalBottomSheet( @@ -413,9 +412,9 @@ class _CreateRoutineScreenState extends State { // Filter exercises by search and exclude already selected var filteredExercises = allExercises.where((ex) { if (_selectedExerciseIds.contains(ex.id)) return false; - if (_pickerSearchQuery.isEmpty) return true; + if (pickerSearchQuery.isEmpty) return true; return ex.name.toLowerCase().contains( - _pickerSearchQuery.toLowerCase(), + pickerSearchQuery.toLowerCase(), ); }).toList(); @@ -479,12 +478,12 @@ class _CreateRoutineScreenState extends State { decoration: InputDecoration( hintText: 'Search exercises...', prefixIcon: const Icon(Icons.search), - suffixIcon: _pickerSearchQuery.isNotEmpty + suffixIcon: pickerSearchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { setModalState( - () => _pickerSearchQuery = '', + () => pickerSearchQuery = '', ); }, ) @@ -492,7 +491,7 @@ class _CreateRoutineScreenState extends State { isDense: true, ), onChanged: (val) { - setModalState(() => _pickerSearchQuery = val); + setModalState(() => pickerSearchQuery = val); }, ), if (tempSelectedIds.isNotEmpty) ...[ diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 6f714d7..a2bcc06 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -1100,40 +1100,10 @@ class _WorkoutFlowScreenState extends State { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (context) => Container( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - autofocus: true, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), - ], - decoration: const InputDecoration(labelText: 'Enter value'), - controller: TextEditingController( - text: decimals == 0 - ? currentValue.toInt().toString() - : currentValue.toString(), - ), - onSubmitted: (val) { - final parsed = double.tryParse(val); - if (parsed != null) { - onChanged(parsed); - } - Navigator.pop(context); - }, - ), - const SizedBox(height: AppSpacing.md), - ElevatedButton( - onPressed: () => Navigator.pop(context), - child: const Text('Done'), - ), - ], - ), + builder: (context) => _NumberPickerContent( + initialValue: currentValue, + decimals: decimals, + onChanged: onChanged, ), ); } @@ -1264,3 +1234,72 @@ class _WorkoutFlowScreenState extends State { ); } } + +/// A StatefulWidget for the number picker content that properly manages +/// its TextEditingController lifecycle to avoid memory leaks. +class _NumberPickerContent extends StatefulWidget { + final double initialValue; + final int decimals; + final Function(double) onChanged; + + const _NumberPickerContent({ + required this.initialValue, + required this.decimals, + required this.onChanged, + }); + + @override + State<_NumberPickerContent> createState() => _NumberPickerContentState(); +} + +class _NumberPickerContentState extends State<_NumberPickerContent> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController( + text: widget.decimals == 0 + ? widget.initialValue.toInt().toString() + : widget.initialValue.toString(), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final parsed = double.tryParse(_controller.text); + if (parsed != null) { + widget.onChanged(parsed); + } + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _controller, + autofocus: true, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), + ], + decoration: const InputDecoration(labelText: 'Enter value'), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: AppSpacing.md), + ElevatedButton(onPressed: _submit, child: const Text('Done')), + ], + ), + ); + } +} diff --git a/workout-logger/lib/services/interfaces/ml_service_interface.dart b/workout-logger/lib/services/interfaces/ml_service_interface.dart index 2a237bf..676850f 100644 --- a/workout-logger/lib/services/interfaces/ml_service_interface.dart +++ b/workout-logger/lib/services/interfaces/ml_service_interface.dart @@ -14,6 +14,18 @@ class DataPoint { final double y; // Volume or performance metric DataPoint({required this.x, required this.y}); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is DataPoint && + other.runtimeType == runtimeType && + other.x == x && + other.y == y; + } + + @override + int get hashCode => Object.hash(x, y); } /// Abstract interface for ML operations diff --git a/workout-logger/lib/services/managers/active_workout_manager.dart b/workout-logger/lib/services/managers/active_workout_manager.dart index 99b0abe..4cd251e 100644 --- a/workout-logger/lib/services/managers/active_workout_manager.dart +++ b/workout-logger/lib/services/managers/active_workout_manager.dart @@ -160,6 +160,9 @@ class ActiveWorkoutManager extends ChangeNotifier { } /// Finish workout and save + /// + /// Note: If saving fails, the active workout state remains intact and the + /// exception is rethrown. Callers must handle errors appropriately. Future finishWorkout({String? notes}) async { if (!hasActiveWorkout) { throw StateError('No active workout to finish.'); @@ -183,9 +186,15 @@ class ActiveWorkoutManager extends ChangeNotifier { notes: notes, ); - await _storage.saveWorkoutSession(session); + try { + await _storage.saveWorkoutSession(session); + } catch (e) { + // Log the error and rethrow - active workout state remains intact + debugPrint('Failed to save workout session: $e'); + rethrow; + } - // Notify callback if provided + // Only proceed if save was successful onWorkoutSaved?.call(session); // Clear active workout state diff --git a/workout-logger/lib/services/managers/analytics_manager.dart b/workout-logger/lib/services/managers/analytics_manager.dart index 0c46ab8..52fcf06 100644 --- a/workout-logger/lib/services/managers/analytics_manager.dart +++ b/workout-logger/lib/services/managers/analytics_manager.dart @@ -86,13 +86,19 @@ class AnalyticsManager extends ChangeNotifier { } /// Get set recommendations for an exercise + /// + /// Sessions are sorted newest-first to find the most recent exercise log. List getRecommendations( String exerciseId, List sessions, ) { + // Sort sessions newest-first to find the most recent exercise log + final sortedSessions = List.from(sessions) + ..sort((a, b) => b.date.compareTo(a.date)); + // Find last session with this exercise ExerciseLog? lastLog; - for (var session in sessions) { + for (var session in sortedSessions) { for (var log in session.exercises) { if (log.exerciseId == exerciseId) { lastLog = log; @@ -163,11 +169,8 @@ class AnalyticsManager extends ChangeNotifier { } Exercise? _findExercise(String id, List exercises) { - try { - return exercises.firstWhere((e) => e.id == id); - } catch (_) { - return null; - } + final index = exercises.indexWhere((e) => e.id == id); + return index != -1 ? exercises[index] : null; } /// Get quick stats for dashboard diff --git a/workout-logger/lib/services/managers/exercise_manager.dart b/workout-logger/lib/services/managers/exercise_manager.dart index 3aaaae9..300fb8f 100644 --- a/workout-logger/lib/services/managers/exercise_manager.dart +++ b/workout-logger/lib/services/managers/exercise_manager.dart @@ -43,13 +43,12 @@ class ExerciseManager extends ChangeNotifier { } /// Get an exercise by ID + /// + /// Returns null if not found. Caller should ensure loadExercises() has been + /// called first to populate the in-memory exercise list. Exercise? getExercise(String id) { - try { - return _allExercises.firstWhere((e) => e.id == id); - } catch (_) { - // Fallback to built-in database - return ExerciseDatabase.getById(id); - } + final index = _allExercises.indexWhere((e) => e.id == id); + return index != -1 ? _allExercises[index] : null; } /// Get exercise name by ID @@ -152,8 +151,13 @@ class ExerciseManager extends ChangeNotifier { } /// Get exercises by category + /// + /// Performs case-insensitive comparison to match both built-in and custom exercises. List getExercisesByCategory(String category) { - return _allExercises.where((e) => e.category == category).toList(); + final normalizedCategory = category.toLowerCase(); + return _allExercises + .where((e) => e.category.toLowerCase() == normalizedCategory) + .toList(); } /// Search exercises by name diff --git a/workout-logger/lib/services/managers/history_manager.dart b/workout-logger/lib/services/managers/history_manager.dart index 4d3bc39..6145fba 100644 --- a/workout-logger/lib/services/managers/history_manager.dart +++ b/workout-logger/lib/services/managers/history_manager.dart @@ -33,24 +33,26 @@ class HistoryManager extends ChangeNotifier { /// Load all sessions from storage Future loadSessions() async { _sessions = await _storage.getAllWorkoutSessions(); + // Sort newest-first to ensure consistent ordering for queries + _sessions.sort((a, b) => b.date.compareTo(a.date)); notifyListeners(); } /// Add a new session to history - void addSession(WorkoutSession session) { + /// + /// Persists the session to storage and updates in-memory state. + Future addSession(WorkoutSession session) async { _sessions.insert(0, session); final exerciseIds = session.exercises.map((e) => e.exerciseId).toSet(); onSessionsChanged?.call(exerciseIds); + await _storage.saveWorkoutSession(session); notifyListeners(); } /// Get a session by ID WorkoutSession? getSession(String sessionId) { - try { - return _sessions.firstWhere((s) => s.id == sessionId); - } catch (_) { - return null; - } + final index = _sessions.indexWhere((s) => s.id == sessionId); + return index != -1 ? _sessions[index] : null; } /// Get sessions for a specific exercise @@ -62,12 +64,12 @@ class HistoryManager extends ChangeNotifier { .toList(); } - /// Get sessions within a date range + /// Get sessions within a date range (inclusive of start and end) List getSessionsInDateRange(DateTime start, DateTime end) { return _sessions .where( (session) => - session.date.isAfter(start) && session.date.isBefore(end), + !session.date.isBefore(start) && !session.date.isAfter(end), ) .toList(); } @@ -100,11 +102,15 @@ class HistoryManager extends ChangeNotifier { } /// Update an existing workout session + /// + /// Throws [StateError] if session is not found. Future updateSession(WorkoutSession updatedSession) async { - final previousSession = _sessions.firstWhere( - (s) => s.id == updatedSession.id, - orElse: () => updatedSession, - ); + final index = _sessions.indexWhere((s) => s.id == updatedSession.id); + if (index == -1) { + throw StateError('Session ${updatedSession.id} not found'); + } + + final previousSession = _sessions[index]; // Gather affected exercise IDs from both old and new versions final previousExerciseIds = previousSession.exercises @@ -119,10 +125,7 @@ class HistoryManager extends ChangeNotifier { await _storage.saveWorkoutSession(updatedSession); - final index = _sessions.indexWhere((s) => s.id == updatedSession.id); - if (index != -1) { - _sessions = List.from(_sessions)..[index] = updatedSession; - } + _sessions = List.from(_sessions)..[index] = updatedSession; // Keep sorted by date _sessions.sort((a, b) => b.date.compareTo(a.date)); diff --git a/workout-logger/lib/services/managers/routine_manager.dart b/workout-logger/lib/services/managers/routine_manager.dart index 7391835..bf16a65 100644 --- a/workout-logger/lib/services/managers/routine_manager.dart +++ b/workout-logger/lib/services/managers/routine_manager.dart @@ -36,11 +36,8 @@ class RoutineManager extends ChangeNotifier { /// Get a routine by ID Routine? getRoutine(String id) { - try { - return _routines.firstWhere((r) => r.id == id); - } catch (_) { - return null; - } + final index = _routines.indexWhere((r) => r.id == id); + return index != -1 ? _routines[index] : null; } /// Create a new routine @@ -57,11 +54,15 @@ class RoutineManager extends ChangeNotifier { } /// Update an existing routine + /// + /// If the routine is not found in memory, it will be added. Future updateRoutine(Routine routine) async { await _storage.saveRoutine(routine); final index = _routines.indexWhere((r) => r.id == routine.id); if (index != -1) { _routines[index] = routine; + } else { + _routines.add(routine); } notifyListeners(); } diff --git a/workout-logger/lib/services/managers/target_manager.dart b/workout-logger/lib/services/managers/target_manager.dart index eb1a0d0..b53426c 100644 --- a/workout-logger/lib/services/managers/target_manager.dart +++ b/workout-logger/lib/services/managers/target_manager.dart @@ -64,12 +64,22 @@ class TargetManager extends ChangeNotifier { GrowthModel? getGrowthModel(String exerciseId) => _growthModels[exerciseId]; /// Create a new target + /// + /// Throws [ArgumentError] if [type] is not a supported target type. Future createTarget({ required String exerciseId, required String type, required double targetValue, required List sessions, }) async { + // Validate target type upfront before any calculations + if (!TargetCalculatorFactory.supportedTypes.contains(type.toLowerCase())) { + throw ArgumentError( + 'Unsupported target type: "$type". ' + 'Allowed values: ${TargetCalculatorFactory.supportedTypes.join(", ")}', + ); + } + // Calculate current value using strategy pattern final currentValue = TargetCalculatorFactory.calculateCurrentValue( exerciseId, diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index aa9c8fb..dbf1ede 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -104,7 +104,7 @@ class StorageService implements IStorageService { return allSessions .where( (session) => - session.date.isAfter(start) && session.date.isBefore(end), + !session.date.isBefore(start) && !session.date.isAfter(end), ) .toList(); } diff --git a/workout-logger/lib/services/strategies/target_calculator.dart b/workout-logger/lib/services/strategies/target_calculator.dart index f483501..1ca7823 100644 --- a/workout-logger/lib/services/strategies/target_calculator.dart +++ b/workout-logger/lib/services/strategies/target_calculator.dart @@ -93,6 +93,17 @@ class TargetCalculatorFactory { 'volume': VolumeTargetCalculator(), }; + /// Reset the strategies registry to defaults. + /// + /// This is primarily used in tests to restore isolation after + /// registering custom calculators. + static void reset() { + _strategies.clear(); + _strategies['reps'] = RepsTargetCalculator(); + _strategies['weight'] = WeightTargetCalculator(); + _strategies['volume'] = VolumeTargetCalculator(); + } + /// Get a calculator for the specified target type /// /// Returns null if the target type is not supported. diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index 5657d35..af661f0 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -4,6 +4,7 @@ // Following Dependency Inversion Principle: tests can inject this mock // instead of the real StorageService. +import 'package:repforge/data/exercise_database.dart'; import 'package:repforge/models/models.dart'; import 'package:repforge/services/interfaces/storage_service_interface.dart'; @@ -49,7 +50,11 @@ class MockStorageService implements IStorageService { Future init() async {} @override - Future> getAllExercises() async => List.from(_customExercises); + Future> getAllExercises() async { + // Merge built-in exercises with custom exercises to mirror production behavior + final builtInExercises = ExerciseDatabase.getAll(); + return [...builtInExercises, ..._customExercises]; + } @override Future> getCustomExercises() async => From d8a8255cb88bdd654d00d1d2bb64344658aa9635 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 1 Feb 2026 12:46:54 +0530 Subject: [PATCH 4/6] refactor: Update data structures for exercise selection and improve mock storage service behavior --- .../lib/screens/routines_screen.dart | 3 +- .../managers/active_workout_manager.dart | 15 ++++- .../services/managers/analytics_manager.dart | 10 ++-- .../strategies/target_calculator.dart | 58 ++++++++++--------- .../test/test_utils/mock_storage_service.dart | 13 +++-- 5 files changed, 61 insertions(+), 38 deletions(-) diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 598a84f..0542106 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -398,7 +398,8 @@ class _CreateRoutineScreenState extends State { void _showExercisePicker(List allExercises) { // Local state for picker search - scoped to this modal only String pickerSearchQuery = ''; - final Set tempSelectedIds = {}; + // Use List instead of Set to preserve selection order + final List tempSelectedIds = []; showModalBottomSheet( context: context, diff --git a/workout-logger/lib/services/managers/active_workout_manager.dart b/workout-logger/lib/services/managers/active_workout_manager.dart index 4cd251e..0b314b6 100644 --- a/workout-logger/lib/services/managers/active_workout_manager.dart +++ b/workout-logger/lib/services/managers/active_workout_manager.dart @@ -58,6 +58,9 @@ class ActiveWorkoutManager extends ChangeNotifier { String? get currentExerciseId => currentExerciseLog?.exerciseId; /// Start a new workout with a routine or list of exercises + /// + /// Throws [StateError] if a workout is already in progress or if no + /// exercises are provided. void startWorkout({Routine? routine, List? exerciseIds}) { if (hasActiveWorkout) { throw StateError( @@ -65,13 +68,23 @@ class ActiveWorkoutManager extends ChangeNotifier { ); } + // Resolve exercise IDs from routine or provided list + final ids = routine?.exerciseIds ?? exerciseIds ?? []; + + // Validate that at least one exercise is provided + if (ids.isEmpty) { + throw StateError( + 'Cannot start a workout with zero exercises. ' + 'Provide a routine with exercises or a non-empty exerciseIds list.', + ); + } + _workoutStartTime = DateTime.now(); _activeRoutine = routine; _currentExerciseIndex = 0; _currentExerciseLogs = []; // Initialize exercise logs based on routine or provided exercise IDs - final ids = routine?.exerciseIds ?? exerciseIds ?? []; for (var id in ids) { _currentExerciseLogs.add(ExerciseLog(exerciseId: id, sets: [])); } diff --git a/workout-logger/lib/services/managers/analytics_manager.dart b/workout-logger/lib/services/managers/analytics_manager.dart index 52fcf06..e60b98f 100644 --- a/workout-logger/lib/services/managers/analytics_manager.dart +++ b/workout-logger/lib/services/managers/analytics_manager.dart @@ -38,6 +38,8 @@ class AnalyticsManager extends ChangeNotifier { GrowthModel? getGrowthModel(String exerciseId) => _growthModels[exerciseId]; /// Train all growth models from session history + /// + /// Runs all model updates concurrently for better performance. Future trainAllGrowthModels(List sessions) async { final exerciseIds = {}; @@ -48,10 +50,10 @@ class AnalyticsManager extends ChangeNotifier { } } - // Train model for each exercise - for (var exerciseId in exerciseIds) { - await updateGrowthModel(exerciseId, sessions); - } + // Train models concurrently for better performance + await Future.wait( + exerciseIds.map((exerciseId) => updateGrowthModel(exerciseId, sessions)), + ); } /// Update growth model for a specific exercise diff --git a/workout-logger/lib/services/strategies/target_calculator.dart b/workout-logger/lib/services/strategies/target_calculator.dart index 1ca7823..b92fa71 100644 --- a/workout-logger/lib/services/strategies/target_calculator.dart +++ b/workout-logger/lib/services/strategies/target_calculator.dart @@ -16,22 +16,34 @@ abstract class TargetCalculatorStrategy { double calculate(String exerciseId, List sessions); } +/// Helper function to get all exercise logs for a specific exercise across sessions. +/// +/// Returns an iterable of ExerciseLog entries that have non-empty sets. +Iterable _getExerciseLogsForExercise( + String exerciseId, + List sessions, +) sync* { + for (var session in sessions) { + for (var log in session.exercises) { + if (log.exerciseId == exerciseId && log.sets.isNotEmpty) { + yield log; + } + } + } +} + /// Calculator for maximum reps achieved class RepsTargetCalculator implements TargetCalculatorStrategy { @override double calculate(String exerciseId, List sessions) { double bestValue = 0; - for (var session in sessions) { - for (var log in session.exercises) { - if (log.exerciseId == exerciseId && log.sets.isNotEmpty) { - final maxReps = log.sets - .map((s) => s.reps) - .reduce((a, b) => a > b ? a : b); - if (maxReps > bestValue) { - bestValue = maxReps.toDouble(); - } - } + 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(); } } @@ -45,16 +57,12 @@ class WeightTargetCalculator implements TargetCalculatorStrategy { double calculate(String exerciseId, List sessions) { double bestValue = 0; - for (var session in sessions) { - for (var log in session.exercises) { - if (log.exerciseId == exerciseId && log.sets.isNotEmpty) { - final maxWeight = log.sets - .map((s) => s.weight) - .reduce((a, b) => a > b ? a : b); - if (maxWeight > bestValue) { - bestValue = maxWeight; - } - } + 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; } } @@ -68,13 +76,9 @@ class VolumeTargetCalculator implements TargetCalculatorStrategy { double calculate(String exerciseId, List sessions) { double bestValue = 0; - for (var session in sessions) { - for (var log in session.exercises) { - if (log.exerciseId == exerciseId && log.sets.isNotEmpty) { - if (log.totalVolume > bestValue) { - bestValue = log.totalVolume; - } - } + for (var log in _getExerciseLogsForExercise(exerciseId, sessions)) { + if (log.totalVolume > bestValue) { + bestValue = log.totalVolume; } } diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index af661f0..ac09b6b 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -117,7 +117,7 @@ class MockStorageService implements IStorageService { return _sessions .where( (session) => - session.date.isAfter(start) && session.date.isBefore(end), + !session.date.isBefore(start) && !session.date.isAfter(end), ) .toList(); } @@ -207,11 +207,14 @@ class MockStorageService implements IStorageService { @override Future getExercise(String id) async { - try { - return _customExercises.firstWhere((e) => e.id == id); - } catch (_) { - return null; + // Check built-in exercises first, matching production behavior + final builtIn = ExerciseDatabase.getById(id); + if (builtIn != null) { + return builtIn; } + // Fall back to custom exercises + final index = _customExercises.indexWhere((e) => e.id == id); + return index != -1 ? _customExercises[index] : null; } @override From dc9de77e8cd686d7a85ee8365b87f1cd6e266267 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:07:11 +0530 Subject: [PATCH 5/6] refactor: Prevent duplicate exercise selections and ensure state cleanup in workout manager --- .../lib/screens/routines_screen.dart | 14 ++++++++++-- .../managers/active_workout_manager.dart | 14 +++++++----- .../services/managers/analytics_manager.dart | 22 ++++++++++++++----- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 0542106..381a214 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -544,7 +544,12 @@ class _CreateRoutineScreenState extends State { onChanged: (val) { setModalState(() { if (val == true) { - tempSelectedIds.add(exercise.id); + // Prevent duplicates + if (!tempSelectedIds.contains( + exercise.id, + )) { + tempSelectedIds.add(exercise.id); + } } else { tempSelectedIds.remove(exercise.id); } @@ -582,7 +587,12 @@ class _CreateRoutineScreenState extends State { if (isSelected) { tempSelectedIds.remove(exercise.id); } else { - tempSelectedIds.add(exercise.id); + // Prevent duplicates + if (!tempSelectedIds.contains( + exercise.id, + )) { + tempSelectedIds.add(exercise.id); + } } }); }, diff --git a/workout-logger/lib/services/managers/active_workout_manager.dart b/workout-logger/lib/services/managers/active_workout_manager.dart index 0b314b6..accd5d6 100644 --- a/workout-logger/lib/services/managers/active_workout_manager.dart +++ b/workout-logger/lib/services/managers/active_workout_manager.dart @@ -207,12 +207,14 @@ class ActiveWorkoutManager extends ChangeNotifier { rethrow; } - // Only proceed if save was successful - onWorkoutSaved?.call(session); - - // Clear active workout state - _resetState(); - notifyListeners(); + // Notify callback and ensure cleanup happens even if callback throws + try { + onWorkoutSaved?.call(session); + } finally { + // Always clear active workout state + _resetState(); + notifyListeners(); + } return session; } diff --git a/workout-logger/lib/services/managers/analytics_manager.dart b/workout-logger/lib/services/managers/analytics_manager.dart index e60b98f..7f951bd 100644 --- a/workout-logger/lib/services/managers/analytics_manager.dart +++ b/workout-logger/lib/services/managers/analytics_manager.dart @@ -54,6 +54,9 @@ class AnalyticsManager extends ChangeNotifier { await Future.wait( exerciseIds.map((exerciseId) => updateGrowthModel(exerciseId, sessions)), ); + + // Notify listeners after bulk update completes + notifyListeners(); } /// Update growth model for a specific exercise @@ -77,13 +80,16 @@ class AnalyticsManager extends ChangeNotifier { } /// Update growth models for multiple exercises + /// + /// Runs all model updates concurrently for better performance. Future updateGrowthModelsForExercises( Set exerciseIds, List sessions, ) async { - for (var exerciseId in exerciseIds) { - await updateGrowthModel(exerciseId, sessions); - } + // Run updates in parallel like trainAllGrowthModels + await Future.wait( + exerciseIds.map((exerciseId) => updateGrowthModel(exerciseId, sessions)), + ); notifyListeners(); } @@ -144,12 +150,16 @@ class AnalyticsManager extends ChangeNotifier { } /// Get weekly volume by muscle group + /// + /// [now] parameter allows test injection of a fixed timestamp for deterministic testing. Map getWeeklyVolumeByMuscle( List sessions, - List exercises, - ) { + List exercises, { + DateTime? now, + }) { final volumeByMuscle = {}; - final weekAgo = DateTime.now().subtract(const Duration(days: 7)); + final currentTime = now ?? DateTime.now(); + final weekAgo = currentTime.subtract(const Duration(days: 7)); for (var session in sessions) { if (session.date.isBefore(weekAgo)) continue; From 18cc562cbe47b32a3049c1ee2644a04ab35e0588 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:21:26 +0530 Subject: [PATCH 6/6] feat: Add comprehensive test cases for app functionality and navigation flow --- FIREBASE_TEST_CASES.yaml | 187 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 FIREBASE_TEST_CASES.yaml diff --git a/FIREBASE_TEST_CASES.yaml b/FIREBASE_TEST_CASES.yaml new file mode 100644 index 0000000..098a8a7 --- /dev/null +++ b/FIREBASE_TEST_CASES.yaml @@ -0,0 +1,187 @@ +- displayName: Launch App and View Home Screen + id: launch_app + steps: + - goal: Open the app and verify the home screen displays + hint: Tap the app icon to launch + successCriteria: Screen shows "Workout Logger" title with dashboard cards showing workout statistics + +- displayName: View Exercise Library + id: view_exercise_library + prerequisiteTestCaseId: launch_app + steps: + - goal: Navigate to the exercise library screen + hint: Tap on the "Exercises" tab or button in the navigation + successCriteria: Screen displays a list of exercises grouped by muscle groups + - goal: Search for a specific exercise + hint: Tap the search field and type "bench press" + successCriteria: Filtered list shows bench press exercise + +- displayName: Start a Quick Workout + id: start_quick_workout + prerequisiteTestCaseId: launch_app + steps: + - goal: Navigate to start workout screen + hint: Tap "Start Workout" or "Quick Start" button on home screen + successCriteria: Screen shows "Select Exercises" or exercise selection interface + - goal: Select exercises for the workout + hint: Select at least 2 exercises from the list (e.g., Bench Press, Squats) + successCriteria: Selected exercises appear in the workout list + - goal: Start the workout session + hint: Tap "Start Workout" or "Begin" button + successCriteria: Workout flow screen displays with first exercise and set entry interface + +- displayName: Log Exercise Sets + id: log_exercise_sets + prerequisiteTestCaseId: start_quick_workout + steps: + - goal: Enter weight and reps for a set + hint: Tap weight field, enter "100", tap reps field, enter "10" + successCriteria: Weight shows "100" and reps shows "10" + - goal: Add the set to the exercise log + hint: Tap "Add Set" or checkmark button + successCriteria: Set appears in the list showing "100 kg × 10 reps" + - goal: Add another set + hint: Enter weight "105" and reps "8", then tap "Add Set" + successCriteria: Second set appears showing "105 kg × 8 reps" + +- displayName: Complete Workout + id: complete_workout + prerequisiteTestCaseId: log_exercise_sets + steps: + - goal: Navigate to the next exercise or finish workout + hint: Tap "Next Exercise" or "Finish Workout" button + successCriteria: Confirmation dialog appears asking to save workout + - goal: Save the completed workout + hint: Tap "Save & Finish" or "Save" button in the dialog + successCriteria: Screen returns to home showing success message and updated workout count + +- displayName: View Workout History + id: view_workout_history + prerequisiteTestCaseId: complete_workout + steps: + - goal: Navigate to workout history screen + hint: Tap "History" tab or button in navigation + successCriteria: Screen displays list of completed workouts with dates + - goal: View details of a specific workout + hint: Tap on the most recent workout in the list + successCriteria: Workout details screen shows exercises, sets, weights, and reps for that session + +- displayName: Create a New Routine + id: create_new_routine + prerequisiteTestCaseId: launch_app + steps: + - goal: Navigate to routines screen + hint: Tap "Routines" tab or button in navigation + successCriteria: Screen shows list of routines with "Create Routine" or "+" button + - goal: Start creating a new routine + hint: Tap "Create Routine" or "+" button + successCriteria: Routine creation screen appears with name input field + - goal: Enter routine name + hint: Tap name field and type "Push Day" + successCriteria: Name field shows "Push Day" + - goal: Add exercises to the routine + hint: Tap "Add Exercises" button and select 3-4 exercises (e.g., Bench Press, Shoulder Press, Tricep Dips) + successCriteria: Selected exercises appear in the routine exercise list + - goal: Save the routine + hint: Tap "Save" button + successCriteria: Screen returns to routines list showing the new "Push Day" routine + +- displayName: Start Workout from Routine + id: start_workout_from_routine + prerequisiteTestCaseId: create_new_routine + steps: + - goal: Select a routine to start workout + hint: On routines screen, tap on "Push Day" routine + successCriteria: Routine details screen shows exercises in the routine + - goal: Start workout from this routine + hint: Tap "Start Workout" button + successCriteria: Workout flow screen displays with first exercise from the routine + +- displayName: Add Custom Exercise + id: add_custom_exercise + prerequisiteTestCaseId: view_exercise_library + steps: + - goal: Navigate to add custom exercise screen + hint: On exercise library screen, tap "Add Custom Exercise" or "+" button + successCriteria: Custom exercise creation form appears + - goal: Enter exercise details + hint: Enter name "Cable Flyes", select category "Isolation", select primary muscle "Chest" + successCriteria: All fields are filled with entered values + - goal: Save the custom exercise + hint: Tap "Save" or "Create" button + successCriteria: Screen returns to exercise library with new exercise visible + +- displayName: View Analytics Dashboard + id: view_analytics + prerequisiteTestCaseId: complete_workout + steps: + - goal: Navigate to analytics screen + hint: Tap "Analytics" or "Stats" tab in navigation + successCriteria: Screen displays workout statistics, charts, and progress metrics + - goal: View exercise-specific analytics + hint: Tap on an exercise from the list or chart + successCriteria: Detailed analytics for that exercise showing volume progression or performance trends + +- displayName: Set a Fitness Target + id: set_fitness_target + prerequisiteTestCaseId: launch_app + steps: + - goal: Navigate to targets or goals section + hint: Look for "Targets", "Goals", or similar option in navigation or home screen + successCriteria: Targets screen displays with list of current targets and "Add Target" option + - goal: Create a new target + hint: Tap "Add Target" or "+" button + successCriteria: Target creation form appears + - goal: Set target details + hint: Select exercise "Bench Press", select type "Weight", enter target value "120" + successCriteria: Target form shows all entered values + - goal: Save the target + hint: Tap "Save" or "Create" button + successCriteria: Screen returns to targets list showing new target with progress indicator + +- displayName: Edit Workout History + id: edit_workout_history + prerequisiteTestCaseId: view_workout_history + steps: + - goal: Open a workout for editing + hint: On workout details screen, tap "Edit" button or icon + successCriteria: Workout edit screen appears with editable sets and exercises + - goal: Modify a set's weight + hint: Tap on a set's weight value, change to a different value, and save + successCriteria: Updated weight value is displayed in the set list + - goal: Save changes + hint: Tap "Save" button + successCriteria: Screen returns to workout details showing updated values + +- displayName: Delete Custom Exercise + id: delete_custom_exercise + prerequisiteTestCaseId: add_custom_exercise + steps: + - goal: Find the custom exercise + hint: On exercise library, locate the "Cable Flyes" custom exercise + successCriteria: Custom exercise is visible with "Custom" badge or indicator + - goal: Delete the custom exercise + hint: Long press or tap menu on the exercise, select "Delete" + successCriteria: Confirmation dialog appears + - goal: Confirm deletion + hint: Tap "Delete" or "Confirm" in the dialog + successCriteria: Exercise is removed from the library list + +- displayName: Cancel Active Workout + id: cancel_active_workout + prerequisiteTestCaseId: start_quick_workout + steps: + - goal: Initiate workout cancellation + hint: During active workout, tap back button or menu and select "Cancel Workout" + successCriteria: Confirmation dialog appears warning that progress will not be saved + - goal: Confirm cancellation + hint: Tap "Cancel Workout" in the confirmation dialog + successCriteria: Returns to home screen without saving, workout count unchanged + +- displayName: App Navigation Flow + id: app_navigation_flow + prerequisiteTestCaseId: launch_app + steps: + - goal: Navigate to each main section + hint: Tap through all navigation tabs - Home, Exercises, Routines, History, Analytics + successCriteria: Each screen loads successfully and displays relevant content without errors