From 5a49b9f0b2a8fa25a9f38a8d5acd72aebb26dd4b Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Tue, 14 Oct 2025 23:35:15 -0600 Subject: [PATCH] [Android] 2.0.1 - Refactoring & Minor Optimizations --- android/app/build.gradle.kts | 4 +- .../ascently/data/health/HealthConnectStub.kt | 97 ++--- .../atridad/ascently/data/model/Attempt.kt | 15 +- .../ascently/data/model/ClimbSession.kt | 10 +- .../atridad/ascently/data/model/ClimbType.kt | 15 +- .../ascently/data/model/DifficultySystem.kt | 335 +++++++------- .../atridad/ascently/data/sync/SyncService.kt | 11 - .../ui/components/FullscreenImageViewer.kt | 144 +++--- .../ui/components/OrientationAwareImage.kt | 5 +- .../ascently/ui/health/HealthConnectCard.kt | 409 ++++++------------ .../ascently/ui/screens/AddEditScreens.kt | 24 +- .../ascently/ui/screens/AnalyticsScreen.kt | 9 +- .../ascently/ui/screens/DetailScreens.kt | 121 +----- .../atridad/ascently/ui/screens/GymsScreen.kt | 4 +- .../ascently/ui/screens/ProblemsScreen.kt | 6 +- .../ascently/ui/screens/SessionsScreen.kt | 10 +- .../ascently/ui/screens/SettingsScreen.kt | 16 +- .../ascently/ui/viewmodel/ClimbViewModel.kt | 240 +++------- .../atridad/ascently/utils/DateFormatUtils.kt | 31 ++ .../ascently/utils/ImageNamingUtils.kt | 34 ++ .../ascently/utils/SessionShareUtils.kt | 13 +- .../com/atridad/openclimb/DataModelTests.kt | 40 +- 22 files changed, 674 insertions(+), 919 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9793adc..ff92738 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" } diff --git a/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt b/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt index a8ce0e2..03a3227 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt @@ -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() - - 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 { 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 { 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 { - 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" diff --git a/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt b/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt index 4951e7d..4681e7c 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt @@ -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( ) } } - } diff --git a/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt b/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt index af1e026..50b794e 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt @@ -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( diff --git a/android/app/src/main/java/com/atridad/ascently/data/model/ClimbType.kt b/android/app/src/main/java/com/atridad/ascently/data/model/ClimbType.kt index 841f89a..4fd258c 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/model/ClimbType.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/model/ClimbType.kt @@ -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" + } } diff --git a/android/app/src/main/java/com/atridad/ascently/data/model/DifficultySystem.kt b/android/app/src/main/java/com/atridad/ascently/data/model/DifficultySystem.kt index 8750109..2cb1732 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/model/DifficultySystem.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/model/DifficultySystem.kt @@ -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 = - 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 + 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 = + fun systemsForClimbType(climbType: ClimbType): List = 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 = + 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 = + 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 } } } diff --git a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt index cae5434..1d831f9 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt @@ -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) { diff --git a/android/app/src/main/java/com/atridad/ascently/ui/components/FullscreenImageViewer.kt b/android/app/src/main/java/com/atridad/ascently/ui/components/FullscreenImageViewer.kt index 887d8eb..d219a51 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/components/FullscreenImageViewer.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/components/FullscreenImageViewer.kt @@ -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, 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, 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) + ) + ) + } + } } } } diff --git a/android/app/src/main/java/com/atridad/ascently/ui/components/OrientationAwareImage.kt b/android/app/src/main/java/com/atridad/ascently/ui/components/OrientationAwareImage.kt index cb0ce38..8c8b32e 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/components/OrientationAwareImage.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/components/OrientationAwareImage.kt @@ -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( diff --git a/android/app/src/main/java/com/atridad/ascently/ui/health/HealthConnectCard.kt b/android/app/src/main/java/com/atridad/ascently/ui/health/HealthConnectCard.kt index 086722f..ecc27da 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/health/HealthConnectCard.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/health/HealthConnectCard.kt @@ -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(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 - ) - } - } - } -} diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/AddEditScreens.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/AddEditScreens.kt index dc21a82..e60ce8f 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/AddEditScreens.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/AddEditScreens.kt @@ -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, diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/AnalyticsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/AnalyticsScreen.kt index 964f647..a41ba16 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/AnalyticsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/AnalyticsScreen.kt @@ -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 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 diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt index 8370e7e..31dfc74 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt @@ -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(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, diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/GymsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/GymsScreen.kt index c889c62..021bd05 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/GymsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/GymsScreen.kt @@ -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 ) diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt index e97033c..bfb6842 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt @@ -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 ) diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt index f250ee5..84a8ab2 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt @@ -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) } diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt index 05420fc..88f57d2 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/SettingsScreen.kt @@ -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!") } diff --git a/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt b/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt index 361801c..feb3462 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/viewmodel/ClimbViewModel.kt @@ -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 = 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() 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> = 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( diff --git a/android/app/src/main/java/com/atridad/ascently/utils/DateFormatUtils.kt b/android/app/src/main/java/com/atridad/ascently/utils/DateFormatUtils.kt index 2c32fca..6fe7d7b 100644 --- a/android/app/src/main/java/com/atridad/ascently/utils/DateFormatUtils.kt +++ b/android/app/src/main/java/com/atridad/ascently/utils/DateFormatUtils.kt @@ -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 + } + } } diff --git a/android/app/src/main/java/com/atridad/ascently/utils/ImageNamingUtils.kt b/android/app/src/main/java/com/atridad/ascently/utils/ImageNamingUtils.kt index dfdf41f..371d664 100644 --- a/android/app/src/main/java/com/atridad/ascently/utils/ImageNamingUtils.kt +++ b/android/app/src/main/java/com/atridad/ascently/utils/ImageNamingUtils.kt @@ -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): ImageValidationResult { + val validImages = mutableListOf() + val invalidImages = mutableListOf() + + 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, @@ -124,3 +145,16 @@ object ImageNamingUtils { return migrationMap } } + +/** Result of image filename validation */ +data class ImageValidationResult( + val totalImages: Int, + val validImages: List, + val invalidImages: List +) { + val isAllValid: Boolean + get() = invalidImages.isEmpty() + + val validPercentage: Double + get() = if (totalImages > 0) (validImages.size.toDouble() / totalImages) * 100.0 else 100.0 +} diff --git a/android/app/src/main/java/com/atridad/ascently/utils/SessionShareUtils.kt b/android/app/src/main/java/com/atridad/ascently/utils/SessionShareUtils.kt index 789a312..b027f69 100644 --- a/android/app/src/main/java/com/atridad/ascently/utils/SessionShareUtils.kt +++ b/android/app/src/main/java/com/atridad/ascently/utils/SessionShareUtils.kt @@ -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 diff --git a/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt b/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt index 0fcda04..23512cb 100644 --- a/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt +++ b/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt @@ -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))