From b3820ad984c4fc02d1195f83d03ab1e639ee2811 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:26:23 +0530 Subject: [PATCH 01/10] feat: integrate Health Connect for workout syncing and permissions --- workout-logger/android/app/build.gradle.kts | 2 +- .../android/app/src/main/AndroidManifest.xml | 8 + workout-logger/lib/main.dart | 5 + .../lib/screens/profile_screen.dart | 103 ++++++++++++ .../lib/services/health_connect_service.dart | 159 ++++++++++++++++++ .../health_connect_service_interface.dart | 8 + .../lib/services/interfaces/interfaces.dart | 1 + .../lib/services/settings_provider.dart | 11 ++ .../lib/services/workout_provider.dart | 15 +- workout-logger/pubspec.yaml | 3 + 10 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 workout-logger/lib/services/health_connect_service.dart create mode 100644 workout-logger/lib/services/interfaces/health_connect_service_interface.dart diff --git a/workout-logger/android/app/build.gradle.kts b/workout-logger/android/app/build.gradle.kts index b76f436..60e3a01 100644 --- a/workout-logger/android/app/build.gradle.kts +++ b/workout-logger/android/app/build.gradle.kts @@ -24,7 +24,7 @@ android { applicationId = "com.devasy.repforge" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 26 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/workout-logger/android/app/src/main/AndroidManifest.xml b/workout-logger/android/app/src/main/AndroidManifest.xml index 5345f21..18b4b9b 100644 --- a/workout-logger/android/app/src/main/AndroidManifest.xml +++ b/workout-logger/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + + + + + + diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index cc23f4f..691a71a 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -9,8 +9,10 @@ import 'package:provider/provider.dart'; import 'services/storage_service.dart'; import 'services/ml_service.dart'; +import 'services/health_connect_service.dart'; import 'services/interfaces/storage_service_interface.dart'; import 'services/interfaces/ml_service_interface.dart'; +import 'services/interfaces/health_connect_service_interface.dart'; import 'services/workout_provider.dart'; import 'services/settings_provider.dart'; import 'services/api_service.dart'; @@ -45,6 +47,7 @@ class WorkoutLoggerApp extends StatelessWidget { // This ensures the same instances are used throughout the app lifecycle static final IStorageService _storageService = StorageService(); static final IMLService _mlService = MLService(); + static final IHealthConnectService _healthConnectService = HealthConnectService(); static final ProgramManager _programManager = ProgramManager(_storageService); static final SettingsProvider _settingsProvider = SettingsProvider(_storageService); @@ -61,6 +64,7 @@ class WorkoutLoggerApp extends StatelessWidget { Provider.value(value: _storageService), // Provide the ML service interface for direct access if needed Provider.value(value: _mlService), + Provider.value(value: _healthConnectService), // Provide the ApiService singleton via DI Provider.value(value: ApiService()), // ProgramManager passed to tree directly @@ -72,6 +76,7 @@ class WorkoutLoggerApp extends StatelessWidget { create: (_) => WorkoutProvider( _storageService, mlService: _mlService, + healthConnectService: _healthConnectService, programManager: _programManager, ), ), diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index 37309dc..7402f0a 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -13,6 +13,7 @@ import 'package:intl/intl.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; import '../services/api_service.dart'; +import '../services/interfaces/health_connect_service_interface.dart'; import '../theme/app_theme.dart'; const String _appVersion = '1.0.12'; @@ -29,6 +30,32 @@ class _ProfileScreenState extends State { bool _isExporting = false; bool _isImporting = false; bool _isBackingUp = false; + bool _isRequestingHcPermission = false; + + Future _requestHealthConnectPermission() async { + setState(() => _isRequestingHcPermission = true); + try { + final hc = context.read(); + final available = await hc.isAvailable(); + if (!available) { + if (mounted) _showSnack('Health Connect is not available on this device.', AppTheme.error); + return; + } + final granted = await hc.requestPermissions(); + if (!mounted) return; + if (granted) { + final settings = context.read(); + await settings.setHealthConnectEnabled(true); + _showSnack('Health Connect connected!', AppTheme.success); + } else { + _showSnack('Permission denied. Grant it in Health Connect settings.', AppTheme.warning); + } + } catch (e) { + if (mounted) _showSnack('Could not connect to Health Connect.', AppTheme.error); + } finally { + if (mounted) setState(() => _isRequestingHcPermission = false); + } + } // ==================== Data Actions ==================== @@ -169,6 +196,8 @@ class _ProfileScreenState extends State { delegate: SliverChildListDelegate([ _buildPreferencesSection(settings), const SizedBox(height: AppSpacing.lg), + _buildHealthConnectSection(settings), + const SizedBox(height: AppSpacing.lg), _buildDataSection(), const SizedBox(height: AppSpacing.lg), _buildCloudSyncSection(), @@ -317,6 +346,80 @@ class _ProfileScreenState extends State { ); } + // ==================== Health Connect ==================== + + Widget _buildHealthConnectSection(SettingsProvider settings) { + final enabled = settings.healthConnectEnabled; + return _ProfileSection( + icon: Icons.monitor_heart_outlined, + iconColor: const Color(0xFF00BFA5), + title: 'Health Connect', + subtitle: 'Sync workouts to Android Health Connect', + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sync workouts after finishing', + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 14, + ), + ), + Text( + 'Writes session + per-set reps to Health Connect', + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ), + Switch( + value: enabled, + onChanged: _isRequestingHcPermission + ? null + : (value) async { + if (value) { + await _requestHealthConnectPermission(); + } else { + await settings.setHealthConnectEnabled(false); + } + }, + activeThumbColor: const Color(0xFF00BFA5), + activeTrackColor: const Color(0xFF00BFA5).withValues(alpha: 0.4), + ), + ], + ), + if (enabled) ...[ + const SizedBox(height: AppSpacing.sm), + const Divider(color: AppTheme.surfaceColor, height: 1), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + const Icon(Icons.check_circle_outline, + color: Color(0xFF00BFA5), size: 16), + const SizedBox(width: 8), + const Text( + 'Connected — syncing after each workout', + style: TextStyle( + color: Color(0xFF00BFA5), + fontSize: 12, + ), + ), + ], + ), + ], + ], + ), + ); + } + // ==================== Data Management ==================== Widget _buildDataSection() { diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart new file mode 100644 index 0000000..688fc1d --- /dev/null +++ b/workout-logger/lib/services/health_connect_service.dart @@ -0,0 +1,159 @@ +import 'dart:math' show max; + +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:health_connector/health_connector.dart'; + +import '../models/models.dart'; +import 'interfaces/health_connect_service_interface.dart'; + +class HealthConnectService implements IHealthConnectService { + HealthConnector? _connector; + + // Maps RepForge exercise IDs to Health Connect ExerciseSegmentType enum values. + // Custom exercises not in this map fall back to otherWorkout. + static const _segmentTypeMap = { + 'bench_press': ExerciseSegmentType.benchPress, + 'incline_bench_press': ExerciseSegmentType.benchPress, + 'dumbbell_bench_press': ExerciseSegmentType.benchPress, + 'incline_dumbbell_press': ExerciseSegmentType.benchPress, + 'close_grip_bench': ExerciseSegmentType.benchPress, + 'push_ups': ExerciseSegmentType.otherWorkout, + 'dips': ExerciseSegmentType.otherWorkout, + 'cable_fly': ExerciseSegmentType.otherWorkout, + 'pec_deck': ExerciseSegmentType.otherWorkout, + 'lat_pulldown': ExerciseSegmentType.latPullDown, + 'pull_ups': ExerciseSegmentType.pullUp, + 'chin_ups': ExerciseSegmentType.pullUp, + 'barbell_row': ExerciseSegmentType.otherWorkout, + 'dumbbell_row': ExerciseSegmentType.dumbbellRow, + 'seated_cable_row': ExerciseSegmentType.otherWorkout, + 't_bar_row': ExerciseSegmentType.otherWorkout, + 'deadlift': ExerciseSegmentType.deadlift, + 'romanian_deadlift': ExerciseSegmentType.deadlift, + 'face_pull': ExerciseSegmentType.otherWorkout, + 'overhead_press': ExerciseSegmentType.barbellShoulderPress, + 'dumbbell_shoulder_press': ExerciseSegmentType.shoulderPress, + 'lateral_raise': ExerciseSegmentType.dumbbellLateralRaise, + 'front_raise': ExerciseSegmentType.dumbbellFrontRaise, + 'rear_delt_fly': ExerciseSegmentType.otherWorkout, + 'shrugs': ExerciseSegmentType.otherWorkout, + 'squat': ExerciseSegmentType.squat, + 'leg_press': ExerciseSegmentType.legPress, + 'leg_extension': ExerciseSegmentType.legExtension, + 'leg_curl': ExerciseSegmentType.legCurl, + 'lunges': ExerciseSegmentType.lunge, + 'hip_thrust': ExerciseSegmentType.hipThrust, + 'calf_raise': ExerciseSegmentType.otherWorkout, + 'bicep_curl': ExerciseSegmentType.armCurl, + 'hammer_curl': ExerciseSegmentType.armCurl, + 'preacher_curl': ExerciseSegmentType.armCurl, + 'concentration_curl': ExerciseSegmentType.armCurl, + 'tricep_pushdown': ExerciseSegmentType.otherWorkout, + 'skull_crushers': ExerciseSegmentType.otherWorkout, + 'overhead_tricep_extension': ExerciseSegmentType.doubleArmTricepsExtension, + 'plank': ExerciseSegmentType.plank, + 'crunches': ExerciseSegmentType.crunch, + 'leg_raises': ExerciseSegmentType.legRaise, + }; + + @override + Future isAvailable() async { + try { + final status = await HealthConnector.getHealthPlatformStatus(); + return status == HealthPlatformStatus.available; + } catch (_) { + return false; + } + } + + @override + Future requestPermissions() async { + try { + _connector ??= await HealthConnector.create(); + final results = await _connector!.requestPermissions([ + HealthDataType.exerciseSession.writePermission, + ]); + return results.every((r) => r.status == PermissionStatus.granted); + } catch (e) { + debugPrint('Health Connect requestPermissions failed: $e'); + return false; + } + } + + @override + Future hasPermissions() async { + try { + _connector ??= await HealthConnector.create(); + final status = await _connector!.getPermissionStatus( + HealthDataType.exerciseSession.writePermission, + ); + return status == PermissionStatus.granted; + } catch (_) { + return false; + } + } + + @override + Future syncWorkoutSession(WorkoutSession session) async { + try { + _connector ??= await HealthConnector.create(); + + final sessionStart = session.date; + final durationMinutes = max(session.duration, 1); + final sessionEnd = sessionStart.add(Duration(minutes: durationMinutes)); + final segments = _buildSegments(session, sessionStart, sessionEnd); + + final record = ExerciseSessionRecord( + startTime: sessionStart, + endTime: sessionEnd, + exerciseType: ExerciseType.strengthTraining, + metadata: Metadata.manualEntry(), + notes: session.notes?.isNotEmpty == true ? session.notes : null, + events: segments, + ); + + await _connector!.writeRecords([record]); + return true; + } catch (e) { + debugPrint('Health Connect sync failed: $e'); + return false; + } + } + + // Builds non-overlapping ExerciseSessionSegmentEvents distributed evenly + // across the session time window. Each set becomes one segment. + List _buildSegments( + WorkoutSession session, + DateTime sessionStart, + DateTime sessionEnd, + ) { + final allSets = <(ExerciseSegmentType, int)>[]; + + for (final log in session.exercises) { + final type = _segmentTypeMap[log.exerciseId] ?? ExerciseSegmentType.otherWorkout; + for (final set in log.sets) { + if (set.reps > 0) { + allSets.add((type, set.reps)); + } + } + } + + if (allSets.isEmpty) return []; + + final totalMs = sessionEnd.difference(sessionStart).inMilliseconds; + final slotMs = totalMs ~/ allSets.length; + + return List.generate(allSets.length, (i) { + final start = sessionStart.add(Duration(milliseconds: slotMs * i)); + final end = i < allSets.length - 1 + ? sessionStart.add(Duration(milliseconds: slotMs * (i + 1))) + : sessionEnd; + return ExerciseSessionSegmentEvent( + startTime: start, + endTime: end, + segmentType: allSets[i].$1, + repetitions: allSets[i].$2, + ); + }); + } +} diff --git a/workout-logger/lib/services/interfaces/health_connect_service_interface.dart b/workout-logger/lib/services/interfaces/health_connect_service_interface.dart new file mode 100644 index 0000000..186a07d --- /dev/null +++ b/workout-logger/lib/services/interfaces/health_connect_service_interface.dart @@ -0,0 +1,8 @@ +import '../../models/models.dart'; + +abstract class IHealthConnectService { + Future isAvailable(); + Future requestPermissions(); + Future hasPermissions(); + Future syncWorkoutSession(WorkoutSession session); +} diff --git a/workout-logger/lib/services/interfaces/interfaces.dart b/workout-logger/lib/services/interfaces/interfaces.dart index fdea987..8aa9a2d 100644 --- a/workout-logger/lib/services/interfaces/interfaces.dart +++ b/workout-logger/lib/services/interfaces/interfaces.dart @@ -6,3 +6,4 @@ export 'storage_service_interface.dart'; export 'ml_service_interface.dart'; +export 'health_connect_service_interface.dart'; diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index 504ca63..00d5cc1 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -10,10 +10,12 @@ class SettingsProvider extends ChangeNotifier { WeightUnit _weightUnit = WeightUnit.kg; double _weightIncrement = 2.5; + bool _healthConnectEnabled = false; WeightUnit get weightUnit => _weightUnit; double get weightIncrement => _weightIncrement; String get unitLabel => _weightUnit == WeightUnit.kg ? 'kg' : 'lbs'; + bool get healthConnectEnabled => _healthConnectEnabled; SettingsProvider(this._storage); @@ -25,6 +27,9 @@ class SettingsProvider extends ChangeNotifier { _weightIncrement = increment != null ? (double.tryParse(increment) ?? _defaultIncrement) : _defaultIncrement; + + final hcEnabled = await _storage.getSetting('healthConnectEnabled'); + _healthConnectEnabled = hcEnabled == 'true'; } double get _defaultIncrement => _weightUnit == WeightUnit.kg ? 2.5 : 5.0; @@ -43,6 +48,12 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + Future setHealthConnectEnabled(bool enabled) async { + _healthConnectEnabled = enabled; + await _storage.saveSetting('healthConnectEnabled', enabled.toString()); + notifyListeners(); + } + /// Convert from internal kg storage to display unit. double toDisplay(double kg) { if (_weightUnit == WeightUnit.lbs) return kg * 2.20462; diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 94f2ba1..c01fc11 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -22,6 +22,7 @@ import '../models/models.dart'; import '../data/exercise_database.dart'; import 'interfaces/storage_service_interface.dart'; import 'interfaces/ml_service_interface.dart'; +import 'interfaces/health_connect_service_interface.dart'; import 'ml_service.dart'; import 'strategies/target_calculator.dart'; import 'managers/program_manager.dart'; @@ -36,6 +37,7 @@ class WorkoutInProgressError extends StateError { class WorkoutProvider extends ChangeNotifier { final IStorageService _storage; final IMLService _mlService; + final IHealthConnectService? _healthConnect; final Uuid _uuid = const Uuid(); // State @@ -86,8 +88,10 @@ class WorkoutProvider extends ChangeNotifier { WorkoutProvider( this._storage, { IMLService? mlService, + IHealthConnectService? healthConnectService, required this.programManager, - }) : _mlService = mlService ?? MLService(); + }) : _mlService = mlService ?? MLService(), + _healthConnect = healthConnectService; // ==================== INITIALIZATION ==================== @@ -586,6 +590,15 @@ class WorkoutProvider extends ChangeNotifier { await _clearDraft(); _sessions.insert(0, session); + // Fire-and-forget Health Connect sync if enabled + final hcEnabled = await _storage.getSetting('healthConnectEnabled'); + if (hcEnabled == 'true' && _healthConnect != null) { + _healthConnect.syncWorkoutSession(session).catchError((e) { + debugPrint('Health Connect sync error: $e'); + return false; + }); + } + // Update growth models for performed exercises for (var log in completedExercises) { await _updateGrowthModel(log.exerciseId); diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 4d41622..022cdaf 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -52,6 +52,9 @@ dependencies: http: ^1.2.1 package_info_plus: ^8.3.1 + # Health Connect integration + health_connector: ^3.8.1 + # Backup export/import file_picker: ^10.3.10 path_provider: ^2.1.5 From 5d40dab760d0760c07b3e032961ba89133c1011b Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:19:13 +0530 Subject: [PATCH 02/10] feat: enhance Health Connect integration with permission handling and workout session syncing --- CLAUDE.md | 4 ++-- .../android/app/src/main/AndroidManifest.xml | 15 +++++++++++++ .../lib/screens/profile_screen.dart | 22 +++++++++++++++++-- .../lib/services/health_connect_service.dart | 3 ++- .../health_connect_service_interface.dart | 2 +- .../lib/services/workout_provider.dart | 5 ++++- 6 files changed, 44 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4a1ff5d..b09d982 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,8 +77,8 @@ flutter test test/workout_provider_test.dart # Check lint / static analysis flutter analyze -# Build release APK -flutter build apk --release +# Build release APKs (split per ABI) +flutter build apk --release --split-per-abi --obfuscate --split-debug-info=build/debug-info # Generate Mockito mocks (after modifying interfaces) dart run build_runner build --delete-conflicting-outputs diff --git a/workout-logger/android/app/src/main/AndroidManifest.xml b/workout-logger/android/app/src/main/AndroidManifest.xml index 18b4b9b..61b507a 100644 --- a/workout-logger/android/app/src/main/AndroidManifest.xml +++ b/workout-logger/android/app/src/main/AndroidManifest.xml @@ -27,7 +27,22 @@ + + + + + + + + + + + { if (mounted) _showSnack('Health Connect is not available on this device.', AppTheme.error); return; } - final granted = await hc.requestPermissions(); + + // Check if permissions were already granted (e.g. via HC settings). + bool granted = await hc.hasPermissions(); + + if (!granted) { + // Try to show the in-app permission dialog. + try { + granted = await hc.requestPermissions(); + } catch (_) { + // requestPermissions can fail if the plugin loses its activity reference + // during the async gap (known issue with health_connector on some devices). + // Re-check hasPermissions in case the user already granted via HC settings. + granted = await hc.hasPermissions(); + } + } + if (!mounted) return; if (granted) { final settings = context.read(); await settings.setHealthConnectEnabled(true); _showSnack('Health Connect connected!', AppTheme.success); } else { - _showSnack('Permission denied. Grant it in Health Connect settings.', AppTheme.warning); + _showSnack( + 'Open Health Connect → App permissions → RepForge and enable Exercise.', + AppTheme.warning, + ); } } catch (e) { if (mounted) _showSnack('Could not connect to Health Connect.', AppTheme.error); diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart index 688fc1d..abad585 100644 --- a/workout-logger/lib/services/health_connect_service.dart +++ b/workout-logger/lib/services/health_connect_service.dart @@ -94,7 +94,7 @@ class HealthConnectService implements IHealthConnectService { } @override - Future syncWorkoutSession(WorkoutSession session) async { + Future syncWorkoutSession(WorkoutSession session, {String? title}) async { try { _connector ??= await HealthConnector.create(); @@ -108,6 +108,7 @@ class HealthConnectService implements IHealthConnectService { endTime: sessionEnd, exerciseType: ExerciseType.strengthTraining, metadata: Metadata.manualEntry(), + title: title?.isNotEmpty == true ? title : null, notes: session.notes?.isNotEmpty == true ? session.notes : null, events: segments, ); diff --git a/workout-logger/lib/services/interfaces/health_connect_service_interface.dart b/workout-logger/lib/services/interfaces/health_connect_service_interface.dart index 186a07d..14b6c9a 100644 --- a/workout-logger/lib/services/interfaces/health_connect_service_interface.dart +++ b/workout-logger/lib/services/interfaces/health_connect_service_interface.dart @@ -4,5 +4,5 @@ abstract class IHealthConnectService { Future isAvailable(); Future requestPermissions(); Future hasPermissions(); - Future syncWorkoutSession(WorkoutSession session); + Future syncWorkoutSession(WorkoutSession session, {String? title}); } diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index c01fc11..b7fb2a3 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -593,7 +593,10 @@ class WorkoutProvider extends ChangeNotifier { // Fire-and-forget Health Connect sync if enabled final hcEnabled = await _storage.getSetting('healthConnectEnabled'); if (hcEnabled == 'true' && _healthConnect != null) { - _healthConnect.syncWorkoutSession(session).catchError((e) { + _healthConnect.syncWorkoutSession( + session, + title: _activeRoutine?.name, + ).catchError((e) { debugPrint('Health Connect sync error: $e'); return false; }); From 564b7734571f585c5c20fc704ad097573802f5bb Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Fri, 1 May 2026 20:29:39 +0530 Subject: [PATCH 03/10] feat: integrate Health Connect, add workout provider logic, and update project configuration --- workout-logger/android/app/build.gradle.kts | 4 ++ .../lib/screens/profile_screen.dart | 54 ++++++++++++++++++- .../lib/services/workout_provider.dart | 26 +++++---- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/workout-logger/android/app/build.gradle.kts b/workout-logger/android/app/build.gradle.kts index 60e3a01..d12c514 100644 --- a/workout-logger/android/app/build.gradle.kts +++ b/workout-logger/android/app/build.gradle.kts @@ -24,6 +24,10 @@ android { applicationId = "com.devasy.repforge" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. + // MIGRATION NOTE: minSdk is intentionally set to 26 (Android 8.0 Oreo). + // Health Connect requires API 26+. Devices running API <26 are no longer + // supported. If downgrading, remove the health_connector dependency and + // all HealthConnectService usages, then restore minSdk to flutter.minSdkVersion. minSdk = 26 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index b2d726c..d356930 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -26,12 +26,64 @@ class ProfileScreen extends StatefulWidget { State createState() => _ProfileScreenState(); } -class _ProfileScreenState extends State { +class _ProfileScreenState extends State + with WidgetsBindingObserver { bool _isExporting = false; bool _isImporting = false; bool _isBackingUp = false; bool _isRequestingHcPermission = false; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + // Reconcile stored HC flag against runtime state on screen load. + WidgetsBinding.instance.addPostFrameCallback((_) => _reconcileHealthConnectState()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Re-check HC state when the user returns from background + // (e.g. after visiting Health Connect settings). + if (state == AppLifecycleState.resumed) { + _reconcileHealthConnectState(); + } + } + + /// Reconciles the persisted [SettingsProvider.healthConnectEnabled] flag + /// with the actual runtime HC availability and permission state. + /// If HC is unavailable or permissions are revoked, the flag is cleared + /// so the toggle and status row reflect reality. + Future _reconcileHealthConnectState() async { + if (!mounted) return; + final settings = context.read(); + // Only run the runtime checks when the flag is currently enabled — + // avoids unnecessary plugin calls when HC is already off. + if (!settings.healthConnectEnabled) return; + try { + final hc = context.read(); + final available = await hc.isAvailable(); + if (!available) { + if (mounted) await settings.setHealthConnectEnabled(false); + return; + } + final hasPerms = await hc.hasPermissions(); + if (!hasPerms) { + if (mounted) await settings.setHealthConnectEnabled(false); + } + } catch (e) { + // If we can't determine state, fail-safe: disable the flag. + debugPrint('HC reconciliation error: $e'); + if (mounted) await settings.setHealthConnectEnabled(false); + } + } + Future _requestHealthConnectPermission() async { setState(() => _isRequestingHcPermission = true); try { diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index b7fb2a3..a8d274a 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -590,16 +590,22 @@ class WorkoutProvider extends ChangeNotifier { await _clearDraft(); _sessions.insert(0, session); - // Fire-and-forget Health Connect sync if enabled - final hcEnabled = await _storage.getSetting('healthConnectEnabled'); - if (hcEnabled == 'true' && _healthConnect != null) { - _healthConnect.syncWorkoutSession( - session, - title: _activeRoutine?.name, - ).catchError((e) { - debugPrint('Health Connect sync error: $e'); - return false; - }); + // Best-effort Health Connect sync — errors must never abort finishWorkout. + try { + final hcEnabled = await _storage.getSetting('healthConnectEnabled'); + if (hcEnabled == 'true' && _healthConnect != null) { + _healthConnect + .syncWorkoutSession( + session, + title: _activeRoutine?.name, + ) + .catchError((e) { + debugPrint('Health Connect sync error: $e'); + return false; + }); + } + } catch (e) { + debugPrint('Health Connect pre-sync error (setting read failed): $e'); } // Update growth models for performed exercises From 796279a730a15f76e36b35015ff1c8009c8fc1e7 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Fri, 1 May 2026 21:41:11 +0530 Subject: [PATCH 04/10] feat: implement dependency injection and modularize service management with new History and HealthSync managers --- workout-logger/lib/main.dart | 18 +- workout-logger/lib/models/models.dart | 11 + .../lib/screens/history_screen.dart | 265 ++++++++++++++++-- .../lib/services/health_connect_service.dart | 83 +++++- .../health_sync_manager_interface.dart | 23 ++ .../lib/services/interfaces/interfaces.dart | 1 + .../managers/health_sync_manager.dart | 48 ++++ .../services/managers/history_manager.dart | 56 +++- .../lib/services/managers/managers.dart | 1 + .../lib/services/workout_provider.dart | 39 ++- .../test/health_sync_manager_test.dart | 136 +++++++++ 11 files changed, 615 insertions(+), 66 deletions(-) create mode 100644 workout-logger/lib/services/interfaces/health_sync_manager_interface.dart create mode 100644 workout-logger/lib/services/managers/health_sync_manager.dart create mode 100644 workout-logger/test/health_sync_manager_test.dart diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 691a71a..0a7f206 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -17,6 +17,8 @@ import 'services/workout_provider.dart'; import 'services/settings_provider.dart'; import 'services/api_service.dart'; import 'services/managers/program_manager.dart'; +import 'services/managers/history_manager.dart'; +import 'services/managers/health_sync_manager.dart'; import 'theme/app_theme.dart'; import 'screens/home_screen.dart'; @@ -50,6 +52,12 @@ class WorkoutLoggerApp extends StatelessWidget { static final IHealthConnectService _healthConnectService = HealthConnectService(); static final ProgramManager _programManager = ProgramManager(_storageService); static final SettingsProvider _settingsProvider = SettingsProvider(_storageService); + // HealthSyncManager uses the in-memory settings flag — no storage I/O on sync. + static final HealthSyncManager _healthSyncManager = + HealthSyncManager(_healthConnectService, _settingsProvider); + // HistoryManager is the single owner of session history + HC sync trigger. + static final HistoryManager _historyManager = + HistoryManager(_storageService, healthSyncManager: _healthSyncManager); const WorkoutLoggerApp({super.key}); @@ -64,6 +72,7 @@ class WorkoutLoggerApp extends StatelessWidget { Provider.value(value: _storageService), // Provide the ML service interface for direct access if needed Provider.value(value: _mlService), + // IHealthConnectService stays in tree for ProfileScreen permission flow Provider.value(value: _healthConnectService), // Provide the ApiService singleton via DI Provider.value(value: ApiService()), @@ -71,12 +80,15 @@ class WorkoutLoggerApp extends StatelessWidget { ChangeNotifierProvider.value(value: _programManager), // SettingsProvider for user preferences (weight unit, increments) ChangeNotifierProvider.value(value: _settingsProvider), + // HistoryManager is the single source of truth for session history. + // Provided as ChangeNotifier so HistoryScreen rebuilds on sync badge changes. + ChangeNotifierProvider.value(value: _historyManager), // WorkoutProvider receives dependencies via constructor injection ChangeNotifierProvider( create: (_) => WorkoutProvider( _storageService, mlService: _mlService, - healthConnectService: _healthConnectService, + historyManager: _historyManager, programManager: _programManager, ), ), @@ -116,6 +128,10 @@ class _AppInitializerState extends State { final settings = context.read(); await settings.init(); + // Load HistoryManager session list (independent of WorkoutProvider). + final historyManager = context.read(); + await historyManager.loadSessions(); + // Fire-and-forget analytics in background final api = context.read(); api.sendHeartbeat(); diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index e6fb57e..eea2168 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -225,6 +225,8 @@ class WorkoutSession { final List exercises; final int duration; // minutes final String? notes; + /// Non-null when this session was successfully synced to Health Connect. + final DateTime? hcSyncedAt; WorkoutSession({ required this.id, @@ -233,6 +235,7 @@ class WorkoutSession { required this.exercises, required this.duration, this.notes, + this.hcSyncedAt, }); double get totalVolume => @@ -245,6 +248,7 @@ class WorkoutSession { 'exercises': exercises.map((e) => e.toJson()).toList(), 'duration': duration, 'notes': notes, + 'hcSyncedAt': hcSyncedAt?.toIso8601String(), }; factory WorkoutSession.fromJson(Map json) => WorkoutSession( @@ -256,6 +260,9 @@ class WorkoutSession { .toList(), duration: json['duration'], notes: json['notes'], + hcSyncedAt: json['hcSyncedAt'] != null + ? DateTime.parse(json['hcSyncedAt'] as String) + : null, ); WorkoutSession copyWith({ @@ -265,6 +272,7 @@ class WorkoutSession { Object? exercises = _sentinel, Object? duration = _sentinel, Object? notes = _sentinel, + Object? hcSyncedAt = _sentinel, }) => WorkoutSession( id: id == _sentinel ? this.id : id as String, date: date == _sentinel ? this.date : date as DateTime, @@ -274,6 +282,9 @@ class WorkoutSession { : exercises as List, duration: duration == _sentinel ? this.duration : duration as int, notes: notes == _sentinel ? this.notes : notes as String?, + hcSyncedAt: hcSyncedAt == _sentinel + ? this.hcSyncedAt + : hcSyncedAt as DateTime?, ); } diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index cbb2b57..3868f67 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -6,22 +6,29 @@ import 'package:intl/intl.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; +import '../services/managers/history_manager.dart'; +import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'edit_workout_session_screen.dart'; +// Teal color shared by the HC badge and sync status indicators. +const Color _hcColor = Color(0xFF00BFA5); + class HistoryScreen extends StatelessWidget { const HistoryScreen({super.key}); @override Widget build(BuildContext context) { - final provider = context.watch(); - final sessions = provider.sessions; + // Watch HistoryManager so the list rebuilds when hcSyncedAt changes. + final historyManager = context.watch(); + final provider = context.read(); + final sessions = historyManager.sessions; return Scaffold( appBar: AppBar(title: const Text('Workout History')), body: sessions.isEmpty ? _buildEmptyState(context) - : _buildSessionList(context, sessions, provider), + : _buildSessionList(context, sessions, provider, historyManager), ); } @@ -50,6 +57,7 @@ class HistoryScreen extends StatelessWidget { BuildContext context, List sessions, WorkoutProvider provider, + HistoryManager historyManager, ) { // Group sessions by month final groupedSessions = >{}; @@ -80,7 +88,11 @@ class HistoryScreen extends StatelessWidget { ), ), ...monthSessions.map( - (session) => _SessionCard(session: session, provider: provider), + (session) => _SessionCard( + session: session, + provider: provider, + historyManager: historyManager, + ), ), ], ); @@ -92,13 +104,21 @@ class HistoryScreen extends StatelessWidget { class _SessionCard extends StatelessWidget { final WorkoutSession session; final WorkoutProvider provider; + final HistoryManager historyManager; - const _SessionCard({required this.session, required this.provider}); + const _SessionCard({ + required this.session, + required this.provider, + required this.historyManager, + }); @override Widget build(BuildContext context) { final dateFormat = DateFormat('EEEE, MMM d'); final timeFormat = DateFormat('h:mm a'); + final settings = context.watch(); + final isSynced = session.hcSyncedAt != null; + final showSyncOption = !isSynced && settings.healthConnectEnabled; return Card( margin: const EdgeInsets.only(bottom: AppSpacing.md), @@ -110,16 +130,31 @@ class _SessionCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // ── Header row ──────────────────────────────────────── Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - dateFormat.format(session.date), - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, + Expanded( + child: Text( + dateFormat.format(session.date), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), ), ), + // HC synced badge + if (isSynced) + Tooltip( + message: 'Synced to Health Connect', + child: Padding( + padding: const EdgeInsets.only(right: 6), + child: Icon( + Icons.monitor_heart, + color: _hcColor, + size: 16, + ), + ), + ), Text( timeFormat.format(session.date), style: const TextStyle( @@ -127,6 +162,14 @@ class _SessionCard extends StatelessWidget { fontSize: 12, ), ), + // ⋮ popup menu + _SessionMenu( + session: session, + provider: provider, + historyManager: historyManager, + showSyncOption: showSyncOption, + onDetailRequested: () => _showSessionDetails(context), + ), ], ), const SizedBox(height: AppSpacing.sm), @@ -211,6 +254,7 @@ class _SessionCard extends StatelessWidget { builder: (context, scrollController) => _SessionDetailsSheet( session: session, provider: provider, + historyManager: historyManager, scrollController: scrollController, ), ), @@ -218,14 +262,176 @@ class _SessionCard extends StatelessWidget { } } +// ── 3-button popup menu ──────────────────────────────────────────────────────── + +enum _SessionMenuAction { edit, syncHc, delete } + +class _SessionMenu extends StatelessWidget { + final WorkoutSession session; + final WorkoutProvider provider; + final HistoryManager historyManager; + final bool showSyncOption; + final VoidCallback onDetailRequested; + + const _SessionMenu({ + required this.session, + required this.provider, + required this.historyManager, + required this.showSyncOption, + required this.onDetailRequested, + }); + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_SessionMenuAction>( + icon: const Icon(Icons.more_vert, color: AppTheme.textSecondary, size: 20), + color: AppTheme.cardColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: (action) => _handleAction(context, action), + itemBuilder: (_) => [ + const PopupMenuItem( + value: _SessionMenuAction.edit, + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.edit_outlined, color: AppTheme.primaryColor), + title: Text('Edit', style: TextStyle(color: AppTheme.textPrimary)), + ), + ), + if (showSyncOption) + const PopupMenuItem( + value: _SessionMenuAction.syncHc, + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.monitor_heart_outlined, color: _hcColor), + title: Text( + 'Sync to Health Connect', + style: TextStyle(color: AppTheme.textPrimary), + ), + ), + ), + const PopupMenuItem( + value: _SessionMenuAction.delete, + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.delete_outline, color: AppTheme.error), + title: Text( + 'Delete', + style: TextStyle(color: AppTheme.error), + ), + ), + ), + ], + ); + } + + void _handleAction(BuildContext context, _SessionMenuAction action) { + switch (action) { + case _SessionMenuAction.edit: + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EditWorkoutSessionScreen(session: session), + ), + ); + case _SessionMenuAction.syncHc: + historyManager.syncSession(session); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + SizedBox(width: 12), + Text('Syncing to Health Connect…'), + ], + ), + backgroundColor: AppTheme.cardColor, + duration: Duration(seconds: 2), + ), + ); + case _SessionMenuAction.delete: + _confirmDelete(context); + } + } + + Future _confirmDelete(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.cardColor, + title: const Text('Delete Workout?'), + content: Text( + 'Are you sure you want to delete this workout from ' + '${DateFormat('MMMM d, yyyy').format(session.date)}? ' + 'This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: AppTheme.error), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + final messenger = ScaffoldMessenger.of(context); + try { + await provider.deleteWorkoutSession(session.id); + if (context.mounted) { + messenger.showSnackBar( + const SnackBar( + content: Row( + children: [ + Icon(Icons.check_circle, color: AppTheme.success), + SizedBox(width: 8), + Text('Workout deleted'), + ], + ), + backgroundColor: AppTheme.cardColor, + ), + ); + } + } catch (e) { + debugPrint('Failed to delete workout session: $e'); + if (context.mounted) { + messenger.showSnackBar( + const SnackBar( + content: Text('Failed to delete workout. Please try again.'), + backgroundColor: AppTheme.error, + ), + ); + } + } + } + } +} + +// ── Detail bottom sheet ──────────────────────────────────────────────────────── + class _SessionDetailsSheet extends StatelessWidget { final WorkoutSession session; final WorkoutProvider provider; + final HistoryManager historyManager; final ScrollController scrollController; const _SessionDetailsSheet({ required this.session, required this.provider, + required this.historyManager, required this.scrollController, }); @@ -278,13 +484,30 @@ class _SessionDetailsSheet extends StatelessWidget { const SizedBox(height: AppSpacing.sm), // Header - Text( - dateFormat.format(session.date), - style: Theme.of(context).textTheme.titleLarge, - ), - Text( - '${timeFormat.format(session.date)} • ${session.duration} minutes', - style: Theme.of(context).textTheme.bodyMedium, + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dateFormat.format(session.date), + style: Theme.of(context).textTheme.titleLarge, + ), + Text( + '${timeFormat.format(session.date)} • ${session.duration} minutes', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + if (session.hcSyncedAt != null) + Tooltip( + message: + 'Synced to Health Connect\n${DateFormat('MMM d, h:mm a').format(session.hcSyncedAt!)}', + child: const Icon(Icons.monitor_heart, color: _hcColor, size: 20), + ), + ], ), const SizedBox(height: AppSpacing.lg), @@ -339,9 +562,8 @@ class _SessionDetailsSheet extends StatelessWidget { } void _editSession(BuildContext context) { - // Capture navigator before pop to avoid using deactivated context final navigator = Navigator.of(context); - navigator.pop(); // Close the bottom sheet first + navigator.pop(); navigator.push( MaterialPageRoute( builder: (context) => EditWorkoutSessionScreen(session: session), @@ -378,8 +600,7 @@ class _SessionDetailsSheet extends StatelessWidget { try { await provider.deleteWorkoutSession(session.id); if (context.mounted) { - // Ideally we can trust navigator if it's still valid, but keep check for safety - navigator.pop(); // Close the bottom sheet + navigator.pop(); messenger.showSnackBar( const SnackBar( content: Row( diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart index abad585..b5d0299 100644 --- a/workout-logger/lib/services/health_connect_service.dart +++ b/workout-logger/lib/services/health_connect_service.dart @@ -53,6 +53,8 @@ class HealthConnectService implements IHealthConnectService { 'overhead_tricep_extension': ExerciseSegmentType.doubleArmTricepsExtension, 'plank': ExerciseSegmentType.plank, 'crunches': ExerciseSegmentType.crunch, + 'cable_crunch': ExerciseSegmentType.crunch, + 'russian_twist': ExerciseSegmentType.crunch, 'leg_raises': ExerciseSegmentType.legRaise, }; @@ -121,40 +123,91 @@ class HealthConnectService implements IHealthConnectService { } } - // Builds non-overlapping ExerciseSessionSegmentEvents distributed evenly - // across the session time window. Each set becomes one segment. + // Builds non-overlapping ExerciseSessionSegmentEvents using per-set timestamps + // from WorkoutSet.timestamp. Sets are sorted by their recorded timestamp so + // that each segment's startTime reflects when the set was actually performed. + // + // Fallback: if all timestamps are identical (e.g. fabricated via DateTime.now() + // default on legacy data) the method falls back to the original evenly-spaced + // distribution so callers always receive valid, non-empty output. List _buildSegments( WorkoutSession session, DateTime sessionStart, DateTime sessionEnd, ) { - final allSets = <(ExerciseSegmentType, int)>[]; + // Collect (segmentType, reps, timestamp) for every valid set. + final allSets = <(ExerciseSegmentType, int, DateTime)>[]; for (final log in session.exercises) { - final type = _segmentTypeMap[log.exerciseId] ?? ExerciseSegmentType.otherWorkout; + final type = + _segmentTypeMap[log.exerciseId] ?? ExerciseSegmentType.otherWorkout; for (final set in log.sets) { if (set.reps > 0) { - allSets.add((type, set.reps)); + allSets.add((type, set.reps, set.timestamp)); } } } if (allSets.isEmpty) return []; - final totalMs = sessionEnd.difference(sessionStart).inMilliseconds; - final slotMs = totalMs ~/ allSets.length; + // Sort by recorded timestamp so segments follow real workout order. + allSets.sort((a, b) => a.$3.compareTo(b.$3)); + + // Check whether timestamps are meaningful: if every set has the exact same + // timestamp it almost certainly means they were created with DateTime.now() + // at the same instant (legacy data or unit-test stubs). In that case fall + // back to the original even-distribution algorithm. + final allSameTimestamp = allSets.every((s) => s.$3 == allSets.first.$3); + if (allSameTimestamp) { + final totalMs = sessionEnd.difference(sessionStart).inMilliseconds; + final slotMs = totalMs ~/ allSets.length; + return List.generate(allSets.length, (i) { + final start = sessionStart.add(Duration(milliseconds: slotMs * i)); + final end = i < allSets.length - 1 + ? sessionStart.add(Duration(milliseconds: slotMs * (i + 1))) + : sessionEnd; + return ExerciseSessionSegmentEvent( + startTime: start, + endTime: end, + segmentType: allSets[i].$1, + repetitions: allSets[i].$2, + ); + }); + } - return List.generate(allSets.length, (i) { - final start = sessionStart.add(Duration(milliseconds: slotMs * i)); - final end = i < allSets.length - 1 - ? sessionStart.add(Duration(milliseconds: slotMs * (i + 1))) - : sessionEnd; - return ExerciseSessionSegmentEvent( + // Build segments using real timestamps. + // Each segment's startTime is the set's timestamp clamped to [sessionStart, sessionEnd]. + // endTime is the next set's timestamp (or sessionEnd for the last set), + // clamped so start <= end <= sessionEnd. + final segments = []; + for (var i = 0; i < allSets.length; i++) { + final rawStart = allSets[i].$3; + final start = rawStart.isBefore(sessionStart) + ? sessionStart + : rawStart.isAfter(sessionEnd) + ? sessionEnd + : rawStart; + + final DateTime rawEnd; + if (i < allSets.length - 1) { + rawEnd = allSets[i + 1].$3; + } else { + rawEnd = sessionEnd; + } + // Clamp: end must be >= start and <= sessionEnd. + final end = rawEnd.isBefore(start) + ? start + : rawEnd.isAfter(sessionEnd) + ? sessionEnd + : rawEnd; + + segments.add(ExerciseSessionSegmentEvent( startTime: start, endTime: end, segmentType: allSets[i].$1, repetitions: allSets[i].$2, - ); - }); + )); + } + return segments; } } diff --git a/workout-logger/lib/services/interfaces/health_sync_manager_interface.dart b/workout-logger/lib/services/interfaces/health_sync_manager_interface.dart new file mode 100644 index 0000000..628e025 --- /dev/null +++ b/workout-logger/lib/services/interfaces/health_sync_manager_interface.dart @@ -0,0 +1,23 @@ +// Health Sync Manager Interface (Dependency Inversion Principle) +// +// Abstracts the orchestration of Health Connect sync after a session is saved. +// WorkoutProvider and HistoryManager depend on this abstraction, not the +// concrete HealthSyncManager, so the sync implementation can be swapped or +// mocked in tests without touching callers. + +import '../../models/models.dart'; + +/// Contract for post-session Health Connect sync orchestration. +abstract class IHealthSyncManager { + /// Schedules a best-effort HC sync for [session]. + /// + /// - [routineName] is forwarded as the HC exercise session title. + /// - [onSynced] is called with the updated session (hcSyncedAt set) if the + /// sync succeeds. It is never called on failure. + /// - This method is fire-and-forget: it never throws and does not block. + void syncSession( + WorkoutSession session, { + String? routineName, + void Function(WorkoutSession updated)? onSynced, + }); +} diff --git a/workout-logger/lib/services/interfaces/interfaces.dart b/workout-logger/lib/services/interfaces/interfaces.dart index 8aa9a2d..55819d6 100644 --- a/workout-logger/lib/services/interfaces/interfaces.dart +++ b/workout-logger/lib/services/interfaces/interfaces.dart @@ -7,3 +7,4 @@ export 'storage_service_interface.dart'; export 'ml_service_interface.dart'; export 'health_connect_service_interface.dart'; +export 'health_sync_manager_interface.dart'; diff --git a/workout-logger/lib/services/managers/health_sync_manager.dart b/workout-logger/lib/services/managers/health_sync_manager.dart new file mode 100644 index 0000000..6f84383 --- /dev/null +++ b/workout-logger/lib/services/managers/health_sync_manager.dart @@ -0,0 +1,48 @@ +// Health Sync Manager (Single Responsibility Principle) +// +// Responsible ONLY for orchestrating Health Connect sync after a workout session +// is saved. It: +// - Checks the user's HC-enabled setting via SettingsProvider (in-memory, sync). +// - Calls IHealthConnectService.syncWorkoutSession in a fire-and-forget pattern. +// - On success, invokes the optional onSynced callback with the updated session. +// - Swallows all errors so callers are never affected. + +import 'package:flutter/foundation.dart' show debugPrint; + +import '../../models/models.dart'; +import '../interfaces/health_connect_service_interface.dart'; +import '../interfaces/health_sync_manager_interface.dart'; +import '../settings_provider.dart'; + +/// Orchestrates Health Connect sync after a workout session is saved. +/// +/// Following Single Responsibility Principle: this class only manages +/// the HC sync concern. History persistence and active workout state +/// are handled by other managers. +class HealthSyncManager implements IHealthSyncManager { + final IHealthConnectService _hc; + final SettingsProvider _settings; + + HealthSyncManager(this._hc, this._settings); + + @override + void syncSession( + WorkoutSession session, { + String? routineName, + void Function(WorkoutSession updated)? onSynced, + }) { + // Synchronous in-memory flag check — no I/O, no await. + if (!_settings.healthConnectEnabled) return; + + _hc + .syncWorkoutSession(session, title: routineName) + .then((success) { + if (success) { + onSynced?.call(session.copyWith(hcSyncedAt: DateTime.now())); + } + }) + .catchError((Object e) { + debugPrint('HealthSyncManager: sync error: $e'); + }); + } +} diff --git a/workout-logger/lib/services/managers/history_manager.dart b/workout-logger/lib/services/managers/history_manager.dart index a55e77f..e9ed740 100644 --- a/workout-logger/lib/services/managers/history_manager.dart +++ b/workout-logger/lib/services/managers/history_manager.dart @@ -5,12 +5,15 @@ // - Loading/saving workout sessions // - Updating and deleting sessions // - Querying session history +// - Triggering Health Connect sync after a new session is persisted +// (via the optional IHealthSyncManager dependency). // // 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'; +import '../interfaces/health_sync_manager_interface.dart'; /// Manages workout session history. /// @@ -18,13 +21,18 @@ import '../interfaces/storage_service_interface.dart'; /// historical session data, not active workouts or analytics. class HistoryManager extends ChangeNotifier { final IStorageService _storage; + final IHealthSyncManager? _healthSync; List _sessions = []; // Callback for when sessions change (to notify other managers like AnalyticsManager) final void Function(Set affectedExerciseIds)? onSessionsChanged; - HistoryManager(this._storage, {this.onSessionsChanged}); + HistoryManager( + this._storage, { + IHealthSyncManager? healthSyncManager, + this.onSessionsChanged, + }) : _healthSync = healthSyncManager; // Getters List get sessions => List.unmodifiable(_sessions); @@ -38,14 +46,52 @@ class HistoryManager extends ChangeNotifier { notifyListeners(); } - /// Add a new session to history + /// Add a new session to history. /// - /// Persists the session to storage and updates in-memory state. - Future addSession(WorkoutSession session) async { + /// Persists the session, updates in-memory state, then triggers a + /// best-effort Health Connect sync if [healthSyncManager] is provided. + /// [routineName] is forwarded as the HC exercise session title. + Future addSession( + WorkoutSession session, { + String? routineName, + }) async { _sessions.insert(0, session); + await _storage.saveWorkoutSession(session); + final exerciseIds = session.exercises.map((e) => e.exerciseId).toSet(); onSessionsChanged?.call(exerciseIds); - await _storage.saveWorkoutSession(session); + notifyListeners(); + + // Fire-and-forget HC sync — runs after UI is already updated. + _healthSync?.syncSession( + session, + routineName: routineName, + onSynced: _onHcSynced, + ); + } + + /// Manually trigger a Health Connect sync for an existing session. + /// + /// Called from the ⋮ menu in the history UI when a session is unsynced. + /// No-op when [healthSyncManager] was not provided. + void syncSession(WorkoutSession session, {String? routineName}) { + _healthSync?.syncSession( + session, + routineName: routineName, + onSynced: _onHcSynced, + ); + } + + // Called by HealthSyncManager on successful sync. + // Patches the in-memory session and re-persists it. + void _onHcSynced(WorkoutSession updated) { + final index = _sessions.indexWhere((s) => s.id == updated.id); + if (index == -1) return; + _sessions[index] = updated; + // Re-persist so hcSyncedAt survives app restarts. + _storage.saveWorkoutSession(updated).catchError((Object e) { + debugPrint('HistoryManager: failed to persist hcSyncedAt: $e'); + }); notifyListeners(); } diff --git a/workout-logger/lib/services/managers/managers.dart b/workout-logger/lib/services/managers/managers.dart index 749c0d9..8a6aed8 100644 --- a/workout-logger/lib/services/managers/managers.dart +++ b/workout-logger/lib/services/managers/managers.dart @@ -16,3 +16,4 @@ export 'exercise_manager.dart'; export 'target_manager.dart'; export 'analytics_manager.dart'; export 'program_manager.dart'; +export 'health_sync_manager.dart'; diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index a8d274a..917e8ed 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -22,10 +22,10 @@ import '../models/models.dart'; import '../data/exercise_database.dart'; import 'interfaces/storage_service_interface.dart'; import 'interfaces/ml_service_interface.dart'; -import 'interfaces/health_connect_service_interface.dart'; import 'ml_service.dart'; import 'strategies/target_calculator.dart'; import 'managers/program_manager.dart'; +import 'managers/history_manager.dart'; import 'utils/exercise_history.dart'; enum StartWorkoutConflictAction { resume, discardAndStart, cancel } @@ -37,7 +37,7 @@ class WorkoutInProgressError extends StateError { class WorkoutProvider extends ChangeNotifier { final IStorageService _storage; final IMLService _mlService; - final IHealthConnectService? _healthConnect; + final HistoryManager? _historyManager; final Uuid _uuid = const Uuid(); // State @@ -85,13 +85,15 @@ class WorkoutProvider extends ChangeNotifier { /// Following Dependency Inversion Principle: accepts abstractions /// rather than concrete implementations. [programManager] defaults to a /// new ProgramManager backed by the same storage if not provided. + /// [historyManager] is optional; when provided, session persistence and + /// Health Connect sync are delegated to it rather than handled inline. WorkoutProvider( this._storage, { IMLService? mlService, - IHealthConnectService? healthConnectService, + HistoryManager? historyManager, required this.programManager, }) : _mlService = mlService ?? MLService(), - _healthConnect = healthConnectService; + _historyManager = historyManager; // ==================== INITIALIZATION ==================== @@ -586,28 +588,19 @@ class WorkoutProvider extends ChangeNotifier { notes: notes, ); - await _storage.saveWorkoutSession(session); + if (_historyManager != null) { + // Delegate persistence + HC sync to HistoryManager. + await _historyManager.addSession( + session, + routineName: _activeRoutine?.name, + ); + } else { + // Fallback: persist directly (no HC sync) when historyManager is absent. + await _storage.saveWorkoutSession(session); + } await _clearDraft(); _sessions.insert(0, session); - // Best-effort Health Connect sync — errors must never abort finishWorkout. - try { - final hcEnabled = await _storage.getSetting('healthConnectEnabled'); - if (hcEnabled == 'true' && _healthConnect != null) { - _healthConnect - .syncWorkoutSession( - session, - title: _activeRoutine?.name, - ) - .catchError((e) { - debugPrint('Health Connect sync error: $e'); - return false; - }); - } - } catch (e) { - debugPrint('Health Connect pre-sync error (setting read failed): $e'); - } - // Update growth models for performed exercises for (var log in completedExercises) { await _updateGrowthModel(log.exerciseId); diff --git a/workout-logger/test/health_sync_manager_test.dart b/workout-logger/test/health_sync_manager_test.dart new file mode 100644 index 0000000..c1c9750 --- /dev/null +++ b/workout-logger/test/health_sync_manager_test.dart @@ -0,0 +1,136 @@ +// Unit tests for HealthSyncManager + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/managers/health_sync_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'test_utils/mock_storage_service.dart'; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +class _MockHcService implements IHealthConnectService { + int syncCallCount = 0; + WorkoutSession? lastSession; + String? lastTitle; + bool returnValue; + bool shouldThrow; + + _MockHcService({this.returnValue = true, this.shouldThrow = false}); + + @override + Future isAvailable() async => true; + + @override + Future requestPermissions() async => true; + + @override + Future hasPermissions() async => true; + + @override + Future syncWorkoutSession( + WorkoutSession session, { + String? title, + }) async { + if (shouldThrow) throw Exception('mock HC error'); + syncCallCount++; + lastSession = session; + lastTitle = title; + return returnValue; + } +} + +WorkoutSession _makeSession({String id = 'session_1'}) => WorkoutSession( + id: id, + date: DateTime(2026, 5, 1, 10), + exercises: [], + duration: 45, +); + +// ── Tests ────────────────────────────────────────────────────────────────────── + +void main() { + group('HealthSyncManager', () { + late MockStorageService storage; + late SettingsProvider settings; + late _MockHcService hc; + + setUp(() async { + storage = MockStorageService(); + settings = SettingsProvider(storage); + await settings.init(); // healthConnectEnabled defaults to false + hc = _MockHcService(); + }); + + test('does nothing when healthConnectEnabled is false', () async { + final manager = HealthSyncManager(hc, settings); + manager.syncSession(_makeSession()); + + // Give the async chain time to settle + await Future.delayed(Duration.zero); + + expect(hc.syncCallCount, 0); + }); + + test('calls syncWorkoutSession when enabled', () async { + await settings.setHealthConnectEnabled(true); + final manager = HealthSyncManager(hc, settings); + final session = _makeSession(); + + manager.syncSession(session, routineName: 'Push Day'); + await Future.delayed(Duration.zero); + + expect(hc.syncCallCount, 1); + expect(hc.lastSession?.id, session.id); + expect(hc.lastTitle, 'Push Day'); + }); + + test('calls onSynced with updated session when sync succeeds', () async { + await settings.setHealthConnectEnabled(true); + hc = _MockHcService(returnValue: true); + final manager = HealthSyncManager(hc, settings); + + WorkoutSession? syncedSession; + manager.syncSession( + _makeSession(), + onSynced: (s) => syncedSession = s, + ); + await Future.delayed(Duration.zero); + + expect(syncedSession, isNotNull); + expect(syncedSession!.hcSyncedAt, isNotNull); + }); + + test('does NOT call onSynced when sync returns false', () async { + await settings.setHealthConnectEnabled(true); + hc = _MockHcService(returnValue: false); + final manager = HealthSyncManager(hc, settings); + + WorkoutSession? syncedSession; + manager.syncSession( + _makeSession(), + onSynced: (s) => syncedSession = s, + ); + await Future.delayed(Duration.zero); + + expect(syncedSession, isNull); + }); + + test('does not rethrow when HC throws', () async { + await settings.setHealthConnectEnabled(true); + hc = _MockHcService(shouldThrow: true); + final manager = HealthSyncManager(hc, settings); + + // Must complete without throwing + expect( + () async { + manager.syncSession(_makeSession()); + await Future.delayed(Duration.zero); + }, + returnsNormally, + ); + }); + }); +} From f1808cddbfc601f87033ef41857b061459b3a8b1 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy@users.noreply.github.com> Date: Fri, 1 May 2026 21:49:32 +0530 Subject: [PATCH 05/10] Update workout-logger/test/health_sync_manager_test.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- workout-logger/test/health_sync_manager_test.dart | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/workout-logger/test/health_sync_manager_test.dart b/workout-logger/test/health_sync_manager_test.dart index c1c9750..f705798 100644 --- a/workout-logger/test/health_sync_manager_test.dart +++ b/workout-logger/test/health_sync_manager_test.dart @@ -123,14 +123,10 @@ void main() { hc = _MockHcService(shouldThrow: true); final manager = HealthSyncManager(hc, settings); - // Must complete without throwing - expect( - () async { - manager.syncSession(_makeSession()); - await Future.delayed(Duration.zero); - }, - returnsNormally, - ); + // Must complete without throwing — just run and let test fail on exception. + manager.syncSession(_makeSession()); + await Future.delayed(Duration.zero); + // If we reach here, no exception propagated. }); }); } From af29b299b7d8f8306cfa74262f090f5a7dffb132 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 2 May 2026 00:16:18 +0530 Subject: [PATCH 06/10] feat: synchronize HistoryManager cache with WorkoutProvider session updates --- .github/workflows/test.yml | 7 +- .../lib/screens/workout_flow_screen.dart | 2 +- .../services/managers/history_manager.dart | 21 ++ .../lib/services/workout_provider.dart | 6 + .../test/health_sync_manager_test.dart | 1 - workout-logger/test/history_manager_test.dart | 293 ++++++++++++++++++ 6 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 workout-logger/test/history_manager_test.dart diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8303fc..08ce89b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,4 +46,9 @@ jobs: - name: Run tests working-directory: ./workout-logger - run: flutter test + run: flutter test --coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: workout-logger/coverage/lcov.info diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 2890b3d..d2c9f22 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -1587,7 +1587,7 @@ class _WorkoutFlowScreenState extends State { await context.read().cancelWorkout(); if (!mounted) return; Navigator.pop(dialogContext); // Close dialog - Navigator.pop(this.context); // Close workout screen + Navigator.pop(context); // Close workout screen }, child: const Text( 'Cancel Workout', diff --git a/workout-logger/lib/services/managers/history_manager.dart b/workout-logger/lib/services/managers/history_manager.dart index e9ed740..710f0b6 100644 --- a/workout-logger/lib/services/managers/history_manager.dart +++ b/workout-logger/lib/services/managers/history_manager.dart @@ -186,6 +186,27 @@ class HistoryManager extends ChangeNotifier { notifyListeners(); } + // ── Cache-only mutations (no storage I/O) ───────────────────────────────── + // Called by WorkoutProvider after it has already handled storage, so that + // HistoryManager's in-memory list stays in sync without a double-write. + + /// Remove a session from the in-memory list without touching storage. + void evictSession(String sessionId) { + final index = _sessions.indexWhere((s) => s.id == sessionId); + if (index == -1) return; + _sessions = List.from(_sessions)..removeAt(index); + notifyListeners(); + } + + /// Replace a session in the in-memory list without touching storage. + void patchSession(WorkoutSession updated) { + final index = _sessions.indexWhere((s) => s.id == updated.id); + if (index == -1) return; + _sessions = List.from(_sessions)..[index] = updated; + _sessions.sort((a, b) => b.date.compareTo(a.date)); + notifyListeners(); + } + /// Get last session containing a specific exercise ExerciseLog? getLastSessionForExercise(String exerciseId) { for (var session in _sessions) { diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 917e8ed..408d754 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -684,6 +684,9 @@ class WorkoutProvider extends ChangeNotifier { // Remove from local list _sessions = List.from(_sessions)..removeWhere((s) => s.id == sessionId); + // Keep HistoryManager's cache in sync so HistoryScreen rebuilds. + _historyManager?.evictSession(sessionId); + // Retrain growth models for all affected exercises // (their data has changed because a session was removed) for (var exerciseId in affectedExerciseIds) { @@ -728,6 +731,9 @@ class WorkoutProvider extends ChangeNotifier { // Sort sessions by date (most recent first) _sessions.sort((a, b) => b.date.compareTo(a.date)); + // Keep HistoryManager's cache in sync so HistoryScreen rebuilds. + _historyManager?.patchSession(updatedSession); + // Retrain growth models for ALL affected exercises // (both exercises that were in the old session and exercises in the new session) for (var exerciseId in allAffectedExerciseIds) { diff --git a/workout-logger/test/health_sync_manager_test.dart b/workout-logger/test/health_sync_manager_test.dart index f705798..9597f8d 100644 --- a/workout-logger/test/health_sync_manager_test.dart +++ b/workout-logger/test/health_sync_manager_test.dart @@ -1,6 +1,5 @@ // Unit tests for HealthSyncManager -import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:repforge/models/models.dart'; diff --git a/workout-logger/test/history_manager_test.dart b/workout-logger/test/history_manager_test.dart new file mode 100644 index 0000000..d8ed53b --- /dev/null +++ b/workout-logger/test/history_manager_test.dart @@ -0,0 +1,293 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/managers/history_manager.dart'; +import 'package:repforge/services/interfaces/health_sync_manager_interface.dart'; +import 'test_utils/mock_storage_service.dart'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +WorkoutSession _session({ + String id = 's1', + DateTime? date, + List exercises = const [], +}) => + WorkoutSession( + id: id, + date: date ?? DateTime(2026, 5, 1, 10), + exercises: exercises, + duration: 30, + ); + +ExerciseLog _log(String exerciseId) => ExerciseLog( + exerciseId: exerciseId, + sets: [WorkoutSet(weight: 60, reps: 10)], + ); + +// Stub that records calls +class _StubSync implements IHealthSyncManager { + final List synced = []; + void Function(WorkoutSession updated)? capturedOnSynced; + + @override + void syncSession( + WorkoutSession session, { + String? routineName, + void Function(WorkoutSession updated)? onSynced, + }) { + synced.add(session); + capturedOnSynced = onSynced; + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +void main() { + late MockStorageService storage; + late HistoryManager manager; + + setUp(() { + storage = MockStorageService(); + manager = HistoryManager(storage); + }); + + group('loadSessions', () { + test('populates sessions from storage sorted newest-first', () async { + final older = _session(id: 'old', date: DateTime(2026, 1, 1)); + final newer = _session(id: 'new', date: DateTime(2026, 4, 1)); + storage.addMockSession(older); + storage.addMockSession(newer); + + await manager.loadSessions(); + + expect(manager.sessions.map((s) => s.id), ['new', 'old']); + }); + + test('notifies listeners', () async { + var notified = false; + manager.addListener(() => notified = true); + + await manager.loadSessions(); + + expect(notified, isTrue); + }); + }); + + group('addSession', () { + test('inserts session at head and persists to storage', () async { + final s = _session(); + await manager.addSession(s); + + expect(manager.sessions.first.id, s.id); + expect(storage.sessions.any((x) => x.id == s.id), isTrue); + }); + + test('calls onSessionsChanged with affected exercise IDs', () async { + Set? reported; + manager = HistoryManager(storage, onSessionsChanged: (ids) => reported = ids); + + final s = _session(exercises: [_log('bench_press'), _log('squat')]); + await manager.addSession(s); + + expect(reported, {'bench_press', 'squat'}); + }); + + test('fires HC sync when healthSyncManager is provided', () async { + final stub = _StubSync(); + manager = HistoryManager(storage, healthSyncManager: stub); + final s = _session(); + + await manager.addSession(s, routineName: 'Push Day'); + + expect(stub.synced.length, 1); + expect(stub.synced.first.id, s.id); + }); + + test('does not fire HC sync without healthSyncManager', () async { + // No exception and sessions list is populated — coverage of null path. + final s = _session(); + await manager.addSession(s); + expect(manager.sessions.length, 1); + }); + }); + + group('deleteSession', () { + test('removes session from memory and storage', () async { + final s = _session(); + await manager.addSession(s); + + await manager.deleteSession(s.id); + + expect(manager.sessions, isEmpty); + expect(storage.sessions.any((x) => x.id == s.id), isFalse); + }); + + test('calls onSessionsChanged with affected exercise IDs', () async { + Set? reported; + manager = HistoryManager(storage, onSessionsChanged: (ids) => reported = ids); + + final s = _session(exercises: [_log('deadlift')]); + await manager.addSession(s); + reported = null; // reset after addSession + + await manager.deleteSession(s.id); + + expect(reported, {'deadlift'}); + }); + + test('is a no-op for unknown session ID', () async { + final s = _session(); + await manager.addSession(s); + + // Should not throw + await manager.deleteSession('does_not_exist'); + expect(manager.sessions.length, 1); + }); + }); + + group('updateSession', () { + test('replaces session in memory and storage', () async { + final original = _session(exercises: [_log('squat')]); + await manager.addSession(original); + + final updated = original.copyWith(notes: 'felt strong'); + await manager.updateSession(updated); + + expect(manager.sessions.first.notes, 'felt strong'); + expect(storage.sessions.first.notes, 'felt strong'); + }); + + test('preserves sort order after date change', () async { + final s1 = _session(id: 's1', date: DateTime(2026, 3, 1)); + final s2 = _session(id: 's2', date: DateTime(2026, 4, 1)); + await manager.addSession(s1); + await manager.addSession(s2); + + // Move s1 to be the most recent + final movedS1 = s1.copyWith(date: DateTime(2026, 5, 1)); + await manager.updateSession(movedS1); + + expect(manager.sessions.first.id, 's1'); + }); + + test('throws StateError for unknown session ID', () async { + await expectLater( + () => manager.updateSession(_session(id: 'ghost')), + throwsStateError, + ); + }); + }); + + group('evictSession (cache-only, no storage I/O)', () { + test('removes session from memory without touching storage', () async { + final s = _session(); + storage.addMockSession(s); + await manager.loadSessions(); // prime the cache + + manager.evictSession(s.id); + + expect(manager.sessions, isEmpty); + // Storage still has it — evict is cache-only + expect(storage.sessions.any((x) => x.id == s.id), isTrue); + }); + + test('notifies listeners', () async { + final s = _session(); + await manager.addSession(s); + + var notified = false; + manager.addListener(() => notified = true); + manager.evictSession(s.id); + + expect(notified, isTrue); + }); + + test('is a no-op for unknown ID', () async { + final s = _session(); + await manager.addSession(s); + + // Should not throw + manager.evictSession('ghost'); + expect(manager.sessions.length, 1); + }); + }); + + group('patchSession (cache-only, no storage I/O)', () { + test('updates session in memory without touching storage', () async { + final s = _session(); + await manager.addSession(s); + final original = storage.sessions.first.notes; + + final patched = s.copyWith(notes: 'patched'); + manager.patchSession(patched); + + expect(manager.sessions.first.notes, 'patched'); + // Storage unchanged + expect(storage.sessions.first.notes, original); + }); + + test('notifies listeners', () async { + final s = _session(); + await manager.addSession(s); + + var notified = false; + manager.addListener(() => notified = true); + manager.patchSession(s.copyWith(notes: 'x')); + + expect(notified, isTrue); + }); + + test('is a no-op for unknown ID', () async { + final s = _session(); + await manager.addSession(s); + + // Should not throw + manager.patchSession(_session(id: 'ghost')); + expect(manager.sessions.length, 1); + }); + + test('maintains sort order after date patch', () async { + final s1 = _session(id: 's1', date: DateTime(2026, 3, 1)); + final s2 = _session(id: 's2', date: DateTime(2026, 4, 1)); + await manager.addSession(s1); + await manager.addSession(s2); + // s2 is first (newer) + expect(manager.sessions.first.id, 's2'); + + // Patch s1 to be the newest + manager.patchSession(s1.copyWith(date: DateTime(2026, 5, 1))); + + expect(manager.sessions.first.id, 's1'); + }); + }); + + group('syncSession (manual HC trigger)', () { + test('delegates to IHealthSyncManager', () async { + final stub = _StubSync(); + manager = HistoryManager(storage, healthSyncManager: stub); + final s = _session(); + await manager.addSession(s); + + manager.syncSession(s, routineName: 'Legs'); + + // addSession already called syncSession once; this is the second call + expect(stub.synced.length, 2); + expect(stub.synced.last.id, s.id); + }); + + test('_onHcSynced patches cache and re-persists', () async { + final stub = _StubSync(); + manager = HistoryManager(storage, healthSyncManager: stub); + final s = _session(); + await manager.addSession(s); + + // Simulate the sync completing + final synced = s.copyWith(hcSyncedAt: DateTime(2026, 5, 1, 12)); + stub.capturedOnSynced?.call(synced); + // Give async storage write time to settle + await Future.delayed(Duration.zero); + + expect(manager.sessions.first.hcSyncedAt, isNotNull); + expect(storage.sessions.first.hcSyncedAt, isNotNull); + }); + }); +} From 67658dcd8ac42791519786516e2dba891f2bc946 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 2 May 2026 00:22:00 +0530 Subject: [PATCH 07/10] feat: enhance workout session syncing with improved timestamp handling and fallback logic --- .../lib/services/health_connect_service.dart | 16 ++++++++++------ .../lib/services/workout_provider.dart | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart index b5d0299..35af98f 100644 --- a/workout-logger/lib/services/health_connect_service.dart +++ b/workout-logger/lib/services/health_connect_service.dart @@ -153,12 +153,16 @@ class HealthConnectService implements IHealthConnectService { // Sort by recorded timestamp so segments follow real workout order. allSets.sort((a, b) => a.$3.compareTo(b.$3)); - // Check whether timestamps are meaningful: if every set has the exact same - // timestamp it almost certainly means they were created with DateTime.now() - // at the same instant (legacy data or unit-test stubs). In that case fall - // back to the original even-distribution algorithm. - final allSameTimestamp = allSets.every((s) => s.$3 == allSets.first.$3); - if (allSameTimestamp) { + // Fall back to evenly-spaced distribution whenever timestamps are not fully + // unique. Duplicate timestamps arise when: + // • All sets share the same instant (legacy data / unit-test stubs). + // • Two or more sets were logged within the same DateTime resolution tick + // (common on devices where DateTime.now() resolution is ~1 ms). + // In either case the real-timestamp path would produce overlapping or + // zero-duration segments, which ExerciseSessionRecord's constructor rejects + // with an ArgumentError, silently aborting the sync. + final uniqueTimestamps = allSets.map((s) => s.$3).toSet(); + if (uniqueTimestamps.length < allSets.length) { final totalMs = sessionEnd.difference(sessionStart).inMilliseconds; final slotMs = totalMs ~/ allSets.length; return List.generate(allSets.length, (i) { diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 408d754..79ef6c8 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -592,7 +592,7 @@ class WorkoutProvider extends ChangeNotifier { // Delegate persistence + HC sync to HistoryManager. await _historyManager.addSession( session, - routineName: _activeRoutine?.name, + routineName: _activeRoutine?.name ?? _activeProgramDay?.name, ); } else { // Fallback: persist directly (no HC sync) when historyManager is absent. From 08922a5b0062e31248ab448ba57aa03a4db5d585 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 2 May 2026 00:24:46 +0530 Subject: [PATCH 08/10] feat: update Codecov action to version 5 and ensure token usage --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08ce89b..62890d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,6 +49,7 @@ jobs: run: flutter test --coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: workout-logger/coverage/lcov.info + token: ${{ secrets.CODECOV_TOKEN }} From 4d69d5c15d9c490af1801c59eca49458f4f679e3 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 2 May 2026 00:48:17 +0530 Subject: [PATCH 09/10] feat: improve workout session syncing by clamping timestamps and merging sync data --- .../lib/services/health_connect_service.dart | 68 +++++++++---------- .../services/managers/history_manager.dart | 10 +-- 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart index 35af98f..95529f1 100644 --- a/workout-logger/lib/services/health_connect_service.dart +++ b/workout-logger/lib/services/health_connect_service.dart @@ -153,63 +153,57 @@ class HealthConnectService implements IHealthConnectService { // Sort by recorded timestamp so segments follow real workout order. allSets.sort((a, b) => a.$3.compareTo(b.$3)); - // Fall back to evenly-spaced distribution whenever timestamps are not fully - // unique. Duplicate timestamps arise when: + // Clamp all set timestamps to [sessionStart, sessionEnd] before the + // uniqueness check. Without this, timestamps recorded after the session + // window ends (e.g. the last set logged slightly past the stored duration) + // all collapse to sessionEnd after clamping in the segment-build loop, + // producing zero-duration segments that Health Connect rejects silently. + final clampedSets = allSets + .map((s) { + var ts = s.$3; + if (ts.isBefore(sessionStart)) ts = sessionStart; + if (ts.isAfter(sessionEnd)) ts = sessionEnd; + return (s.$1, s.$2, ts); + }) + .toList(); + + // Fall back to evenly-spaced distribution whenever clamped timestamps are + // not fully unique. Duplicate timestamps arise when: // • All sets share the same instant (legacy data / unit-test stubs). // • Two or more sets were logged within the same DateTime resolution tick // (common on devices where DateTime.now() resolution is ~1 ms). - // In either case the real-timestamp path would produce overlapping or + // • One or more timestamps were clamped to the same boundary value. + // In any of these cases the real-timestamp path would produce overlapping or // zero-duration segments, which ExerciseSessionRecord's constructor rejects // with an ArgumentError, silently aborting the sync. - final uniqueTimestamps = allSets.map((s) => s.$3).toSet(); - if (uniqueTimestamps.length < allSets.length) { + final uniqueTimestamps = clampedSets.map((s) => s.$3).toSet(); + if (uniqueTimestamps.length < clampedSets.length) { final totalMs = sessionEnd.difference(sessionStart).inMilliseconds; - final slotMs = totalMs ~/ allSets.length; - return List.generate(allSets.length, (i) { + final slotMs = totalMs ~/ clampedSets.length; + return List.generate(clampedSets.length, (i) { final start = sessionStart.add(Duration(milliseconds: slotMs * i)); - final end = i < allSets.length - 1 + final end = i < clampedSets.length - 1 ? sessionStart.add(Duration(milliseconds: slotMs * (i + 1))) : sessionEnd; return ExerciseSessionSegmentEvent( startTime: start, endTime: end, - segmentType: allSets[i].$1, - repetitions: allSets[i].$2, + segmentType: clampedSets[i].$1, + repetitions: clampedSets[i].$2, ); }); } - // Build segments using real timestamps. - // Each segment's startTime is the set's timestamp clamped to [sessionStart, sessionEnd]. - // endTime is the next set's timestamp (or sessionEnd for the last set), - // clamped so start <= end <= sessionEnd. + // Build segments using clamped timestamps (already within [sessionStart, sessionEnd]). final segments = []; - for (var i = 0; i < allSets.length; i++) { - final rawStart = allSets[i].$3; - final start = rawStart.isBefore(sessionStart) - ? sessionStart - : rawStart.isAfter(sessionEnd) - ? sessionEnd - : rawStart; - - final DateTime rawEnd; - if (i < allSets.length - 1) { - rawEnd = allSets[i + 1].$3; - } else { - rawEnd = sessionEnd; - } - // Clamp: end must be >= start and <= sessionEnd. - final end = rawEnd.isBefore(start) - ? start - : rawEnd.isAfter(sessionEnd) - ? sessionEnd - : rawEnd; - + for (var i = 0; i < clampedSets.length; i++) { + final start = clampedSets[i].$3; + final end = i < clampedSets.length - 1 ? clampedSets[i + 1].$3 : sessionEnd; segments.add(ExerciseSessionSegmentEvent( startTime: start, endTime: end, - segmentType: allSets[i].$1, - repetitions: allSets[i].$2, + segmentType: clampedSets[i].$1, + repetitions: clampedSets[i].$2, )); } return segments; diff --git a/workout-logger/lib/services/managers/history_manager.dart b/workout-logger/lib/services/managers/history_manager.dart index 710f0b6..90d239e 100644 --- a/workout-logger/lib/services/managers/history_manager.dart +++ b/workout-logger/lib/services/managers/history_manager.dart @@ -83,13 +83,15 @@ class HistoryManager extends ChangeNotifier { } // Called by HealthSyncManager on successful sync. - // Patches the in-memory session and re-persists it. + // Merges only hcSyncedAt into the current in-memory session so that any + // edits made between sync being triggered and this callback firing are not + // overwritten, then re-persists. void _onHcSynced(WorkoutSession updated) { final index = _sessions.indexWhere((s) => s.id == updated.id); if (index == -1) return; - _sessions[index] = updated; - // Re-persist so hcSyncedAt survives app restarts. - _storage.saveWorkoutSession(updated).catchError((Object e) { + final merged = _sessions[index].copyWith(hcSyncedAt: updated.hcSyncedAt); + _sessions[index] = merged; + _storage.saveWorkoutSession(merged).catchError((Object e) { debugPrint('HistoryManager: failed to persist hcSyncedAt: $e'); }); notifyListeners(); From 12658abbb6d546417f65fdfd83b39c8a2abcb840 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 2 May 2026 01:10:11 +0530 Subject: [PATCH 10/10] feat: integrate health connect version retrieval in ProfileScreen --- workout-logger/lib/screens/profile_screen.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index d356930..5f8fca8 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -10,13 +10,14 @@ import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:intl/intl.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; import '../services/api_service.dart'; import '../services/interfaces/health_connect_service_interface.dart'; import '../theme/app_theme.dart'; -const String _appVersion = '1.0.12'; const String _createdBy = 'Devasy Patel'; class ProfileScreen extends StatefulWidget { @@ -32,11 +33,15 @@ class _ProfileScreenState extends State bool _isImporting = false; bool _isBackingUp = false; bool _isRequestingHcPermission = false; + String _appVersion = ''; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + PackageInfo.fromPlatform().then((info) { + if (mounted) setState(() => _appVersion = info.version); + }); // Reconcile stored HC flag against runtime state on screen load. WidgetsBinding.instance.addPostFrameCallback((_) => _reconcileHealthConnectState()); }