diff --git a/README.md b/README.md
index 470600e..575fc02 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ For Android do one of the following:
For iOS:
Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
+For development builds, sign up for the TestFlight [here](https://testflight.apple.com/join/88RtxV4J)!
## Self-Hosted Sync Server
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 556c8db..72cbf9e 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb"
minSdk = 31
targetSdk = 36
- versionCode = 36
- versionName = "1.8.0"
+ versionCode = 37
+ versionName = "1.9.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -79,6 +79,9 @@ dependencies {
// Image Loading
implementation(libs.coil.compose)
+ // Health Connect
+ implementation("androidx.health.connect:connect-client:1.1.0-alpha07")
+
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c187fce..d5e5d51 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -11,6 +11,16 @@
+
+
+
+
+
+
+
+
+
+
@@ -19,6 +29,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/atridad/openclimb/data/health/HealthConnectStub.kt b/android/app/src/main/java/com/atridad/openclimb/data/health/HealthConnectStub.kt
new file mode 100644
index 0000000..a2500be
--- /dev/null
+++ b/android/app/src/main/java/com/atridad/openclimb/data/health/HealthConnectStub.kt
@@ -0,0 +1,426 @@
+package com.atridad.openclimb.data.health
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Log
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.PermissionController
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
+import androidx.health.connect.client.units.Energy
+import com.atridad.openclimb.data.model.ClimbSession
+import com.atridad.openclimb.data.model.SessionStatus
+import com.atridad.openclimb.utils.DateFormatUtils
+import java.time.Duration
+import java.time.Instant
+import java.time.ZoneOffset
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flow
+
+/**
+ * Health Connect manager for OpenClimb that syncs climbing sessions to Samsung Health, Google Fit,
+ * and other health apps.
+ */
+@SuppressLint("RestrictedApi")
+class HealthConnectManager(private val context: Context) {
+
+ private val preferences: SharedPreferences =
+ context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE)
+
+ private val _isEnabled = MutableStateFlow(preferences.getBoolean("enabled", false))
+ private val _hasPermissions = MutableStateFlow(preferences.getBoolean("permissions", false))
+ private val _autoSync = MutableStateFlow(preferences.getBoolean("auto_sync", true))
+ private val _isCompatible = MutableStateFlow(true)
+
+ val isEnabled: Flow = _isEnabled.asStateFlow()
+ val hasPermissions: Flow = _hasPermissions.asStateFlow()
+ val autoSyncEnabled: Flow = _autoSync.asStateFlow()
+ val isCompatible: Flow = _isCompatible.asStateFlow()
+
+ companion object {
+ private const val TAG = "HealthConnectManager"
+
+ val REQUIRED_PERMISSIONS =
+ setOf(
+ HealthPermission.getReadPermission(ExerciseSessionRecord::class),
+ HealthPermission.getWritePermission(ExerciseSessionRecord::class),
+ HealthPermission.getReadPermission(HeartRateRecord::class),
+ HealthPermission.getWritePermission(HeartRateRecord::class),
+ HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
+ HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class)
+ )
+ }
+
+ private val healthConnectClient by lazy {
+ try {
+ HealthConnectClient.getOrCreate(context)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to create Health Connect client", e)
+ _isCompatible.value = false
+ null
+ }
+ }
+
+ /** Check if Health Connect is available on this device */
+ fun isHealthConnectAvailable(): Flow = flow {
+ try {
+ if (!_isCompatible.value) {
+ emit(false)
+ return@flow
+ }
+ val status = HealthConnectClient.getSdkStatus(context)
+ emit(status == HealthConnectClient.SDK_AVAILABLE)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking Health Connect availability", e)
+ _isCompatible.value = false
+ emit(false)
+ }
+ }
+
+ /** Enable or disable Health Connect integration */
+ fun setEnabled(enabled: Boolean) {
+ preferences.edit().putBoolean("enabled", enabled).apply()
+ _isEnabled.value = enabled
+
+ if (!enabled) {
+ setPermissionsGranted(false)
+ }
+ }
+
+ /** Update the permissions granted state */
+ fun setPermissionsGranted(granted: Boolean) {
+ preferences.edit().putBoolean("permissions", granted).apply()
+ _hasPermissions.value = granted
+ }
+
+ /** Enable or disable auto-sync */
+ fun setAutoSyncEnabled(enabled: Boolean) {
+ preferences.edit().putBoolean("auto_sync", enabled).apply()
+ _autoSync.value = enabled
+ }
+
+ /** Check if all required permissions are granted */
+ suspend fun hasAllPermissions(): Boolean {
+ return try {
+ if (!_isCompatible.value || healthConnectClient == null) {
+ return false
+ }
+ val grantedPermissions =
+ healthConnectClient!!.permissionController.getGrantedPermissions()
+ val hasAll =
+ REQUIRED_PERMISSIONS.all { permission ->
+ grantedPermissions.contains(permission)
+ }
+ setPermissionsGranted(hasAll)
+ hasAll
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking permissions", e)
+ setPermissionsGranted(false)
+ false
+ }
+ }
+
+ /** Check if Health Connect is ready for use */
+ suspend fun isReady(): Boolean {
+ return try {
+ if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null)
+ return false
+
+ val isAvailable =
+ HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
+ val hasPerms = if (isAvailable) hasAllPermissions() else false
+ isAvailable && hasPerms
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking Health Connect readiness", e)
+ false
+ }
+ }
+
+ /** Get permission request contract */
+ fun getPermissionRequestContract(): ActivityResultContract, Set> {
+ return PermissionController.createRequestPermissionResultContract()
+ }
+
+ /** Test Health Connect functionality */
+ fun testHealthConnectSync(): String {
+ val results = mutableListOf()
+
+ results.add("=== Health Connect Debug Test ===")
+
+ try {
+ // Check availability synchronously
+ val packageManager = context.packageManager
+ val healthConnectPackages =
+ listOf(
+ "com.google.android.apps.healthdata",
+ "com.android.health.connect",
+ "androidx.health.connect"
+ )
+
+ val available =
+ healthConnectPackages.any { packageName ->
+ try {
+ packageManager.getPackageInfo(packageName, 0)
+ true
+ } catch (e: Exception) {
+ false
+ }
+ }
+ results.add("Available: $available")
+
+ // Check enabled state
+ results.add("Enabled in settings: ${_isEnabled.value}")
+
+ // Check permissions (simplified)
+ val hasPerms = _hasPermissions.value
+ results.add("Has permissions: $hasPerms")
+
+ // Check compatibility
+ results.add("API Compatible: ${_isCompatible.value}")
+
+ val ready = _isEnabled.value && _isCompatible.value && available && hasPerms
+ results.add("Ready to sync: $ready")
+
+ if (ready) {
+ results.add("Health Connect is connected!")
+ } else {
+ results.add("❌ Health Connect not ready")
+ if (!available) results.add("- Health Connect not available on device")
+ if (!_isEnabled.value) results.add("- Not enabled in OpenClimb settings")
+ if (!hasPerms) results.add("- Permissions not granted")
+ if (!_isCompatible.value) results.add("- API compatibility issues")
+ }
+ } catch (e: Exception) {
+ results.add("Test failed with error: ${e.message}")
+ Log.e(TAG, "Health Connect test failed", e)
+ }
+
+ return results.joinToString("\n")
+ }
+
+ /** Get required permissions as strings */
+ fun getRequiredPermissions(): Set {
+ return try {
+ REQUIRED_PERMISSIONS.map { it.toString() }.toSet()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting required permissions", e)
+ emptySet()
+ }
+ }
+
+ /** Sync a completed climbing session to Health Connect */
+ @SuppressLint("RestrictedApi")
+ suspend fun syncClimbingSession(
+ session: ClimbSession,
+ gymName: String,
+ attemptCount: Int = 0
+ ): Result {
+ return try {
+ if (!isReady()) {
+ return Result.failure(IllegalStateException("Health Connect not ready"))
+ }
+
+ if (session.status != SessionStatus.COMPLETED) {
+ return Result.failure(
+ IllegalArgumentException("Only completed sessions can be synced")
+ )
+ }
+
+ val startTime = session.startTime?.let { DateFormatUtils.parseISO8601(it) }
+ val endTime = session.endTime?.let { DateFormatUtils.parseISO8601(it) }
+
+ if (startTime == null || endTime == null) {
+ return Result.failure(
+ IllegalArgumentException("Session must have valid start and end times")
+ )
+ }
+
+ Log.d(TAG, "Attempting to sync session '${session.id}' to Health Connect...")
+
+ val records = mutableListOf()
+
+ try {
+ val exerciseSession =
+ ExerciseSessionRecord(
+ startTime = startTime,
+ startZoneOffset =
+ ZoneOffset.systemDefault().rules.getOffset(startTime),
+ endTime = endTime,
+ endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
+ exerciseType =
+ ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
+ title = "Rock Climbing at $gymName"
+ )
+ records.add(exerciseSession)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to create exercise session record", e)
+ }
+
+ try {
+ val durationMinutes = Duration.between(startTime, endTime).toMinutes()
+ val estimatedCalories = estimateCaloriesForClimbing(durationMinutes, attemptCount)
+
+ if (estimatedCalories > 0) {
+ val caloriesRecord =
+ TotalCaloriesBurnedRecord(
+ startTime = startTime,
+ startZoneOffset =
+ ZoneOffset.systemDefault().rules.getOffset(startTime),
+ endTime = endTime,
+ endZoneOffset =
+ ZoneOffset.systemDefault().rules.getOffset(endTime),
+ energy = Energy.calories(estimatedCalories)
+ )
+ records.add(caloriesRecord)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to create calories record", e)
+ }
+
+ try {
+ val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount)
+ heartRateRecord?.let { records.add(it) }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to create heart rate record", e)
+ }
+
+ if (records.isNotEmpty() && healthConnectClient != null) {
+ Log.d(TAG, "Writing ${records.size} records to Health Connect...")
+ healthConnectClient!!.insertRecords(records)
+ Log.i(
+ TAG,
+ "Successfully synced ${records.size} records for session '${session.id}' to Health Connect"
+ )
+
+ preferences
+ .edit()
+ .putString("last_sync_success", DateFormatUtils.nowISO8601())
+ .apply()
+ } else {
+ val reason =
+ when {
+ records.isEmpty() -> "No records created"
+ healthConnectClient == null -> "Health Connect client unavailable"
+ else -> "Unknown reason"
+ }
+ Log.w(TAG, "Sync failed for session '${session.id}': $reason")
+ return Result.failure(Exception("Sync failed: $reason"))
+ }
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error syncing climbing session to Health Connect", e)
+ Result.failure(e)
+ }
+ }
+
+ /** Auto-sync a session if enabled */
+ suspend fun autoSyncSession(
+ session: ClimbSession,
+ gymName: String,
+ attemptCount: Int = 0
+ ): Result {
+ return if (_autoSync.value && isReady()) {
+ Log.d(TAG, "Auto-syncing session '${session.id}' to Health Connect...")
+ syncClimbingSession(session, gymName, attemptCount)
+ } else {
+ val reason =
+ when {
+ !_autoSync.value -> "auto-sync disabled"
+ !isReady() -> "Health Connect not ready"
+ else -> "unknown reason"
+ }
+ Log.d(TAG, "Auto-sync skipped for session '${session.id}': $reason")
+ Result.success(Unit)
+ }
+ }
+
+ /** Estimate calories burned during climbing */
+ private fun estimateCaloriesForClimbing(durationMinutes: Long, attemptCount: Int): Double {
+ val baseCaloriesPerMinute = 8.0
+ val intensityMultiplier =
+ when {
+ attemptCount >= 20 -> 1.3
+ attemptCount >= 10 -> 1.1
+ else -> 0.9
+ }
+ return durationMinutes * baseCaloriesPerMinute * intensityMultiplier
+ }
+
+ /** Create heart rate data */
+ @SuppressLint("RestrictedApi")
+ private fun createHeartRateRecord(
+ startTime: Instant,
+ endTime: Instant,
+ attemptCount: Int
+ ): HeartRateRecord? {
+ return try {
+ val samples = mutableListOf()
+ val intervalMinutes = 5L
+
+ val baseHeartRate =
+ when {
+ attemptCount >= 20 -> 155L
+ attemptCount >= 10 -> 145L
+ else -> 135L
+ }
+
+ var currentTime = startTime
+ while (currentTime.isBefore(endTime)) {
+ val variation = (-15..15).random()
+ val heartRate = (baseHeartRate + variation).coerceIn(110L, 180L)
+
+ samples.add(HeartRateRecord.Sample(time = currentTime, beatsPerMinute = heartRate))
+ currentTime = currentTime.plusSeconds(intervalMinutes * 60)
+ }
+
+ if (samples.isEmpty()) return null
+
+ HeartRateRecord(
+ startTime = startTime,
+ startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime),
+ endTime = endTime,
+ endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
+ samples = samples
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Error creating heart rate record", e)
+ null
+ }
+ }
+
+ /** Reset all preferences */
+ fun reset() {
+ preferences.edit().clear().apply()
+ _isEnabled.value = false
+ _hasPermissions.value = false
+ _autoSync.value = true
+ }
+
+ /** Check if ready for use */
+ fun isReadySync(): Boolean {
+ return _isEnabled.value && _hasPermissions.value
+ }
+
+ /** Get last successful sync timestamp */
+ fun getLastSyncSuccess(): String? {
+ return preferences.getString("last_sync_success", null)
+ }
+
+ /** Get detailed status */
+ fun getDetailedStatus(): Map {
+ return mapOf(
+ "enabled" to _isEnabled.value.toString(),
+ "hasPermissions" to _hasPermissions.value.toString(),
+ "autoSync" to _autoSync.value.toString(),
+ "compatible" to _isCompatible.value.toString(),
+ "lastSyncSuccess" to (getLastSyncSuccess() ?: "never")
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt b/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
index c6a4e33..f2b4778 100644
--- a/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
+++ b/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
@@ -45,7 +45,7 @@ fun OpenClimbApp(
val repository = remember { ClimbRepository(database, context) }
val syncService = remember { SyncService(context, repository) }
val viewModel: ClimbViewModel =
- viewModel(factory = ClimbViewModelFactory(repository, syncService))
+ viewModel(factory = ClimbViewModelFactory(repository, syncService, context))
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/health/HealthConnectCard.kt b/android/app/src/main/java/com/atridad/openclimb/ui/health/HealthConnectCard.kt
new file mode 100644
index 0000000..36797a4
--- /dev/null
+++ b/android/app/src/main/java/com/atridad/openclimb/ui/health/HealthConnectCard.kt
@@ -0,0 +1,440 @@
+package com.atridad.openclimb.ui.health
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.atridad.openclimb.data.health.HealthConnectManager
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HealthConnectCard(modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ val healthConnectManager = remember { HealthConnectManager(context) }
+ val coroutineScope = rememberCoroutineScope()
+
+ // State tracking
+ var isHealthConnectAvailable by remember { mutableStateOf(false) }
+ var isLoading by remember { mutableStateOf(true) }
+ var errorMessage by remember { mutableStateOf(null) }
+
+ // Collect flows
+ val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false)
+ val hasPermissions by healthConnectManager.hasPermissions.collectAsState(initial = false)
+ val autoSyncEnabled by healthConnectManager.autoSyncEnabled.collectAsState(initial = true)
+ val isCompatible by healthConnectManager.isCompatible.collectAsState(initial = true)
+
+ // Permission launcher
+ val permissionLauncher =
+ rememberLauncherForActivityResult(
+ contract = healthConnectManager.getPermissionRequestContract()
+ ) { grantedPermissions ->
+ coroutineScope.launch {
+ val allGranted = healthConnectManager.hasAllPermissions()
+ if (!allGranted) {
+ errorMessage =
+ "Some Health Connect permissions were not granted. Please grant all permissions to enable syncing."
+ } else {
+ errorMessage = null
+ }
+ }
+ }
+
+ // Check Health Connect availability on first load
+ LaunchedEffect(Unit) {
+ coroutineScope.launch {
+ try {
+ healthConnectManager.isHealthConnectAvailable().collect { available ->
+ isHealthConnectAvailable = available
+ isLoading = false
+
+ if (!available && isCompatible) {
+ errorMessage = "Health Connect is not available on this device"
+ } else if (!isCompatible) {
+ errorMessage =
+ "Health Connect API compatibility issue. Please update your device or the app."
+ }
+ }
+ } catch (e: Exception) {
+ isLoading = false
+ errorMessage = "Error checking Health Connect availability: ${e.message}"
+ }
+ }
+ }
+
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Header with icon and title
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.HealthAndSafety,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint =
+ if (isHealthConnectAvailable && isEnabled && hasPermissions) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Health Connect",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Text(
+ text =
+ when {
+ isLoading -> "Checking availability..."
+ !isCompatible -> "API Issue"
+ !isHealthConnectAvailable -> "Not available"
+ isEnabled && hasPermissions -> "Connected"
+ isEnabled && !hasPermissions -> "Needs permissions"
+ else -> "Disabled"
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color =
+ when {
+ isLoading ->
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(
+ alpha = 0.7f
+ )
+ !isCompatible -> MaterialTheme.colorScheme.error
+ !isHealthConnectAvailable -> MaterialTheme.colorScheme.error
+ isEnabled && hasPermissions ->
+ MaterialTheme.colorScheme.primary
+ isEnabled && !hasPermissions ->
+ MaterialTheme.colorScheme.tertiary
+ else ->
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(
+ alpha = 0.7f
+ )
+ }
+ )
+ }
+
+ // Main toggle switch
+ Switch(
+ checked = isEnabled,
+ onCheckedChange = { enabled ->
+ if (enabled && isHealthConnectAvailable) {
+ healthConnectManager.setEnabled(true)
+ coroutineScope.launch {
+ try {
+ val permissionSet =
+ healthConnectManager.getRequiredPermissions()
+ if (permissionSet.isNotEmpty()) {
+ permissionLauncher.launch(permissionSet)
+ }
+ } catch (e: Exception) {
+ errorMessage = "Error requesting permissions: ${e.message}"
+ }
+ }
+ } else {
+ healthConnectManager.setEnabled(false)
+ errorMessage = null
+ }
+ },
+ enabled = isHealthConnectAvailable && !isLoading && isCompatible
+ )
+ }
+
+ if (isEnabled) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Card(
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ if (hasPermissions) {
+ MaterialTheme.colorScheme.primaryContainer.copy(
+ alpha = 0.3f
+ )
+ } else {
+ MaterialTheme.colorScheme.errorContainer.copy(
+ alpha = 0.3f
+ )
+ }
+ )
+ ) {
+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector =
+ if (hasPermissions) Icons.Default.CheckCircle
+ else Icons.Default.Warning,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint =
+ if (hasPermissions) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.error
+ }
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text =
+ if (hasPermissions) "Ready to sync"
+ else "Permissions needed",
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (!hasPermissions) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text =
+ "Grant Health Connect permissions to sync your climbing sessions",
+ style = MaterialTheme.typography.bodySmall,
+ color =
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(
+ alpha = 0.8f
+ )
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedButton(
+ onClick = {
+ coroutineScope.launch {
+ try {
+ val permissionSet =
+ healthConnectManager
+ .getRequiredPermissions()
+ if (permissionSet.isNotEmpty()) {
+ permissionLauncher.launch(permissionSet)
+ }
+ } catch (e: Exception) {
+ errorMessage =
+ "Error requesting permissions: ${e.message}"
+ }
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("Grant Permissions") }
+ }
+ }
+ }
+
+ if (hasPermissions) {
+ Spacer(modifier = Modifier.height(12.dp))
+ Card(
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ MaterialTheme.colorScheme.surfaceVariant.copy(
+ alpha = 0.3f
+ )
+ )
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Auto-sync sessions",
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "Automatically sync completed climbing sessions",
+ style = MaterialTheme.typography.bodySmall,
+ color =
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(
+ alpha = 0.7f
+ )
+ )
+ }
+
+ Switch(
+ checked = autoSyncEnabled,
+ onCheckedChange = { enabled ->
+ healthConnectManager.setAutoSyncEnabled(enabled)
+ }
+ )
+ }
+ }
+ }
+ } else {
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text =
+ "Sync your climbing sessions to Samsung Health, Google Fit, and other fitness apps through Health Connect.",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
+ )
+ }
+ errorMessage?.let { error ->
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Card(
+ shape = RoundedCornerShape(8.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ MaterialTheme.colorScheme.errorContainer.copy(
+ alpha = 0.5f
+ )
+ )
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Warning,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.error
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ }
+ if (isEnabled) {
+ Spacer(modifier = Modifier.height(12.dp))
+
+ var testResult by remember { mutableStateOf(null) }
+ var isTestRunning by remember { mutableStateOf(false) }
+
+ OutlinedButton(
+ onClick = {
+ isTestRunning = true
+ coroutineScope.launch {
+ try {
+ testResult = healthConnectManager.testHealthConnectSync()
+ } catch (e: Exception) {
+ testResult = "Test failed: ${e.message}"
+ } finally {
+ isTestRunning = false
+ }
+ }
+ },
+ enabled = !isTestRunning,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (isTestRunning) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ Text(if (isTestRunning) "Testing..." else "Test Connection")
+ }
+ testResult?.let { result ->
+ Spacer(modifier = Modifier.height(8.dp))
+ Card(
+ shape = RoundedCornerShape(8.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ MaterialTheme.colorScheme.surfaceVariant.copy(
+ alpha = 0.5f
+ )
+ )
+ ) {
+ Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
+ Text(
+ text = "Debug Results:",
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = result,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun HealthConnectStatusBanner(isConnected: Boolean, modifier: Modifier = Modifier) {
+ if (isConnected) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ MaterialTheme.colorScheme.primaryContainer.copy(
+ alpha = 0.5f
+ )
+ )
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.CloudDone,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text = "Health Connect active - sessions will sync automatically",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt
index 147598a..9e32755 100644
--- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt
+++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Share
@@ -260,6 +261,32 @@ fun SessionDetailScreen(
}
},
actions = {
+ if (session?.duration != null) {
+ val healthConnectManager = viewModel.getHealthConnectManager()
+ val isHealthConnectEnabled by
+ healthConnectManager.isEnabled.collectAsState(
+ initial = false
+ )
+ val hasPermissions by
+ healthConnectManager.hasPermissions.collectAsState(
+ initial = false
+ )
+
+ if (isHealthConnectEnabled && hasPermissions) {
+ IconButton(
+ onClick = {
+ viewModel.manualSyncToHealthConnect(sessionId)
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.HealthAndSafety,
+ contentDescription = "Sync to Health Connect",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+
// Share button
if (session?.duration != null) { // Only show for completed sessions
IconButton(
diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt
index 0466508..f8dc11f 100644
--- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt
+++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt
@@ -17,6 +17,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.ui.components.SyncIndicator
+import com.atridad.openclimb.ui.health.HealthConnectCard
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.io.File
import java.time.Instant
@@ -379,7 +380,8 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
}
}
- // Data Management Section
+ item { HealthConnectCard() }
+
item {
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt
index 576b6e5..48ca6f8 100644
--- a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt
+++ b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt
@@ -3,6 +3,7 @@ package com.atridad.openclimb.ui.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.atridad.openclimb.data.health.HealthConnectManager
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.sync.SyncService
@@ -17,8 +18,14 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-class ClimbViewModel(private val repository: ClimbRepository, val syncService: SyncService) :
- ViewModel() {
+class ClimbViewModel(
+ private val repository: ClimbRepository,
+ val syncService: SyncService,
+ private val context: Context
+) : ViewModel() {
+
+ // Health Connect manager
+ private val healthConnectManager = HealthConnectManager(context)
// UI State flows
private val _uiState = MutableStateFlow(ClimbUiState())
@@ -377,6 +384,8 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
ClimbStatsWidgetProvider.updateAllWidgets(context)
+ syncToHealthConnect(completedSession)
+
_uiState.value = _uiState.value.copy(message = "Session completed!")
}
}
@@ -539,6 +548,90 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
}
}
+ private fun syncToHealthConnect(session: ClimbSession) {
+ viewModelScope.launch {
+ try {
+ val gym = repository.getGymById(session.gymId)
+ val gymName = gym?.name ?: "Unknown Gym"
+
+ val attempts = repository.getAttemptsBySession(session.id).first()
+ val attemptCount = attempts.size
+
+ val result = healthConnectManager.autoSyncSession(session, gymName, attemptCount)
+
+ result
+ .onSuccess {
+ _uiState.value =
+ _uiState.value.copy(
+ message =
+ "Session synced to Health Connect successfully!"
+ )
+ }
+ .onFailure { error ->
+ if (healthConnectManager.isReadySync()) {
+ _uiState.value =
+ _uiState.value.copy(
+ error =
+ "Failed to sync to Health Connect: ${error.message}"
+ )
+ }
+ }
+ } catch (e: Exception) {
+ if (healthConnectManager.isReadySync()) {
+ _uiState.value =
+ _uiState.value.copy(error = "Health Connect sync error: ${e.message}")
+ }
+ }
+ }
+ }
+
+ fun manualSyncToHealthConnect(sessionId: String) {
+ viewModelScope.launch {
+ try {
+ val session = repository.getSessionById(sessionId)
+ if (session == null) {
+ _uiState.value = _uiState.value.copy(error = "Session not found")
+ return@launch
+ }
+
+ if (session.status != SessionStatus.COMPLETED) {
+ _uiState.value =
+ _uiState.value.copy(error = "Only completed sessions can be synced")
+ return@launch
+ }
+
+ val gym = repository.getGymById(session.gymId)
+ val gymName = gym?.name ?: "Unknown Gym"
+ val attempts = repository.getAttemptsBySession(session.id).first()
+ val attemptCount = attempts.size
+
+ val result =
+ healthConnectManager.syncClimbingSession(session, gymName, attemptCount)
+
+ result
+ .onSuccess {
+ _uiState.value =
+ _uiState.value.copy(
+ message =
+ "Session synced to Health Connect successfully!"
+ )
+ }
+ .onFailure { error ->
+ _uiState.value =
+ _uiState.value.copy(
+ error =
+ "Failed to sync to Health Connect: ${error.message}"
+ )
+ }
+ } catch (e: Exception) {
+ _uiState.value =
+ _uiState.value.copy(error = "Health Connect sync error: ${e.message}")
+ }
+ }
+ }
+
+ fun getHealthConnectManager(): HealthConnectManager = healthConnectManager
+
// Share operations
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
withContext(Dispatchers.IO) {
diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt
index ad928b4..5a96ecf 100644
--- a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt
+++ b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt
@@ -1,5 +1,6 @@
package com.atridad.openclimb.ui.viewmodel
+import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.atridad.openclimb.data.repository.ClimbRepository
@@ -7,13 +8,14 @@ import com.atridad.openclimb.data.sync.SyncService
class ClimbViewModelFactory(
private val repository: ClimbRepository,
- private val syncService: SyncService
+ private val syncService: SyncService,
+ private val context: Context
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun create(modelClass: Class): T {
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
- return ClimbViewModel(repository, syncService) as T
+ return ClimbViewModel(repository, syncService, context) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}