[Android] 1.9.0
This commit is contained in:
@@ -12,6 +12,7 @@ For Android do one of the following:
|
|||||||
For iOS:
|
For iOS:
|
||||||
|
|
||||||
Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
|
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
|
## Self-Hosted Sync Server
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.openclimb"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 36
|
versionCode = 37
|
||||||
versionName = "1.8.0"
|
versionName = "1.9.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -79,6 +79,9 @@ dependencies {
|
|||||||
// Image Loading
|
// Image Loading
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
// Health Connect
|
||||||
|
implementation("androidx.health.connect:connect-client:1.1.0-alpha07")
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.mockk)
|
testImplementation(libs.mockk)
|
||||||
|
|||||||
@@ -11,6 +11,16 @@
|
|||||||
<!-- Permission for sync functionality -->
|
<!-- Permission for sync functionality -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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 -->
|
<!-- Hardware features -->
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -40,6 +62,16 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</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 repository = remember { ClimbRepository(database, context) }
|
||||||
val syncService = remember { SyncService(context, repository) }
|
val syncService = remember { SyncService(context, repository) }
|
||||||
val viewModel: ClimbViewModel =
|
val viewModel: ClimbViewModel =
|
||||||
viewModel(factory = ClimbViewModelFactory(repository, syncService))
|
viewModel(factory = ClimbViewModelFactory(repository, syncService, context))
|
||||||
|
|
||||||
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
|
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
|
||||||
var hasCheckedNotificationPermission 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.Check
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
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.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
@@ -260,6 +261,32 @@ fun SessionDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
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
|
// Share button
|
||||||
if (session?.duration != null) { // Only show for completed sessions
|
if (session?.duration != null) { // Only show for completed sessions
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.openclimb.R
|
||||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
|
import com.atridad.openclimb.ui.health.HealthConnectCard
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -379,7 +380,8 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data Management Section
|
item { HealthConnectCard() }
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.atridad.openclimb.ui.viewmodel
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.atridad.openclimb.data.health.HealthConnectManager
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.openclimb.data.model.*
|
||||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||||
import com.atridad.openclimb.data.sync.SyncService
|
import com.atridad.openclimb.data.sync.SyncService
|
||||||
@@ -17,8 +18,14 @@ import kotlinx.coroutines.flow.*
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class ClimbViewModel(private val repository: ClimbRepository, val syncService: SyncService) :
|
class ClimbViewModel(
|
||||||
ViewModel() {
|
private val repository: ClimbRepository,
|
||||||
|
val syncService: SyncService,
|
||||||
|
private val context: Context
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// Health Connect manager
|
||||||
|
private val healthConnectManager = HealthConnectManager(context)
|
||||||
|
|
||||||
// UI State flows
|
// UI State flows
|
||||||
private val _uiState = MutableStateFlow(ClimbUiState())
|
private val _uiState = MutableStateFlow(ClimbUiState())
|
||||||
@@ -377,6 +384,8 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
|||||||
|
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
|
||||||
|
syncToHealthConnect(completedSession)
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(message = "Session completed!")
|
_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
|
// Share operations
|
||||||
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
|
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.atridad.openclimb.ui.viewmodel
|
package com.atridad.openclimb.ui.viewmodel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||||
@@ -7,13 +8,14 @@ import com.atridad.openclimb.data.sync.SyncService
|
|||||||
|
|
||||||
class ClimbViewModelFactory(
|
class ClimbViewModelFactory(
|
||||||
private val repository: ClimbRepository,
|
private val repository: ClimbRepository,
|
||||||
private val syncService: SyncService
|
private val syncService: SyncService,
|
||||||
|
private val context: Context
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
|
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
|
||||||
return ClimbViewModel(repository, syncService) as T
|
return ClimbViewModel(repository, syncService, context) as T
|
||||||
}
|
}
|
||||||
throw IllegalArgumentException("Unknown ViewModel class")
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user