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") }