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());
}