diff --git a/exercises.json b/exercises.json new file mode 100644 index 0000000..02e0a08 --- /dev/null +++ b/exercises.json @@ -0,0 +1,114 @@ +{ + "version": 1, + "exercises": [ + { + "id": "remote_cable_crossover", + "name": "Cable Crossover", + "category": "isolation", + "isCustom": false, + "muscleActivations": [ + { "muscleGroupId": "chest", "activationPercentage": 80 }, + { "muscleGroupId": "front_delts", "activationPercentage": 20 } + ] + }, + { + "id": "remote_meadows_row", + "name": "Meadows Row", + "category": "compound", + "isCustom": false, + "muscleActivations": [ + { "muscleGroupId": "back", "activationPercentage": 70 }, + { "muscleGroupId": "rear_delts", "activationPercentage": 25 }, + { "muscleGroupId": "biceps", "activationPercentage": 20 } + ] + }, + { + "id": "remote_z_press", + "name": "Z-Press", + "category": "compound", + "isCustom": false, + "muscleActivations": [ + { "muscleGroupId": "front_delts", "activationPercentage": 70 }, + { "muscleGroupId": "side_delts", "activationPercentage": 30 }, + { "muscleGroupId": "triceps", "activationPercentage": 20 }, + { "muscleGroupId": "core", "activationPercentage": 40 } + ] + }, + { + "id": "remote_larsen_press", + "name": "Larsen Press", + "category": "compound", + "isCustom": false, + "muscleActivations": [ + { "muscleGroupId": "chest", "activationPercentage": 65 }, + { "muscleGroupId": "triceps", "activationPercentage": 35 }, + { "muscleGroupId": "front_delts", "activationPercentage": 25 }, + { "muscleGroupId": "core", "activationPercentage": 30 } + ] + }, + { + "id": "remote_bayesian_curl", + "name": "Bayesian Cable Curl", + "category": "isolation", + "isCustom": false, + "muscleActivations": [ + { "muscleGroupId": "biceps", "activationPercentage": 90 }, + { "muscleGroupId": "forearms", "activationPercentage": 20 } + ] + }, + { + "id": "remote_nordic_curl", + "name": "Nordic Hamstring Curl", + "category": "isolation", + "isCustom": false, + "muscleActivations": [ + { "muscleGroupId": "hamstrings", "activationPercentage": 95 }, + { "muscleGroupId": "glutes", "activationPercentage": 20 } + ] + }, + { + "id": "remote_snatch_grip_rdl", + "name": "Snatch-Grip Romanian Deadlift", + "category": "compound", + "isCustom": false, + "muscleActivations": [ + { "muscleGroupId": "hamstrings", "activationPercentage": 75 }, + { "muscleGroupId": "glutes", "activationPercentage": 50 }, + { "muscleGroupId": "lower_back", "activationPercentage": 40 }, + { "muscleGroupId": "traps", "activationPercentage": 30 } + ] + }, + { + "id": "remote_copenhagen_plank", + "name": "Copenhagen Plank", + "category": "isolation", + "isCustom": false, + "muscleActivations": [ + { "muscleGroupId": "core", "activationPercentage": 60 }, + { "muscleGroupId": "hamstrings", "activationPercentage": 50 }, + { "muscleGroupId": "glutes", "activationPercentage": 40 } + ] + }, + { + "id": "remote_tib_raise", + "name": "Tibialis Raise", + "category": "isolation", + "isCustom": false, + "muscleActivations": [ + { "muscleGroupId": "calves", "activationPercentage": 90 } + ] + }, + { + "id": "remote_wide_grip_cable_row", + "name": "Wide-Grip Cable Row", + "category": "compound", + "isCustom": false, + "muscleActivations": [ + { "muscleGroupId": "back", "activationPercentage": 70 }, + { "muscleGroupId": "rear_delts", "activationPercentage": 35 }, + { "muscleGroupId": "biceps", "activationPercentage": 25 }, + { "muscleGroupId": "traps", "activationPercentage": 20 } + ] + } + ] +} diff --git a/workout-logger/lib/screens/exercise_library_screen.dart b/workout-logger/lib/screens/exercise_library_screen.dart index ee80ea2..9797ec8 100644 --- a/workout-logger/lib/screens/exercise_library_screen.dart +++ b/workout-logger/lib/screens/exercise_library_screen.dart @@ -20,10 +20,29 @@ class _ExerciseLibraryScreenState extends State { String _searchQuery = ''; String? _selectedMuscleGroup; + Future _fetchRemoteExercises(BuildContext context) async { + final provider = context.read(); + await provider.fetchRemoteExercises(); + if (!mounted) return; + final error = provider.lastFetchError; + if (error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to fetch exercises: $error')), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Loaded ${provider.lastFetchCount} remote exercises'), + ), + ); + } + } + @override Widget build(BuildContext context) { - // Use Provider's exercise list (includes custom exercises) - final allExercises = context.watch().allExercises; + final provider = context.watch(); + // Use Provider's exercise list (includes custom + remote exercises) + final allExercises = provider.allExercises; // Filter exercises var filteredExercises = allExercises.where((e) { @@ -38,13 +57,12 @@ class _ExerciseLibraryScreenState extends State { return matchesSearch && matchesMuscle; }).toList(); - // Sort: custom exercises first within each group for visibility + // Sort: custom first, remote second, then alphabetically filteredExercises.sort((a, b) { - // First by custom status (custom first) - if (a.isCustom && !b.isCustom) return -1; - if (!a.isCustom && b.isCustom) return 1; - // Then alphabetically - return a.name.compareTo(b.name); + int rank(Exercise e) => + e.isCustom ? 0 : e.id.startsWith('remote_') ? 1 : 2; + final cmp = rank(a).compareTo(rank(b)); + return cmp != 0 ? cmp : a.name.compareTo(b.name); }); // Group by primary muscle @@ -61,6 +79,23 @@ class _ExerciseLibraryScreenState extends State { appBar: AppBar( title: const Text('Exercise Library'), actions: [ + if (provider.isFetchingRemote) + const Padding( + padding: EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else + IconButton( + icon: const Icon(Icons.cloud_download_outlined), + tooltip: 'Fetch exercises', + onPressed: () => _fetchRemoteExercises(context), + ), if (customCount > 0) Padding( padding: const EdgeInsets.only(right: AppSpacing.md), @@ -260,7 +295,7 @@ class _ExerciseCard extends StatelessWidget { padding: const EdgeInsets.all(AppSpacing.md), child: Row( children: [ - // Icon with custom badge + // Icon with custom/remote badge Stack( children: [ Container( @@ -269,7 +304,9 @@ class _ExerciseCard extends StatelessWidget { decoration: BoxDecoration( color: exercise.isCustom ? AppTheme.warning.withOpacity(0.2) - : AppTheme.primaryColor.withOpacity(0.2), + : exercise.id.startsWith('remote_') + ? AppTheme.secondaryColor.withOpacity(0.2) + : AppTheme.primaryColor.withOpacity(0.2), borderRadius: BorderRadius.circular(12), ), child: Icon( @@ -278,7 +315,9 @@ class _ExerciseCard extends StatelessWidget { : Icons.accessibility_new, color: exercise.isCustom ? AppTheme.warning - : AppTheme.primaryColor, + : exercise.id.startsWith('remote_') + ? AppTheme.secondaryColor + : AppTheme.primaryColor, ), ), if (exercise.isCustom) @@ -297,6 +336,23 @@ class _ExerciseCard extends StatelessWidget { color: Colors.black, ), ), + ) + else if (exercise.id.startsWith('remote_')) + Positioned( + right: -2, + top: -2, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: AppTheme.secondaryColor, + borderRadius: BorderRadius.circular(6), + ), + child: const Icon( + Icons.cloud_done, + size: 10, + color: Colors.black, + ), + ), ), ], ), @@ -363,6 +419,26 @@ class _ExerciseCard extends StatelessWidget { ), ), ), + ] else if (exercise.id.startsWith('remote_')) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppTheme.secondaryColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'REMOTE', + style: TextStyle( + color: AppTheme.secondaryColor, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), ], const SizedBox(width: 8), Text( @@ -437,7 +513,9 @@ class _ExerciseDetailsSheet extends StatelessWidget { decoration: BoxDecoration( color: exercise.isCustom ? AppTheme.warning.withOpacity(0.2) - : AppTheme.primaryColor.withOpacity(0.2), + : exercise.id.startsWith('remote_') + ? AppTheme.secondaryColor.withOpacity(0.2) + : AppTheme.primaryColor.withOpacity(0.2), borderRadius: BorderRadius.circular(12), ), child: Icon( @@ -446,7 +524,9 @@ class _ExerciseDetailsSheet extends StatelessWidget { : Icons.accessibility_new, color: exercise.isCustom ? AppTheme.warning - : AppTheme.primaryColor, + : exercise.id.startsWith('remote_') + ? AppTheme.secondaryColor + : AppTheme.primaryColor, ), ), if (exercise.isCustom) @@ -465,6 +545,23 @@ class _ExerciseDetailsSheet extends StatelessWidget { color: Colors.black, ), ), + ) + else if (exercise.id.startsWith('remote_')) + Positioned( + right: -2, + top: -2, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppTheme.secondaryColor, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.cloud_done, + size: 10, + color: Colors.black, + ), + ), ), ], ), @@ -505,6 +602,26 @@ class _ExerciseDetailsSheet extends StatelessWidget { ), ), ), + ] else if (exercise.id.startsWith('remote_')) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppTheme.secondaryColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'REMOTE', + style: TextStyle( + color: AppTheme.secondaryColor, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), ], ], ), diff --git a/workout-logger/lib/services/interfaces/storage_service_interface.dart b/workout-logger/lib/services/interfaces/storage_service_interface.dart index 7ad6e1e..d22d09a 100644 --- a/workout-logger/lib/services/interfaces/storage_service_interface.dart +++ b/workout-logger/lib/services/interfaces/storage_service_interface.dart @@ -55,6 +55,12 @@ abstract class IStorageService { Future> getAllExercises(); Future getExercise(String id); + // ==================== REMOTE EXERCISES ==================== + + Future saveRemoteExercises(List exercises); + Future> getRemoteExercises(); + Future clearRemoteExercises(); + // ==================== SETTINGS ==================== Future saveSetting(String key, String value); diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index 25cc76c..a23e503 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -24,6 +24,7 @@ class StorageService implements IStorageService { static const String _customExercisesBox = 'custom_exercises'; static const String _settingsBox = 'settings'; static const String _trainingProgramsBox = 'training_programs'; + static const String _remoteExercisesBox = 'remote_exercises'; late Box _sessionsBox; late Box _routinesBoxInstance; @@ -32,6 +33,7 @@ class StorageService implements IStorageService { late Box _customExercisesBoxInstance; late Box _settingsBoxInstance; late Box _trainingProgramsBoxInstance; + late Box _remoteExercisesBoxInstance; String _appVersion = const String.fromEnvironment( 'APP_VERSION', @@ -68,6 +70,9 @@ class StorageService implements IStorageService { _trainingProgramsBoxInstance = await Hive.openBox( _trainingProgramsBox, ); + _remoteExercisesBoxInstance = await Hive.openBox( + _remoteExercisesBox, + ); // Initialize default muscle groups if empty if (_muscleGroupsBoxInstance.isEmpty) { @@ -258,15 +263,27 @@ class StorageService implements IStorageService { await _customExercisesBoxInstance.delete(id); } - /// Get all exercises (built-in + custom) + /// Get all exercises (built-in + custom + remote), deduplicated by ID. + /// Built-in and custom take priority over remote if IDs collide. @override Future> getAllExercises() async { final builtIn = ExerciseDatabase.getAll(); final custom = await getCustomExercises(); - return [...builtIn, ...custom]; + final remote = await getRemoteExercises(); + final byId = {}; + for (final e in builtIn) { + byId[e.id] = e; + } + for (final e in custom) { + byId[e.id] = e; + } + for (final e in remote) { + byId.putIfAbsent(e.id, () => e); + } + return byId.values.toList(); } - /// Get exercise by ID (built-in or custom) + /// Get exercise by ID (built-in, custom, or remote) @override Future getExercise(String id) async { // Check built-in first @@ -274,14 +291,47 @@ class StorageService implements IStorageService { if (builtIn != null) return builtIn; // Check custom - final json = _customExercisesBoxInstance.get(id); - if (json != null) { - return Exercise.fromJson(jsonDecode(json)); + final customJson = _customExercisesBoxInstance.get(id); + if (customJson != null) { + return Exercise.fromJson(jsonDecode(customJson)); + } + + // Check remote + final remoteJson = _remoteExercisesBoxInstance.get(id); + if (remoteJson != null) { + return Exercise.fromJson(jsonDecode(remoteJson)); } return null; } + // ==================== REMOTE EXERCISES ==================== + + @override + Future saveRemoteExercises(List exercises) async { + await _remoteExercisesBoxInstance.clear(); + for (final exercise in exercises) { + await _remoteExercisesBoxInstance.put( + exercise.id, + jsonEncode(exercise.toJson()), + ); + } + } + + @override + Future> getRemoteExercises() async { + final exercises = []; + for (final json in _remoteExercisesBoxInstance.values) { + exercises.add(Exercise.fromJson(jsonDecode(json))); + } + return exercises; + } + + @override + Future clearRemoteExercises() async { + await _remoteExercisesBoxInstance.clear(); + } + // ==================== SETTINGS ==================== @override diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index d0c131f..f2aeda7 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -13,7 +13,10 @@ // Following Dependency Inversion Principle: this class now depends on // abstractions (IStorageService, IMLService) rather than concrete implementations. +import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:uuid/uuid.dart'; import '../models/models.dart'; import '../data/exercise_database.dart'; @@ -34,6 +37,13 @@ class WorkoutProvider extends ChangeNotifier { List _targets = []; List _muscleGroups = []; List _allExercises = []; + bool _isFetchingRemote = false; + String? _lastFetchError; + int _lastFetchCount = 0; + + static const String _remoteExercisesUrl = + 'https://raw.githubusercontent.com/devasy/workout-logger/main/exercises.json'; + final Map _growthModels = {}; // exerciseId -> GrowthModel @@ -52,6 +62,9 @@ class WorkoutProvider extends ChangeNotifier { List get targets => _targets; List get muscleGroups => _muscleGroups; List get allExercises => _allExercises; + bool get isFetchingRemote => _isFetchingRemote; + String? get lastFetchError => _lastFetchError; + int get lastFetchCount => _lastFetchCount; bool get hasActiveWorkout => _activeSession != null || _workoutStartTime != null; @@ -261,6 +274,49 @@ class WorkoutProvider extends ChangeNotifier { return true; } + // ==================== REMOTE EXERCISES ==================== + + /// Fetch exercises from the remote GitHub JSON file and cache them locally. + /// + /// Sets [isFetchingRemote] during the request and updates [lastFetchCount] + /// or [lastFetchError] on completion. + Future fetchRemoteExercises() async { + if (_isFetchingRemote) return; + + _isFetchingRemote = true; + _lastFetchError = null; + notifyListeners(); + + try { + final response = await http + .get(Uri.parse(_remoteExercisesUrl)) + .timeout(const Duration(seconds: 15)); + + if (response.statusCode != 200) { + throw Exception('HTTP ${response.statusCode}'); + } + + final decoded = jsonDecode(response.body) as Map; + final exerciseList = decoded['exercises'] as List; + final exercises = exerciseList + .map((e) => Exercise.fromJson(e as Map)) + .toList(); + + await _storage.saveRemoteExercises(exercises); + _allExercises = await _storage.getAllExercises(); + _lastFetchCount = exercises.length; + } on TimeoutException { + _lastFetchError = 'Request timed out. Check your connection.'; + debugPrint('fetchRemoteExercises: timeout'); + } catch (e) { + _lastFetchError = 'Failed to fetch exercises: $e'; + debugPrint('fetchRemoteExercises error: $e'); + } finally { + _isFetchingRemote = false; + notifyListeners(); + } + } + // ==================== WORKOUT FLOW ==================== /// Start a new workout with a routine diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index a422ba5..fde9821 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -20,12 +20,14 @@ class MockStorageService implements IStorageService { final List _muscleGroups = []; final Map _settings = {}; final List _trainingPrograms = []; + final List _remoteExercises = []; bool saveCustomExerciseCalled = false; Exercise? lastSavedExercise; // Public getters for test assertions List get customExercises => _customExercises; + List get remoteExercises => _remoteExercises; List get sessions => _sessions; List get routines => _routines; List get targets => _targets; @@ -35,6 +37,10 @@ class MockStorageService implements IStorageService { _customExercises.add(exercise); } + void addMockRemoteExercise(Exercise exercise) { + _remoteExercises.add(exercise); + } + void addMockSession(WorkoutSession session) { _sessions.add(session); } @@ -52,9 +58,19 @@ class MockStorageService implements IStorageService { @override Future> getAllExercises() async { - // Merge built-in exercises with custom exercises to mirror production behavior + // Merge built-in + custom + remote, mirroring production deduplication final builtInExercises = ExerciseDatabase.getAll(); - return [...builtInExercises, ..._customExercises]; + final byId = {}; + for (final e in builtInExercises) { + byId[e.id] = e; + } + for (final e in _customExercises) { + byId[e.id] = e; + } + for (final e in _remoteExercises) { + byId.putIfAbsent(e.id, () => e); + } + return byId.values.toList(); } @override @@ -208,16 +224,28 @@ class MockStorageService implements IStorageService { @override Future getExercise(String id) async { - // 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; + if (builtIn != null) return builtIn; + final customIdx = _customExercises.indexWhere((e) => e.id == id); + if (customIdx != -1) return _customExercises[customIdx]; + final remoteIdx = _remoteExercises.indexWhere((e) => e.id == id); + return remoteIdx != -1 ? _remoteExercises[remoteIdx] : null; } + @override + Future saveRemoteExercises(List exercises) async { + _remoteExercises + ..clear() + ..addAll(exercises); + } + + @override + Future> getRemoteExercises() async => + List.from(_remoteExercises); + + @override + Future clearRemoteExercises() async => _remoteExercises.clear(); + @override Future saveSetting(String key, String value) async { _settings[key] = value;