diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8303fc..62890d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,4 +46,10 @@ jobs: - name: Run tests working-directory: ./workout-logger - run: flutter test + run: flutter test --coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: workout-logger/coverage/lcov.info + token: ${{ secrets.CODECOV_TOKEN }} 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/build.gradle.kts b/workout-logger/android/app/build.gradle.kts index b76f436..d12c514 100644 --- a/workout-logger/android/app/build.gradle.kts +++ b/workout-logger/android/app/build.gradle.kts @@ -24,7 +24,11 @@ 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 + // 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 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..61b507a 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..0a7f206 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -9,12 +9,16 @@ 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'; 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'; @@ -45,8 +49,15 @@ 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); + // 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}); @@ -61,17 +72,23 @@ 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()), // ProgramManager passed to tree directly 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, + historyManager: _historyManager, programManager: _programManager, ), ), @@ -111,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/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index 37309dc..5f8fca8 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -10,12 +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 { @@ -25,10 +27,110 @@ 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; + 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()); + } + + @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 { + 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; + } + + // 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( + 'Open Health Connect → App permissions → RepForge and enable Exercise.', + 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 +271,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 +421,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/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/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart new file mode 100644 index 0000000..95529f1 --- /dev/null +++ b/workout-logger/lib/services/health_connect_service.dart @@ -0,0 +1,211 @@ +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, + 'cable_crunch': ExerciseSegmentType.crunch, + 'russian_twist': 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, {String? title}) 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(), + title: title?.isNotEmpty == true ? title : null, + 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 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, + ) { + // 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; + for (final set in log.sets) { + if (set.reps > 0) { + allSets.add((type, set.reps, set.timestamp)); + } + } + } + + if (allSets.isEmpty) return []; + + // Sort by recorded timestamp so segments follow real workout order. + allSets.sort((a, b) => a.$3.compareTo(b.$3)); + + // 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). + // • 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 = clampedSets.map((s) => s.$3).toSet(); + if (uniqueTimestamps.length < clampedSets.length) { + final totalMs = sessionEnd.difference(sessionStart).inMilliseconds; + final slotMs = totalMs ~/ clampedSets.length; + return List.generate(clampedSets.length, (i) { + final start = sessionStart.add(Duration(milliseconds: slotMs * i)); + final end = i < clampedSets.length - 1 + ? sessionStart.add(Duration(milliseconds: slotMs * (i + 1))) + : sessionEnd; + return ExerciseSessionSegmentEvent( + startTime: start, + endTime: end, + segmentType: clampedSets[i].$1, + repetitions: clampedSets[i].$2, + ); + }); + } + + // Build segments using clamped timestamps (already within [sessionStart, sessionEnd]). + final segments = []; + 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: clampedSets[i].$1, + repetitions: clampedSets[i].$2, + )); + } + return 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 new file mode 100644 index 0000000..14b6c9a --- /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, {String? title}); +} 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 fdea987..55819d6 100644 --- a/workout-logger/lib/services/interfaces/interfaces.dart +++ b/workout-logger/lib/services/interfaces/interfaces.dart @@ -6,3 +6,5 @@ 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..90d239e 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,54 @@ 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. + // 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; + 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(); } @@ -140,6 +188,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/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/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..79ef6c8 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -25,6 +25,7 @@ import 'interfaces/ml_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 } @@ -36,6 +37,7 @@ class WorkoutInProgressError extends StateError { class WorkoutProvider extends ChangeNotifier { final IStorageService _storage; final IMLService _mlService; + final HistoryManager? _historyManager; final Uuid _uuid = const Uuid(); // State @@ -83,11 +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, + HistoryManager? historyManager, required this.programManager, - }) : _mlService = mlService ?? MLService(); + }) : _mlService = mlService ?? MLService(), + _historyManager = historyManager; // ==================== INITIALIZATION ==================== @@ -582,7 +588,16 @@ 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 ?? _activeProgramDay?.name, + ); + } else { + // Fallback: persist directly (no HC sync) when historyManager is absent. + await _storage.saveWorkoutSession(session); + } await _clearDraft(); _sessions.insert(0, session); @@ -669,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) { @@ -713,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/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 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..9597f8d --- /dev/null +++ b/workout-logger/test/health_sync_manager_test.dart @@ -0,0 +1,131 @@ +// Unit tests for HealthSyncManager + + +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 — just run and let test fail on exception. + manager.syncSession(_makeSession()); + await Future.delayed(Duration.zero); + // If we reach here, no exception propagated. + }); + }); +} 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); + }); + }); +}