[Android] 1.9.0
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
<!-- Permission for sync functionality -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- Health Connect permissions -->
|
||||
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
|
||||
<uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
|
||||
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
|
||||
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
|
||||
<uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED" />
|
||||
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
|
||||
<uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED" />
|
||||
|
||||
<!-- Hardware features -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
@@ -19,6 +29,18 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<!-- Health Connect queries -->
|
||||
<queries>
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
<intent>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE"/>
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS"/>
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -40,6 +62,16 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Health Connect permission rationale handling -->
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Permission handling for Android 14 and later -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE"/>
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS"/>
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -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<Boolean> = _isEnabled.asStateFlow()
|
||||
val hasPermissions: Flow<Boolean> = _hasPermissions.asStateFlow()
|
||||
val autoSyncEnabled: Flow<Boolean> = _autoSync.asStateFlow()
|
||||
val isCompatible: Flow<Boolean> = _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<Boolean> = 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<String>, Set<String>> {
|
||||
return PermissionController.createRequestPermissionResultContract()
|
||||
}
|
||||
|
||||
/** Test Health Connect functionality */
|
||||
fun testHealthConnectSync(): String {
|
||||
val results = mutableListOf<String>()
|
||||
|
||||
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<String> {
|
||||
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<Unit> {
|
||||
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<androidx.health.connect.client.records.Record>()
|
||||
|
||||
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<Unit> {
|
||||
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<HeartRateRecord.Sample>()
|
||||
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<String, String> {
|
||||
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")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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<String?>(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<String?>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <T : ViewModel> create(modelClass: Class<T>): 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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user