[Android] 2.0.1 - Refactoring & Minor Optimizations
This commit is contained in:
@@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.atridad.ascently"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 40
|
||||
versionName = "2.0.0"
|
||||
versionCode = 41
|
||||
versionName = "2.0.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -83,12 +83,25 @@ class HealthConnectManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Enable or disable Health Connect integration */
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
/**
|
||||
* Enable or disable Health Connect integration and automatically request permissions if
|
||||
* enabling
|
||||
*/
|
||||
suspend fun setEnabled(enabled: Boolean) {
|
||||
preferences.edit().putBoolean("enabled", enabled).apply()
|
||||
_isEnabled.value = enabled
|
||||
|
||||
if (!enabled) {
|
||||
if (enabled && _isCompatible.value) {
|
||||
// Automatically request permissions when enabling
|
||||
try {
|
||||
val alreadyHasPermissions = hasAllPermissions()
|
||||
if (!alreadyHasPermissions) {
|
||||
Log.d(TAG, "Health Connect enabled - permissions will be requested by UI")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error checking permissions when enabling Health Connect", e)
|
||||
}
|
||||
} else if (!enabled) {
|
||||
setPermissionsGranted(false)
|
||||
}
|
||||
}
|
||||
@@ -147,63 +160,6 @@ class HealthConnectManager(private val context: Context) {
|
||||
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 Ascently 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 {
|
||||
@@ -214,16 +170,18 @@ class HealthConnectManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync a completed climbing session to Health Connect */
|
||||
/** Sync a completed climbing session to Health Connect (only when auto-sync is enabled) */
|
||||
@SuppressLint("RestrictedApi")
|
||||
suspend fun syncClimbingSession(
|
||||
suspend fun syncCompletedSession(
|
||||
session: ClimbSession,
|
||||
gymName: String,
|
||||
attemptCount: Int = 0
|
||||
): Result<Unit> {
|
||||
return try {
|
||||
if (!isReady()) {
|
||||
return Result.failure(IllegalStateException("Health Connect not ready"))
|
||||
if (!isReady() || !_autoSync.value) {
|
||||
return Result.failure(
|
||||
IllegalStateException("Health Connect not ready or auto-sync disabled")
|
||||
)
|
||||
}
|
||||
|
||||
if (session.status != SessionStatus.COMPLETED) {
|
||||
@@ -320,18 +278,19 @@ class HealthConnectManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-sync a session if enabled */
|
||||
suspend fun autoSyncSession(
|
||||
/** Auto-sync a completed session if enabled - this is the only way to sync sessions */
|
||||
suspend fun autoSyncCompletedSession(
|
||||
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)
|
||||
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
|
||||
Log.d(TAG, "Auto-syncing completed session '${session.id}' to Health Connect...")
|
||||
syncCompletedSession(session, gymName, attemptCount)
|
||||
} else {
|
||||
val reason =
|
||||
when {
|
||||
session.status != SessionStatus.COMPLETED -> "session not completed"
|
||||
!_autoSync.value -> "auto-sync disabled"
|
||||
!isReady() -> "Health Connect not ready"
|
||||
else -> "unknown reason"
|
||||
|
||||
@@ -12,7 +12,19 @@ enum class AttemptResult {
|
||||
SUCCESS,
|
||||
FALL,
|
||||
NO_PROGRESS,
|
||||
FLASH,
|
||||
FLASH;
|
||||
|
||||
val displayName: String
|
||||
get() =
|
||||
when (this) {
|
||||
SUCCESS -> "Success"
|
||||
FALL -> "Fall"
|
||||
NO_PROGRESS -> "No Progress"
|
||||
FLASH -> "Flash"
|
||||
}
|
||||
|
||||
val isSuccessful: Boolean
|
||||
get() = this == SUCCESS || this == FLASH
|
||||
}
|
||||
|
||||
@Entity(
|
||||
@@ -74,5 +86,4 @@ data class Attempt(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -11,7 +11,15 @@ import kotlinx.serialization.Serializable
|
||||
enum class SessionStatus {
|
||||
ACTIVE,
|
||||
COMPLETED,
|
||||
PAUSED
|
||||
PAUSED;
|
||||
|
||||
val displayName: String
|
||||
get() =
|
||||
when (this) {
|
||||
ACTIVE -> "Active"
|
||||
COMPLETED -> "Completed"
|
||||
PAUSED -> "Paused"
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(
|
||||
|
||||
@@ -6,12 +6,11 @@ import kotlinx.serialization.Serializable
|
||||
enum class ClimbType {
|
||||
ROPE,
|
||||
BOULDER;
|
||||
|
||||
/**
|
||||
* Get the display name
|
||||
*/
|
||||
fun getDisplayName(): String = when (this) {
|
||||
ROPE -> "Rope"
|
||||
BOULDER -> "Bouldering"
|
||||
}
|
||||
|
||||
val displayName: String
|
||||
get() =
|
||||
when (this) {
|
||||
ROPE -> "Rope"
|
||||
BOULDER -> "Bouldering"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,130 +12,129 @@ enum class DifficultySystem {
|
||||
YDS,
|
||||
CUSTOM;
|
||||
|
||||
/** Get the display name for the UI */
|
||||
fun getDisplayName(): String =
|
||||
when (this) {
|
||||
V_SCALE -> "V Scale"
|
||||
FONT -> "Font Scale"
|
||||
YDS -> "YDS (Yosemite)"
|
||||
CUSTOM -> "Custom"
|
||||
}
|
||||
val displayName: String
|
||||
get() =
|
||||
when (this) {
|
||||
V_SCALE -> "V Scale"
|
||||
FONT -> "Font Scale"
|
||||
YDS -> "YDS (Yosemite)"
|
||||
CUSTOM -> "Custom"
|
||||
}
|
||||
|
||||
/** Check if this system is for bouldering */
|
||||
fun isBoulderingSystem(): Boolean =
|
||||
when (this) {
|
||||
V_SCALE, FONT -> true
|
||||
YDS -> false
|
||||
CUSTOM -> true
|
||||
}
|
||||
val isBoulderingSystem: Boolean
|
||||
get() =
|
||||
when (this) {
|
||||
V_SCALE, FONT -> true
|
||||
YDS -> false
|
||||
CUSTOM -> true
|
||||
}
|
||||
|
||||
/** Check if this system is for rope climbing */
|
||||
fun isRopeSystem(): Boolean =
|
||||
when (this) {
|
||||
YDS -> true
|
||||
V_SCALE, FONT -> false
|
||||
CUSTOM -> true
|
||||
}
|
||||
val isRopeSystem: Boolean
|
||||
get() =
|
||||
when (this) {
|
||||
YDS -> true
|
||||
V_SCALE, FONT -> false
|
||||
CUSTOM -> true
|
||||
}
|
||||
|
||||
/** Get available grades for this system */
|
||||
fun getAvailableGrades(): List<String> =
|
||||
when (this) {
|
||||
V_SCALE ->
|
||||
listOf(
|
||||
"VB",
|
||||
"V0",
|
||||
"V1",
|
||||
"V2",
|
||||
"V3",
|
||||
"V4",
|
||||
"V5",
|
||||
"V6",
|
||||
"V7",
|
||||
"V8",
|
||||
"V9",
|
||||
"V10",
|
||||
"V11",
|
||||
"V12",
|
||||
"V13",
|
||||
"V14",
|
||||
"V15",
|
||||
"V16",
|
||||
"V17"
|
||||
)
|
||||
FONT ->
|
||||
listOf(
|
||||
"3",
|
||||
"4A",
|
||||
"4B",
|
||||
"4C",
|
||||
"5A",
|
||||
"5B",
|
||||
"5C",
|
||||
"6A",
|
||||
"6A+",
|
||||
"6B",
|
||||
"6B+",
|
||||
"6C",
|
||||
"6C+",
|
||||
"7A",
|
||||
"7A+",
|
||||
"7B",
|
||||
"7B+",
|
||||
"7C",
|
||||
"7C+",
|
||||
"8A",
|
||||
"8A+",
|
||||
"8B",
|
||||
"8B+",
|
||||
"8C",
|
||||
"8C+"
|
||||
)
|
||||
YDS ->
|
||||
listOf(
|
||||
"5.0",
|
||||
"5.1",
|
||||
"5.2",
|
||||
"5.3",
|
||||
"5.4",
|
||||
"5.5",
|
||||
"5.6",
|
||||
"5.7",
|
||||
"5.8",
|
||||
"5.9",
|
||||
"5.10a",
|
||||
"5.10b",
|
||||
"5.10c",
|
||||
"5.10d",
|
||||
"5.11a",
|
||||
"5.11b",
|
||||
"5.11c",
|
||||
"5.11d",
|
||||
"5.12a",
|
||||
"5.12b",
|
||||
"5.12c",
|
||||
"5.12d",
|
||||
"5.13a",
|
||||
"5.13b",
|
||||
"5.13c",
|
||||
"5.13d",
|
||||
"5.14a",
|
||||
"5.14b",
|
||||
"5.14c",
|
||||
"5.14d",
|
||||
"5.15a",
|
||||
"5.15b",
|
||||
"5.15c",
|
||||
"5.15d"
|
||||
)
|
||||
CUSTOM -> emptyList()
|
||||
}
|
||||
val availableGrades: List<String>
|
||||
get() =
|
||||
when (this) {
|
||||
V_SCALE ->
|
||||
listOf(
|
||||
"VB",
|
||||
"V0",
|
||||
"V1",
|
||||
"V2",
|
||||
"V3",
|
||||
"V4",
|
||||
"V5",
|
||||
"V6",
|
||||
"V7",
|
||||
"V8",
|
||||
"V9",
|
||||
"V10",
|
||||
"V11",
|
||||
"V12",
|
||||
"V13",
|
||||
"V14",
|
||||
"V15",
|
||||
"V16",
|
||||
"V17"
|
||||
)
|
||||
FONT ->
|
||||
listOf(
|
||||
"3",
|
||||
"4A",
|
||||
"4B",
|
||||
"4C",
|
||||
"5A",
|
||||
"5B",
|
||||
"5C",
|
||||
"6A",
|
||||
"6A+",
|
||||
"6B",
|
||||
"6B+",
|
||||
"6C",
|
||||
"6C+",
|
||||
"7A",
|
||||
"7A+",
|
||||
"7B",
|
||||
"7B+",
|
||||
"7C",
|
||||
"7C+",
|
||||
"8A",
|
||||
"8A+",
|
||||
"8B",
|
||||
"8B+",
|
||||
"8C",
|
||||
"8C+"
|
||||
)
|
||||
YDS ->
|
||||
listOf(
|
||||
"5.0",
|
||||
"5.1",
|
||||
"5.2",
|
||||
"5.3",
|
||||
"5.4",
|
||||
"5.5",
|
||||
"5.6",
|
||||
"5.7",
|
||||
"5.8",
|
||||
"5.9",
|
||||
"5.10a",
|
||||
"5.10b",
|
||||
"5.10c",
|
||||
"5.10d",
|
||||
"5.11a",
|
||||
"5.11b",
|
||||
"5.11c",
|
||||
"5.11d",
|
||||
"5.12a",
|
||||
"5.12b",
|
||||
"5.12c",
|
||||
"5.12d",
|
||||
"5.13a",
|
||||
"5.13b",
|
||||
"5.13c",
|
||||
"5.13d",
|
||||
"5.14a",
|
||||
"5.14b",
|
||||
"5.14c",
|
||||
"5.14d",
|
||||
"5.15a",
|
||||
"5.15b",
|
||||
"5.15c",
|
||||
"5.15d"
|
||||
)
|
||||
CUSTOM -> emptyList()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Get all difficulty systems based on type */
|
||||
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
|
||||
fun systemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
|
||||
when (climbType) {
|
||||
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }
|
||||
ClimbType.ROPE -> entries.filter { it.isRopeSystem() }
|
||||
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem }
|
||||
ClimbType.ROPE -> entries.filter { it.isRopeSystem }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,38 +153,78 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
||||
DifficultySystem.V_SCALE -> {
|
||||
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
|
||||
}
|
||||
DifficultySystem.YDS -> {
|
||||
when {
|
||||
grade.startsWith("5.10") ->
|
||||
10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
||||
grade.startsWith("5.11") ->
|
||||
14 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
||||
grade.startsWith("5.12") ->
|
||||
18 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
||||
grade.startsWith("5.13") ->
|
||||
22 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
||||
grade.startsWith("5.14") ->
|
||||
26 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
||||
grade.startsWith("5.15") ->
|
||||
30 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
||||
else -> grade.removePrefix("5.").toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
DifficultySystem.FONT -> {
|
||||
when {
|
||||
grade.startsWith("6A") -> 6
|
||||
grade.startsWith("6B") -> 7
|
||||
grade.startsWith("6C") -> 8
|
||||
grade.startsWith("7A") -> 9
|
||||
grade.startsWith("7B") -> 10
|
||||
grade.startsWith("7C") -> 11
|
||||
grade.startsWith("8A") -> 12
|
||||
grade.startsWith("8B") -> 13
|
||||
grade.startsWith("8C") -> 14
|
||||
else -> grade.toIntOrNull() ?: 0
|
||||
}
|
||||
val fontMapping: Map<String, Int> =
|
||||
mapOf(
|
||||
"3" to 3,
|
||||
"4A" to 4,
|
||||
"4B" to 5,
|
||||
"4C" to 6,
|
||||
"5A" to 7,
|
||||
"5B" to 8,
|
||||
"5C" to 9,
|
||||
"6A" to 10,
|
||||
"6A+" to 11,
|
||||
"6B" to 12,
|
||||
"6B+" to 13,
|
||||
"6C" to 14,
|
||||
"6C+" to 15,
|
||||
"7A" to 16,
|
||||
"7A+" to 17,
|
||||
"7B" to 18,
|
||||
"7B+" to 19,
|
||||
"7C" to 20,
|
||||
"7C+" to 21,
|
||||
"8A" to 22,
|
||||
"8A+" to 23,
|
||||
"8B" to 24,
|
||||
"8B+" to 25,
|
||||
"8C" to 26,
|
||||
"8C+" to 27
|
||||
)
|
||||
fontMapping[grade] ?: 0
|
||||
}
|
||||
DifficultySystem.CUSTOM -> grade.hashCode().rem(100)
|
||||
DifficultySystem.YDS -> {
|
||||
val ydsMapping: Map<String, Int> =
|
||||
mapOf(
|
||||
"5.0" to 50,
|
||||
"5.1" to 51,
|
||||
"5.2" to 52,
|
||||
"5.3" to 53,
|
||||
"5.4" to 54,
|
||||
"5.5" to 55,
|
||||
"5.6" to 56,
|
||||
"5.7" to 57,
|
||||
"5.8" to 58,
|
||||
"5.9" to 59,
|
||||
"5.10a" to 60,
|
||||
"5.10b" to 61,
|
||||
"5.10c" to 62,
|
||||
"5.10d" to 63,
|
||||
"5.11a" to 64,
|
||||
"5.11b" to 65,
|
||||
"5.11c" to 66,
|
||||
"5.11d" to 67,
|
||||
"5.12a" to 68,
|
||||
"5.12b" to 69,
|
||||
"5.12c" to 70,
|
||||
"5.12d" to 71,
|
||||
"5.13a" to 72,
|
||||
"5.13b" to 73,
|
||||
"5.13c" to 74,
|
||||
"5.13d" to 75,
|
||||
"5.14a" to 76,
|
||||
"5.14b" to 77,
|
||||
"5.14c" to 78,
|
||||
"5.14d" to 79,
|
||||
"5.15a" to 80,
|
||||
"5.15b" to 81,
|
||||
"5.15c" to 82,
|
||||
"5.15d" to 83
|
||||
)
|
||||
ydsMapping[grade] ?: 0
|
||||
}
|
||||
DifficultySystem.CUSTOM -> grade.toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,17 +130,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
|
||||
}
|
||||
|
||||
// Legacy accessor expected by some UI code (kept for compatibility)
|
||||
@Deprecated(
|
||||
message = "Use serverUrl (kebab case) instead",
|
||||
replaceWith = ReplaceWith("serverUrl")
|
||||
)
|
||||
var serverURL: String
|
||||
get() = serverUrl
|
||||
set(value) {
|
||||
serverUrl = value
|
||||
}
|
||||
|
||||
var authToken: String
|
||||
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
|
||||
set(value) {
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
package com.atridad.ascently.ui.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -20,7 +21,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -29,25 +29,29 @@ import kotlinx.coroutines.launch
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
|
||||
val thumbnailListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// Handle back button press
|
||||
BackHandler { onDismiss() }
|
||||
|
||||
// Auto-scroll thumbnail list to center current image
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200)
|
||||
if (imagePaths.size > 1) {
|
||||
thumbnailListState.animateScrollToItem(
|
||||
index = pagerState.currentPage,
|
||||
scrollOffset = -200
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black).systemBarsPadding()) {
|
||||
// Main image pager
|
||||
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||
OrientationAwareImage(
|
||||
@@ -58,76 +62,96 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
|
||||
)
|
||||
}
|
||||
|
||||
// Close button
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier =
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
||||
) { Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) }
|
||||
|
||||
// Image counter
|
||||
if (imagePaths.size > 1) {
|
||||
Card(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
// Top bar with back button and counter
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.TopStart),
|
||||
color = Color.Black.copy(alpha = 0.6f)
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
// Back button
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Image counter
|
||||
if (imagePaths.size > 1) {
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnail strip (if multiple images)
|
||||
// Thumbnail strip at bottom (if multiple images)
|
||||
if (imagePaths.size > 1) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||
color = Color.Black.copy(alpha = 0.6f)
|
||||
) {
|
||||
LazyRow(
|
||||
state = thumbnailListState,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||
contentPadding = PaddingValues(horizontal = 16.dp)
|
||||
) {
|
||||
itemsIndexed(imagePaths) { index, imagePath ->
|
||||
val isSelected = index == pagerState.currentPage
|
||||
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Thumbnail ${index + 1}",
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(60.dp)
|
||||
Modifier.size(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.background(
|
||||
Color.White.copy(
|
||||
alpha = 0.3f
|
||||
),
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
} else Modifier
|
||||
),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
) {
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Thumbnail ${index + 1}",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// Selection indicator
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.background(
|
||||
Color.White.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.background(
|
||||
Color.Transparent,
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
Color.White.copy(alpha = 0.2f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import android.graphics.Matrix
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.atridad.ascently.utils.ImageUtils
|
||||
import java.io.File
|
||||
@@ -52,7 +55,7 @@ fun OrientationAwareImage(
|
||||
|
||||
Box(modifier = modifier) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.fillMaxSize())
|
||||
CircularProgressIndicator(modifier = Modifier.size(32.dp).align(Alignment.Center))
|
||||
} else {
|
||||
imageBitmap?.let { bitmap ->
|
||||
Image(
|
||||
|
||||
@@ -31,14 +31,14 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
// 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) {
|
||||
@@ -86,313 +86,207 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
) {
|
||||
// Header with icon and title
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
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
|
||||
}
|
||||
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 = "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
|
||||
)
|
||||
}
|
||||
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 ->
|
||||
checked = isEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
coroutineScope.launch {
|
||||
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}"
|
||||
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
|
||||
}
|
||||
},
|
||||
enabled = isHealthConnectAvailable && !isLoading && isCompatible
|
||||
)
|
||||
}
|
||||
|
||||
if (isEnabled) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Card(
|
||||
|
||||
Text(
|
||||
text = "Climbing sessions will be automatically added to Health Connect when completed.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (!hasPermissions) {
|
||||
Spacer(modifier = Modifier.height(12.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,
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint =
|
||||
if (hasPermissions) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
)
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text =
|
||||
if (hasPermissions) "Ready to sync"
|
||||
else "Permissions needed",
|
||||
Text(
|
||||
text = "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
|
||||
)
|
||||
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}"
|
||||
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()
|
||||
}
|
||||
},
|
||||
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(
|
||||
} 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.",
|
||||
"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))
|
||||
)
|
||||
}
|
||||
errorMessage?.let { error ->
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Card(
|
||||
Card(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -401,40 +295,3 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
emptyList()
|
||||
} else {
|
||||
selectedClimbTypes
|
||||
.flatMap { climbType -> DifficultySystem.getSystemsForClimbType(climbType) }
|
||||
.flatMap { climbType -> DifficultySystem.systemsForClimbType(climbType) }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
onCheckedChange = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(climbType.getDisplayName())
|
||||
Text(climbType.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,7 +219,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
||||
onCheckedChange = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(system.getDisplayName())
|
||||
Text(system.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,6 @@ fun AddEditProblemScreen(
|
||||
) {
|
||||
val isEditing = problemId != null
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
// Problem form state
|
||||
var selectedGym by remember {
|
||||
@@ -295,7 +294,7 @@ fun AddEditProblemScreen(
|
||||
|
||||
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
|
||||
val availableDifficultySystems =
|
||||
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
|
||||
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
|
||||
selectedGym?.difficultySystems?.contains(system) != false
|
||||
}
|
||||
|
||||
@@ -324,7 +323,7 @@ fun AddEditProblemScreen(
|
||||
|
||||
// Reset grade when difficulty system changes (unless it's a valid grade for the new system)
|
||||
LaunchedEffect(selectedDifficultySystem) {
|
||||
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
||||
val availableGrades = selectedDifficultySystem.availableGrades
|
||||
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
|
||||
difficultyGrade = ""
|
||||
}
|
||||
@@ -386,13 +385,12 @@ fun AddEditProblemScreen(
|
||||
notes = notes.ifBlank { null }
|
||||
)
|
||||
|
||||
if (isEditing) {
|
||||
if (isEditing && problemId != null) {
|
||||
viewModel.updateProblem(
|
||||
problem.copy(id = problemId),
|
||||
context
|
||||
problem.copy(id = problemId)
|
||||
)
|
||||
} else {
|
||||
viewModel.addProblem(problem, context)
|
||||
viewModel.addProblem(problem)
|
||||
}
|
||||
onNavigateBack()
|
||||
}
|
||||
@@ -505,7 +503,7 @@ fun AddEditProblemScreen(
|
||||
availableClimbTypes.forEach { climbType ->
|
||||
FilterChip(
|
||||
onClick = { selectedClimbType = climbType },
|
||||
label = { Text(climbType.getDisplayName()) },
|
||||
label = { Text(climbType.displayName) },
|
||||
selected = selectedClimbType == climbType
|
||||
)
|
||||
}
|
||||
@@ -538,7 +536,7 @@ fun AddEditProblemScreen(
|
||||
items(availableDifficultySystems) { system ->
|
||||
FilterChip(
|
||||
onClick = { selectedDifficultySystem = system },
|
||||
label = { Text(system.getDisplayName()) },
|
||||
label = { Text(system.displayName) },
|
||||
selected = selectedDifficultySystem == system
|
||||
)
|
||||
}
|
||||
@@ -570,7 +568,7 @@ fun AddEditProblemScreen(
|
||||
)
|
||||
} else {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
||||
val availableGrades = selectedDifficultySystem.availableGrades
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
|
||||
@@ -17,8 +17,8 @@ import com.atridad.ascently.ui.components.BarChart
|
||||
import com.atridad.ascently.ui.components.BarChartDataPoint
|
||||
import com.atridad.ascently.ui.components.SyncIndicator
|
||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
||||
@@ -253,11 +253,8 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
systemFiltered.filter { dataPoint ->
|
||||
try {
|
||||
val attemptDate =
|
||||
LocalDateTime.parse(
|
||||
dataPoint.date,
|
||||
DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||
)
|
||||
attemptDate.isAfter(sevenDaysAgo)
|
||||
DateFormatUtils.parseToLocalDateTime(dataPoint.date)
|
||||
attemptDate?.isAfter(sevenDaysAgo) == true
|
||||
} catch (_: Exception) {
|
||||
// If date parsing fails, include the data point
|
||||
true
|
||||
|
||||
@@ -16,10 +16,8 @@ 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
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -30,16 +28,13 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.atridad.ascently.data.model.*
|
||||
import com.atridad.ascently.ui.components.FullscreenImageViewer
|
||||
import com.atridad.ascently.ui.components.ImageDisplaySection
|
||||
import com.atridad.ascently.ui.theme.CustomIcons
|
||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -221,7 +216,6 @@ fun SessionDetailScreen(
|
||||
val problems by viewModel.problems.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
|
||||
var isGeneratingShare by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var showAddAttemptDialog by remember { mutableStateOf(false) }
|
||||
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) }
|
||||
@@ -234,7 +228,7 @@ fun SessionDetailScreen(
|
||||
val successfulAttempts =
|
||||
attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
|
||||
val uniqueProblems = attempts.map { it.problemId }.distinct()
|
||||
val attemptedProblems = problems.filter { it.id in uniqueProblems }
|
||||
|
||||
val completedProblems = successfulAttempts.map { it.problemId }.distinct()
|
||||
|
||||
val attemptsWithProblems =
|
||||
@@ -261,64 +255,8 @@ 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(
|
||||
onClick = {
|
||||
isGeneratingShare = true
|
||||
viewModel.viewModelScope.launch {
|
||||
val shareFile =
|
||||
viewModel.generateSessionShareCard(
|
||||
context,
|
||||
sessionId
|
||||
)
|
||||
isGeneratingShare = false
|
||||
shareFile?.let { file ->
|
||||
viewModel.shareSessionCard(context, file)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isGeneratingShare
|
||||
) {
|
||||
if (isGeneratingShare) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Share Session"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// No manual actions needed - Health Connect syncs automatically when
|
||||
// sessions complete
|
||||
|
||||
// Show stop icon for active sessions, delete icon for completed
|
||||
// sessions
|
||||
@@ -564,7 +502,7 @@ fun SessionDetailScreen(
|
||||
viewModel.addAttempt(attempt)
|
||||
showAddAttemptDialog = false
|
||||
},
|
||||
onProblemCreated = { problem -> viewModel.addProblem(problem, context) }
|
||||
onProblemCreated = { problem -> viewModel.addProblem(problem) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -590,7 +528,7 @@ fun ProblemDetailScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToEdit: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var showImageViewer by remember { mutableStateOf(false) }
|
||||
var selectedImageIndex by remember { mutableIntStateOf(0) }
|
||||
@@ -665,7 +603,7 @@ fun ProblemDetailScreen(
|
||||
problem?.let { p ->
|
||||
Text(
|
||||
text =
|
||||
"${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}",
|
||||
"${p.difficulty.system.displayName}: ${p.difficulty.grade}",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
@@ -674,7 +612,7 @@ fun ProblemDetailScreen(
|
||||
|
||||
problem?.let { p ->
|
||||
Text(
|
||||
text = p.climbType.getDisplayName(),
|
||||
text = p.climbType.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -854,7 +792,7 @@ fun ProblemDetailScreen(
|
||||
TextButton(
|
||||
onClick = {
|
||||
problem?.let { p ->
|
||||
viewModel.deleteProblem(p, context)
|
||||
viewModel.deleteProblem(p)
|
||||
onNavigateBack()
|
||||
}
|
||||
showDeleteDialog = false
|
||||
@@ -1236,19 +1174,10 @@ fun GymDetailScreen(
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
val dateTime =
|
||||
try {
|
||||
LocalDateTime.parse(session.date)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val formattedDate =
|
||||
dateTime?.format(
|
||||
DateTimeFormatter.ofPattern(
|
||||
"MMM dd, yyyy"
|
||||
)
|
||||
DateFormatUtils.formatDateForDisplay(
|
||||
session.date
|
||||
)
|
||||
?: session.date
|
||||
|
||||
Text(
|
||||
"$formattedDate • ${sessionAttempts.size} attempts"
|
||||
@@ -1463,7 +1392,7 @@ fun SessionAttemptCard(
|
||||
|
||||
Text(
|
||||
text =
|
||||
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
|
||||
"${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
@@ -1538,14 +1467,7 @@ fun SessionAttemptCard(
|
||||
}
|
||||
|
||||
private fun formatDate(dateString: String): String {
|
||||
return try {
|
||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||
val date = LocalDateTime.parse(dateString, formatter)
|
||||
val displayFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy")
|
||||
date.format(displayFormatter)
|
||||
} catch (_: Exception) {
|
||||
dateString.take(10) // Fallback to just the date part
|
||||
}
|
||||
return DateFormatUtils.formatDateForDisplay(dateString)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -1584,7 +1506,7 @@ fun EnhancedAddAttemptDialog(
|
||||
// Auto-select difficulty system if there's only one available for the selected climb type
|
||||
LaunchedEffect(selectedClimbType, gym.difficultySystems) {
|
||||
val availableSystems =
|
||||
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
|
||||
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
|
||||
gym.difficultySystems.contains(system)
|
||||
}
|
||||
|
||||
@@ -1604,7 +1526,7 @@ fun EnhancedAddAttemptDialog(
|
||||
|
||||
// Reset grade when difficulty system changes
|
||||
LaunchedEffect(selectedDifficultySystem) {
|
||||
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
||||
val availableGrades = selectedDifficultySystem.availableGrades
|
||||
if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) {
|
||||
newProblemGrade = ""
|
||||
}
|
||||
@@ -1721,7 +1643,7 @@ fun EnhancedAddAttemptDialog(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text =
|
||||
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
|
||||
"${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
|
||||
style =
|
||||
MaterialTheme.typography
|
||||
.bodyMedium,
|
||||
@@ -1730,7 +1652,7 @@ fun EnhancedAddAttemptDialog(
|
||||
MaterialTheme
|
||||
.colorScheme
|
||||
.onSurface.copy(
|
||||
alpha = 0.8f
|
||||
alpha = 0.9f
|
||||
)
|
||||
else
|
||||
MaterialTheme
|
||||
@@ -1807,7 +1729,7 @@ fun EnhancedAddAttemptDialog(
|
||||
onClick = { selectedClimbType = climbType },
|
||||
label = {
|
||||
Text(
|
||||
climbType.getDisplayName(),
|
||||
climbType.displayName,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
@@ -1838,7 +1760,7 @@ fun EnhancedAddAttemptDialog(
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val availableSystems =
|
||||
DifficultySystem.getSystemsForClimbType(
|
||||
DifficultySystem.systemsForClimbType(
|
||||
selectedClimbType
|
||||
)
|
||||
.filter { system ->
|
||||
@@ -1849,7 +1771,7 @@ fun EnhancedAddAttemptDialog(
|
||||
onClick = { selectedDifficultySystem = system },
|
||||
label = {
|
||||
Text(
|
||||
system.getDisplayName(),
|
||||
system.displayName,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
@@ -1926,8 +1848,7 @@ fun EnhancedAddAttemptDialog(
|
||||
)
|
||||
} else {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val availableGrades =
|
||||
selectedDifficultySystem.getAvailableGrades()
|
||||
val availableGrades = selectedDifficultySystem.availableGrades
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
|
||||
@@ -87,7 +87,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
||||
gym.supportedClimbTypes.forEach { climbType ->
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(climbType.getDisplayName()) },
|
||||
label = { Text(climbType.displayName) },
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
@@ -97,7 +97,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text =
|
||||
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
||||
"Systems: ${gym.difficultySystems.joinToString(", ") { it.displayName }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -104,7 +104,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
items(ClimbType.entries) { climbType ->
|
||||
FilterChip(
|
||||
onClick = { selectedClimbType = climbType },
|
||||
label = { Text(climbType.getDisplayName()) },
|
||||
label = { Text(climbType.displayName) },
|
||||
selected = selectedClimbType == climbType
|
||||
)
|
||||
}
|
||||
@@ -183,7 +183,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
onClick = { onNavigateToProblemDetail(problem.id) },
|
||||
onToggleActive = {
|
||||
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
||||
viewModel.updateProblem(updatedProblem, context)
|
||||
viewModel.updateProblem(updatedProblem)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -268,7 +268,7 @@ fun ProblemCard(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = problem.climbType.getDisplayName(),
|
||||
text = problem.climbType.displayName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -22,8 +22,7 @@ import com.atridad.ascently.data.model.SessionStatus
|
||||
import com.atridad.ascently.ui.components.ActiveSessionBanner
|
||||
import com.atridad.ascently.ui.components.SyncIndicator
|
||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -247,10 +246,5 @@ fun EmptyStateMessage(
|
||||
}
|
||||
|
||||
private fun formatDate(dateString: String): String {
|
||||
return try {
|
||||
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
|
||||
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
|
||||
} catch (_: Exception) {
|
||||
dateString
|
||||
}
|
||||
return DateFormatUtils.formatDateForDisplay(dateString)
|
||||
}
|
||||
|
||||
@@ -50,15 +50,15 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
var isDeletingImages by remember { mutableStateOf(false) }
|
||||
|
||||
// Sync configuration state
|
||||
var serverUrl by remember { mutableStateOf(syncService.serverURL) }
|
||||
var serverUrl by remember { mutableStateOf(syncService.serverUrl) }
|
||||
var authToken by remember { mutableStateOf(syncService.authToken) }
|
||||
|
||||
val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) }
|
||||
val appVersion = packageInfo.versionName
|
||||
|
||||
// Update local state when sync service configuration changes
|
||||
LaunchedEffect(syncService.serverURL, syncService.authToken) {
|
||||
serverUrl = syncService.serverURL
|
||||
LaunchedEffect(syncService.serverUrl, syncService.authToken) {
|
||||
serverUrl = syncService.serverUrl
|
||||
authToken = syncService.authToken
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
},
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text("Server: ${syncService.serverURL}")
|
||||
Text("Server: ${syncService.serverUrl}")
|
||||
lastSyncTime?.let { time ->
|
||||
Text(
|
||||
"Last sync: ${
|
||||
@@ -863,7 +863,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
syncService.serverURL = serverUrl.trim()
|
||||
syncService.serverUrl = serverUrl.trim()
|
||||
syncService.authToken = authToken.trim()
|
||||
viewModel.testSyncConnection()
|
||||
while (syncService.isTesting.value) {
|
||||
@@ -905,7 +905,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
syncService.serverURL = serverUrl.trim()
|
||||
syncService.serverUrl = serverUrl.trim()
|
||||
syncService.authToken = authToken.trim()
|
||||
viewModel.testSyncConnection()
|
||||
while (syncService.isTesting.value) {
|
||||
@@ -932,7 +932,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
serverUrl = syncService.serverURL
|
||||
serverUrl = syncService.serverUrl
|
||||
authToken = syncService.authToken
|
||||
showSyncConfigDialog = false
|
||||
}
|
||||
@@ -981,7 +981,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
isDeletingImages = true
|
||||
showDeleteImagesDialog = false
|
||||
coroutineScope.launch {
|
||||
viewModel.deleteAllImages(context)
|
||||
viewModel.deleteAllImages()
|
||||
isDeletingImages = false
|
||||
viewModel.setMessage("All images deleted successfully!")
|
||||
}
|
||||
|
||||
@@ -8,15 +8,11 @@ import com.atridad.ascently.data.model.*
|
||||
import com.atridad.ascently.data.repository.ClimbRepository
|
||||
import com.atridad.ascently.data.sync.SyncService
|
||||
import com.atridad.ascently.service.SessionTrackingService
|
||||
import com.atridad.ascently.utils.ImageNamingUtils
|
||||
import com.atridad.ascently.utils.ImageUtils
|
||||
import com.atridad.ascently.utils.SessionShareUtils
|
||||
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ClimbViewModel(
|
||||
private val repository: ClimbRepository,
|
||||
@@ -78,64 +74,57 @@ class ClimbViewModel(
|
||||
)
|
||||
|
||||
// Gym operations
|
||||
fun addGym(gym: Gym) {
|
||||
viewModelScope.launch { repository.insertGym(gym) }
|
||||
}
|
||||
|
||||
fun addGym(gym: Gym, context: Context) {
|
||||
fun addGym(gym: Gym, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
repository.insertGym(gym)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateGym(gym: Gym) {
|
||||
viewModelScope.launch { repository.updateGym(gym) }
|
||||
}
|
||||
|
||||
fun updateGym(gym: Gym, context: Context) {
|
||||
fun updateGym(gym: Gym, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
repository.updateGym(gym)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteGym(gym: Gym) {
|
||||
viewModelScope.launch { repository.deleteGym(gym) }
|
||||
}
|
||||
|
||||
fun deleteGym(gym: Gym, context: Context) {
|
||||
fun deleteGym(gym: Gym, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteGym(gym)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
|
||||
|
||||
// Problem operations
|
||||
fun addProblem(problem: Problem, context: Context) {
|
||||
fun addProblem(problem: Problem, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
val finalProblem = renameTemporaryImages(problem, context)
|
||||
val finalProblem = renameTemporaryImages(problem)
|
||||
repository.insertProblem(finalProblem)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
// Auto-sync now happens automatically via repository callback
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem {
|
||||
private suspend fun renameTemporaryImages(problem: Problem): Problem {
|
||||
if (problem.imagePaths.isEmpty()) {
|
||||
return problem
|
||||
}
|
||||
|
||||
val appContext = context ?: return problem
|
||||
val finalImagePaths = mutableListOf<String>()
|
||||
|
||||
problem.imagePaths.forEachIndexed { index, tempPath ->
|
||||
if (tempPath.startsWith("temp_")) {
|
||||
val deterministicName = ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||
val finalPath =
|
||||
ImageUtils.renameTemporaryImage(appContext, tempPath, problem.id, index)
|
||||
ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index)
|
||||
finalImagePaths.add(finalPath ?: tempPath)
|
||||
} else {
|
||||
finalImagePaths.add(tempPath)
|
||||
@@ -145,34 +134,34 @@ class ClimbViewModel(
|
||||
return problem.copy(imagePaths = finalImagePaths)
|
||||
}
|
||||
|
||||
fun updateProblem(problem: Problem, context: Context) {
|
||||
fun updateProblem(problem: Problem, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
val finalProblem = renameTemporaryImages(problem, context)
|
||||
val finalProblem = renameTemporaryImages(problem)
|
||||
repository.updateProblem(finalProblem)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteProblem(problem: Problem, context: Context) {
|
||||
fun deleteProblem(problem: Problem, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
// Delete associated images
|
||||
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
|
||||
|
||||
repository.deleteProblem(problem)
|
||||
|
||||
cleanupOrphanedImages(context)
|
||||
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
cleanupOrphanedImages()
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupOrphanedImages(context: Context) {
|
||||
private suspend fun cleanupOrphanedImages() {
|
||||
val allProblems = repository.getAllProblems().first()
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
|
||||
}
|
||||
|
||||
fun deleteAllImages(context: Context) {
|
||||
fun deleteAllImages() {
|
||||
viewModelScope.launch {
|
||||
val imagesDir = ImageUtils.getImagesDirectory(context)
|
||||
var deletedCount = 0
|
||||
@@ -212,36 +201,30 @@ class ClimbViewModel(
|
||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
|
||||
|
||||
// Session operations
|
||||
fun addSession(session: ClimbSession) {
|
||||
viewModelScope.launch { repository.insertSession(session) }
|
||||
}
|
||||
|
||||
fun addSession(session: ClimbSession, context: Context) {
|
||||
fun addSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
repository.insertSession(session)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSession(session: ClimbSession) {
|
||||
viewModelScope.launch { repository.updateSession(session) }
|
||||
}
|
||||
|
||||
fun updateSession(session: ClimbSession, context: Context) {
|
||||
fun updateSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
repository.updateSession(session)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSession(session: ClimbSession) {
|
||||
viewModelScope.launch { repository.deleteSession(session) }
|
||||
}
|
||||
|
||||
fun deleteSession(session: ClimbSession, context: Context) {
|
||||
fun deleteSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteSession(session)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,36 +328,30 @@ class ClimbViewModel(
|
||||
}
|
||||
|
||||
// Attempt operations
|
||||
fun addAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch { repository.insertAttempt(attempt) }
|
||||
}
|
||||
|
||||
fun addAttempt(attempt: Attempt, context: Context) {
|
||||
fun addAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
repository.insertAttempt(attempt)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch { repository.deleteAttempt(attempt) }
|
||||
}
|
||||
|
||||
fun deleteAttempt(attempt: Attempt, context: Context) {
|
||||
fun deleteAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteAttempt(attempt)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch { repository.updateAttempt(attempt) }
|
||||
}
|
||||
|
||||
fun updateAttempt(attempt: Attempt, context: Context) {
|
||||
fun updateAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
repository.updateAttempt(attempt)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
if (updateWidgets) {
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,107 +476,30 @@ class ClimbViewModel(
|
||||
val attempts = repository.getAttemptsBySession(session.id).first()
|
||||
val attemptCount = attempts.size
|
||||
|
||||
val result = healthConnectManager.autoSyncSession(session, gymName, attemptCount)
|
||||
val result =
|
||||
healthConnectManager.autoSyncCompletedSession(
|
||||
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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
result.onFailure { error ->
|
||||
if (healthConnectManager.isReadySync()) {
|
||||
android.util.Log.w(
|
||||
"ClimbViewModel",
|
||||
"Health Connect sync failed: ${error.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (healthConnectManager.isReadySync()) {
|
||||
_uiState.value =
|
||||
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
|
||||
android.util.Log.w("ClimbViewModel", "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) {
|
||||
try {
|
||||
val session = repository.getSessionById(sessionId) ?: return@withContext null
|
||||
val attempts = repository.getAttemptsBySession(sessionId).first()
|
||||
val problems =
|
||||
repository.getAllProblems().first().filter { problem ->
|
||||
attempts.any { it.problemId == problem.id }
|
||||
}
|
||||
val gym = repository.getGymById(session.gymId) ?: return@withContext null
|
||||
|
||||
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
|
||||
SessionShareUtils.generateShareCard(context, session, gym, stats)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
error = "Failed to generate share card: ${e.message}"
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun shareSessionCard(context: Context, imageFile: File) {
|
||||
SessionShareUtils.shareSessionCard(context, imageFile)
|
||||
}
|
||||
}
|
||||
|
||||
data class ClimbUiState(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.atridad.ascently.utils
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@@ -43,4 +45,33 @@ object DateFormatUtils {
|
||||
fun millisToISO8601(millis: Long): String {
|
||||
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a UTC ISO 8601 date string for display in local timezone This fixes the timezone
|
||||
* display issue where UTC dates were shown as local dates
|
||||
*/
|
||||
fun formatDateForDisplay(dateString: String, pattern: String = "MMM dd, yyyy"): String {
|
||||
return try {
|
||||
val instant = parseISO8601(dateString)
|
||||
if (instant != null) {
|
||||
val localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault())
|
||||
localDateTime.format(DateTimeFormatter.ofPattern(pattern))
|
||||
} else {
|
||||
// Fallback for malformed dates
|
||||
dateString.take(10)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
dateString.take(10)
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a UTC ISO 8601 date string to LocalDateTime in system timezone */
|
||||
fun parseToLocalDateTime(dateString: String): LocalDateTime? {
|
||||
return try {
|
||||
val instant = parseISO8601(dateString)
|
||||
instant?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ object ImageNamingUtils {
|
||||
}
|
||||
|
||||
/** Legacy method for backward compatibility */
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
|
||||
return generateImageFilename(problemId, imageIndex)
|
||||
}
|
||||
@@ -97,6 +98,26 @@ object ImageNamingUtils {
|
||||
}
|
||||
|
||||
/** Creates a mapping of existing server filenames to canonical filenames */
|
||||
/** Validates that a collection of filenames follow our naming convention */
|
||||
fun validateFilenames(filenames: List<String>): ImageValidationResult {
|
||||
val validImages = mutableListOf<String>()
|
||||
val invalidImages = mutableListOf<String>()
|
||||
|
||||
for (filename in filenames) {
|
||||
if (isValidImageFilename(filename)) {
|
||||
validImages.add(filename)
|
||||
} else {
|
||||
invalidImages.add(filename)
|
||||
}
|
||||
}
|
||||
|
||||
return ImageValidationResult(
|
||||
totalImages = filenames.size,
|
||||
validImages = validImages,
|
||||
invalidImages = invalidImages
|
||||
)
|
||||
}
|
||||
|
||||
fun createServerMigrationMap(
|
||||
problemId: String,
|
||||
serverImageFilenames: List<String>,
|
||||
@@ -124,3 +145,16 @@ object ImageNamingUtils {
|
||||
return migrationMap
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of image filename validation */
|
||||
data class ImageValidationResult(
|
||||
val totalImages: Int,
|
||||
val validImages: List<String>,
|
||||
val invalidImages: List<String>
|
||||
) {
|
||||
val isAllValid: Boolean
|
||||
get() = invalidImages.isEmpty()
|
||||
|
||||
val validPercentage: Double
|
||||
get() = if (totalImages > 0) (validImages.size.toDouble() / totalImages) * 100.0 else 100.0
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import androidx.core.graphics.toColorInt
|
||||
import com.atridad.ascently.data.model.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object SessionShareUtils {
|
||||
@@ -457,14 +455,7 @@ object SessionShareUtils {
|
||||
}
|
||||
|
||||
private fun formatSessionDate(dateString: String): String {
|
||||
return try {
|
||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||
val date = LocalDateTime.parse(dateString, formatter)
|
||||
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
|
||||
date.format(displayFormatter)
|
||||
} catch (_: Exception) {
|
||||
dateString.take(10)
|
||||
}
|
||||
return DateFormatUtils.formatDateForDisplay(dateString, "MMMM dd, yyyy")
|
||||
}
|
||||
|
||||
fun shareSessionCard(context: Context, imageFile: File) {
|
||||
@@ -512,7 +503,7 @@ object SessionShareUtils {
|
||||
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
|
||||
}
|
||||
DifficultySystem.FONT -> {
|
||||
val list = DifficultySystem.FONT.getAvailableGrades()
|
||||
val list = DifficultySystem.FONT.availableGrades
|
||||
val idx = list.indexOf(grade.uppercase())
|
||||
if (idx >= 0) idx.toDouble()
|
||||
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
|
||||
|
||||
@@ -18,8 +18,8 @@ class DataModelTests {
|
||||
|
||||
@Test
|
||||
fun testClimbTypeDisplayNames() {
|
||||
assertEquals("Rope", ClimbType.ROPE.getDisplayName())
|
||||
assertEquals("Bouldering", ClimbType.BOULDER.getDisplayName())
|
||||
assertEquals("Rope", ClimbType.ROPE.displayName)
|
||||
assertEquals("Bouldering", ClimbType.BOULDER.displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -34,58 +34,58 @@ class DataModelTests {
|
||||
|
||||
@Test
|
||||
fun testDifficultySystemDisplayNames() {
|
||||
assertEquals("V Scale", DifficultySystem.V_SCALE.getDisplayName())
|
||||
assertEquals("YDS (Yosemite)", DifficultySystem.YDS.getDisplayName())
|
||||
assertEquals("Font Scale", DifficultySystem.FONT.getDisplayName())
|
||||
assertEquals("Custom", DifficultySystem.CUSTOM.getDisplayName())
|
||||
assertEquals("V Scale", DifficultySystem.V_SCALE.displayName)
|
||||
assertEquals("YDS (Yosemite)", DifficultySystem.YDS.displayName)
|
||||
assertEquals("Font Scale", DifficultySystem.FONT.displayName)
|
||||
assertEquals("Custom", DifficultySystem.CUSTOM.displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDifficultySystemClimbTypeCompatibility() {
|
||||
// Test bouldering systems
|
||||
assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem())
|
||||
assertTrue(DifficultySystem.FONT.isBoulderingSystem())
|
||||
assertFalse(DifficultySystem.YDS.isBoulderingSystem())
|
||||
assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem())
|
||||
assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem)
|
||||
assertTrue(DifficultySystem.FONT.isBoulderingSystem)
|
||||
assertFalse(DifficultySystem.YDS.isBoulderingSystem)
|
||||
assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem)
|
||||
|
||||
// Test rope systems
|
||||
assertTrue(DifficultySystem.YDS.isRopeSystem())
|
||||
assertFalse(DifficultySystem.V_SCALE.isRopeSystem())
|
||||
assertFalse(DifficultySystem.FONT.isRopeSystem())
|
||||
assertTrue(DifficultySystem.CUSTOM.isRopeSystem())
|
||||
assertTrue(DifficultySystem.YDS.isRopeSystem)
|
||||
assertFalse(DifficultySystem.V_SCALE.isRopeSystem)
|
||||
assertFalse(DifficultySystem.FONT.isRopeSystem)
|
||||
assertTrue(DifficultySystem.CUSTOM.isRopeSystem)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDifficultySystemAvailableGrades() {
|
||||
val vScaleGrades = DifficultySystem.V_SCALE.getAvailableGrades()
|
||||
val vScaleGrades = DifficultySystem.V_SCALE.availableGrades
|
||||
assertTrue(vScaleGrades.contains("VB"))
|
||||
assertTrue(vScaleGrades.contains("V0"))
|
||||
assertTrue(vScaleGrades.contains("V17"))
|
||||
assertEquals("VB", vScaleGrades.first())
|
||||
|
||||
val ydsGrades = DifficultySystem.YDS.getAvailableGrades()
|
||||
val ydsGrades = DifficultySystem.YDS.availableGrades
|
||||
assertTrue(ydsGrades.contains("5.0"))
|
||||
assertTrue(ydsGrades.contains("5.15d"))
|
||||
assertTrue(ydsGrades.contains("5.10a"))
|
||||
|
||||
val fontGrades = DifficultySystem.FONT.getAvailableGrades()
|
||||
val fontGrades = DifficultySystem.FONT.availableGrades
|
||||
assertTrue(fontGrades.contains("3"))
|
||||
assertTrue(fontGrades.contains("8C+"))
|
||||
assertTrue(fontGrades.contains("6A"))
|
||||
|
||||
val customGrades = DifficultySystem.CUSTOM.getAvailableGrades()
|
||||
val customGrades = DifficultySystem.CUSTOM.availableGrades
|
||||
assertTrue(customGrades.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDifficultySystemsForClimbType() {
|
||||
val boulderSystems = DifficultySystem.getSystemsForClimbType(ClimbType.BOULDER)
|
||||
val boulderSystems = DifficultySystem.systemsForClimbType(ClimbType.BOULDER)
|
||||
assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE))
|
||||
assertTrue(boulderSystems.contains(DifficultySystem.FONT))
|
||||
assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM))
|
||||
assertFalse(boulderSystems.contains(DifficultySystem.YDS))
|
||||
|
||||
val ropeSystems = DifficultySystem.getSystemsForClimbType(ClimbType.ROPE)
|
||||
val ropeSystems = DifficultySystem.systemsForClimbType(ClimbType.ROPE)
|
||||
assertTrue(ropeSystems.contains(DifficultySystem.YDS))
|
||||
assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM))
|
||||
assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE))
|
||||
|
||||
Reference in New Issue
Block a user