[Android] 2.0.1 - Refactoring & Minor Optimizations

This commit is contained in:
2025-10-14 23:35:15 -06:00
parent 7601e7bb03
commit 5a49b9f0b2
22 changed files with 674 additions and 919 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.ascently" applicationId = "com.atridad.ascently"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 40 versionCode = 41
versionName = "2.0.0" versionName = "2.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -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() preferences.edit().putBoolean("enabled", enabled).apply()
_isEnabled.value = enabled _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) setPermissionsGranted(false)
} }
} }
@@ -147,63 +160,6 @@ class HealthConnectManager(private val context: Context) {
return PermissionController.createRequestPermissionResultContract() 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 */ /** Get required permissions as strings */
fun getRequiredPermissions(): Set<String> { fun getRequiredPermissions(): Set<String> {
return try { 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") @SuppressLint("RestrictedApi")
suspend fun syncClimbingSession( suspend fun syncCompletedSession(
session: ClimbSession, session: ClimbSession,
gymName: String, gymName: String,
attemptCount: Int = 0 attemptCount: Int = 0
): Result<Unit> { ): Result<Unit> {
return try { return try {
if (!isReady()) { if (!isReady() || !_autoSync.value) {
return Result.failure(IllegalStateException("Health Connect not ready")) return Result.failure(
IllegalStateException("Health Connect not ready or auto-sync disabled")
)
} }
if (session.status != SessionStatus.COMPLETED) { if (session.status != SessionStatus.COMPLETED) {
@@ -320,18 +278,19 @@ class HealthConnectManager(private val context: Context) {
} }
} }
/** Auto-sync a session if enabled */ /** Auto-sync a completed session if enabled - this is the only way to sync sessions */
suspend fun autoSyncSession( suspend fun autoSyncCompletedSession(
session: ClimbSession, session: ClimbSession,
gymName: String, gymName: String,
attemptCount: Int = 0 attemptCount: Int = 0
): Result<Unit> { ): Result<Unit> {
return if (_autoSync.value && isReady()) { return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
Log.d(TAG, "Auto-syncing session '${session.id}' to Health Connect...") Log.d(TAG, "Auto-syncing completed session '${session.id}' to Health Connect...")
syncClimbingSession(session, gymName, attemptCount) syncCompletedSession(session, gymName, attemptCount)
} else { } else {
val reason = val reason =
when { when {
session.status != SessionStatus.COMPLETED -> "session not completed"
!_autoSync.value -> "auto-sync disabled" !_autoSync.value -> "auto-sync disabled"
!isReady() -> "Health Connect not ready" !isReady() -> "Health Connect not ready"
else -> "unknown reason" else -> "unknown reason"

View File

@@ -12,7 +12,19 @@ enum class AttemptResult {
SUCCESS, SUCCESS,
FALL, FALL,
NO_PROGRESS, 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( @Entity(
@@ -74,5 +86,4 @@ data class Attempt(
) )
} }
} }
} }

View File

@@ -11,7 +11,15 @@ import kotlinx.serialization.Serializable
enum class SessionStatus { enum class SessionStatus {
ACTIVE, ACTIVE,
COMPLETED, COMPLETED,
PAUSED PAUSED;
val displayName: String
get() =
when (this) {
ACTIVE -> "Active"
COMPLETED -> "Completed"
PAUSED -> "Paused"
}
} }
@Entity( @Entity(

View File

@@ -7,10 +7,9 @@ enum class ClimbType {
ROPE, ROPE,
BOULDER; BOULDER;
/** val displayName: String
* Get the display name get() =
*/ when (this) {
fun getDisplayName(): String = when (this) {
ROPE -> "Rope" ROPE -> "Rope"
BOULDER -> "Bouldering" BOULDER -> "Bouldering"
} }

View File

@@ -12,8 +12,8 @@ enum class DifficultySystem {
YDS, YDS,
CUSTOM; CUSTOM;
/** Get the display name for the UI */ val displayName: String
fun getDisplayName(): String = get() =
when (this) { when (this) {
V_SCALE -> "V Scale" V_SCALE -> "V Scale"
FONT -> "Font Scale" FONT -> "Font Scale"
@@ -21,24 +21,24 @@ enum class DifficultySystem {
CUSTOM -> "Custom" CUSTOM -> "Custom"
} }
/** Check if this system is for bouldering */ val isBoulderingSystem: Boolean
fun isBoulderingSystem(): Boolean = get() =
when (this) { when (this) {
V_SCALE, FONT -> true V_SCALE, FONT -> true
YDS -> false YDS -> false
CUSTOM -> true CUSTOM -> true
} }
/** Check if this system is for rope climbing */ val isRopeSystem: Boolean
fun isRopeSystem(): Boolean = get() =
when (this) { when (this) {
YDS -> true YDS -> true
V_SCALE, FONT -> false V_SCALE, FONT -> false
CUSTOM -> true CUSTOM -> true
} }
/** Get available grades for this system */ val availableGrades: List<String>
fun getAvailableGrades(): List<String> = get() =
when (this) { when (this) {
V_SCALE -> V_SCALE ->
listOf( listOf(
@@ -131,11 +131,10 @@ enum class DifficultySystem {
} }
companion object { companion object {
/** Get all difficulty systems based on type */ fun systemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
when (climbType) { when (climbType) {
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() } ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem }
ClimbType.ROPE -> entries.filter { it.isRopeSystem() } ClimbType.ROPE -> entries.filter { it.isRopeSystem }
} }
} }
} }
@@ -154,38 +153,78 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
DifficultySystem.V_SCALE -> { DifficultySystem.V_SCALE -> {
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0 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 -> { DifficultySystem.FONT -> {
when { val fontMapping: Map<String, Int> =
grade.startsWith("6A") -> 6 mapOf(
grade.startsWith("6B") -> 7 "3" to 3,
grade.startsWith("6C") -> 8 "4A" to 4,
grade.startsWith("7A") -> 9 "4B" to 5,
grade.startsWith("7B") -> 10 "4C" to 6,
grade.startsWith("7C") -> 11 "5A" to 7,
grade.startsWith("8A") -> 12 "5B" to 8,
grade.startsWith("8B") -> 13 "5C" to 9,
grade.startsWith("8C") -> 14 "6A" to 10,
else -> grade.toIntOrNull() ?: 0 "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.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.hashCode().rem(100) DifficultySystem.CUSTOM -> grade.toIntOrNull() ?: 0
} }
} }
} }

View File

@@ -130,17 +130,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) } 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 var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: "" get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
set(value) { set(value) {

View File

@@ -1,18 +1,19 @@
package com.atridad.ascently.ui.components package com.atridad.ascently.ui.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -20,7 +21,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
@@ -29,25 +29,29 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) { fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
val context = LocalContext.current
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size }) val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
val thumbnailListState = rememberLazyListState() val thumbnailListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
// Handle back button press
BackHandler { onDismiss() }
// Auto-scroll thumbnail list to center current image // Auto-scroll thumbnail list to center current image
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200) if (imagePaths.size > 1) {
thumbnailListState.animateScrollToItem(
index = pagerState.currentPage,
scrollOffset = -200
)
}
} }
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = properties =
DialogProperties( DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true)
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) { ) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { Box(modifier = Modifier.fillMaxSize().background(Color.Black).systemBarsPadding()) {
// Main image pager // Main image pager
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
OrientationAwareImage( OrientationAwareImage(
@@ -58,76 +62,96 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
) )
} }
// Close button // Top bar with back button and counter
IconButton( Surface(
onClick = onDismiss, modifier = Modifier.fillMaxWidth().align(Alignment.TopStart),
color = Color.Black.copy(alpha = 0.6f)
) {
Row(
modifier = modifier =
Modifier.align(Alignment.TopEnd) Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp),
.padding(16.dp) verticalAlignment = Alignment.CenterVertically
.background(Color.Black.copy(alpha = 0.5f), CircleShape) ) {
) { Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) } // Back button
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close",
tint = Color.White
)
}
Spacer(modifier = Modifier.weight(1f))
// Image counter // Image counter
if (imagePaths.size > 1) { if (imagePaths.size > 1) {
Card(
modifier = Modifier.align(Alignment.TopCenter).padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.7f)
)
) {
Text( Text(
text = "${pagerState.currentPage + 1} / ${imagePaths.size}", text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
color = Color.White, color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) 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) { if (imagePaths.size > 1) {
Card( Surface(
modifier = modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
Modifier.align(Alignment.BottomCenter) color = Color.Black.copy(alpha = 0.6f)
.fillMaxWidth()
.padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.7f)
)
) { ) {
LazyRow( LazyRow(
state = thumbnailListState, state = thumbnailListState,
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 8.dp) contentPadding = PaddingValues(horizontal = 16.dp)
) { ) {
itemsIndexed(imagePaths) { index, imagePath -> itemsIndexed(imagePaths) { index, imagePath ->
val isSelected = index == pagerState.currentPage val isSelected = index == pagerState.currentPage
OrientationAwareImage( Box(
imagePath = imagePath,
contentDescription = "Thumbnail ${index + 1}",
modifier = modifier =
Modifier.size(60.dp) Modifier.size(48.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { .clickable {
coroutineScope.launch { coroutineScope.launch {
pagerState.animateScrollToPage(index) pagerState.animateScrollToPage(index)
} }
} }
.then( ) {
if (isSelected) { OrientationAwareImage(
Modifier.background( imagePath = imagePath,
Color.White.copy( contentDescription = "Thumbnail ${index + 1}",
alpha = 0.3f modifier = Modifier.fillMaxSize(),
),
RoundedCornerShape(8.dp)
)
} else Modifier
),
contentScale = ContentScale.Crop 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)
)
)
}
}
} }
} }
} }

View File

@@ -5,12 +5,15 @@ import android.graphics.Matrix
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.atridad.ascently.utils.ImageUtils import com.atridad.ascently.utils.ImageUtils
import java.io.File import java.io.File
@@ -52,7 +55,7 @@ fun OrientationAwareImage(
Box(modifier = modifier) { Box(modifier = modifier) {
if (isLoading) { if (isLoading) {
CircularProgressIndicator(modifier = Modifier.fillMaxSize()) CircularProgressIndicator(modifier = Modifier.size(32.dp).align(Alignment.Center))
} else { } else {
imageBitmap?.let { bitmap -> imageBitmap?.let { bitmap ->
Image( Image(

View File

@@ -31,14 +31,14 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
// Collect flows // Collect flows
val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false) val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false)
val hasPermissions by healthConnectManager.hasPermissions.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) val isCompatible by healthConnectManager.isCompatible.collectAsState(initial = true)
// Permission launcher // Permission launcher
val permissionLauncher = val permissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = healthConnectManager.getPermissionRequestContract() contract = healthConnectManager.getPermissionRequestContract()
) { grantedPermissions -> ) { _ ->
coroutineScope.launch { coroutineScope.launch {
val allGranted = healthConnectManager.hasAllPermissions() val allGranted = healthConnectManager.hasAllPermissions()
if (!allGranted) { if (!allGranted) {
@@ -128,12 +128,15 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
MaterialTheme.colorScheme.onSurfaceVariant.copy( MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f alpha = 0.7f
) )
!isCompatible -> MaterialTheme.colorScheme.error !isCompatible -> MaterialTheme.colorScheme.error
!isHealthConnectAvailable -> MaterialTheme.colorScheme.error !isHealthConnectAvailable -> MaterialTheme.colorScheme.error
isEnabled && hasPermissions -> isEnabled && hasPermissions ->
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
isEnabled && !hasPermissions -> isEnabled && !hasPermissions ->
MaterialTheme.colorScheme.tertiary MaterialTheme.colorScheme.tertiary
else -> else ->
MaterialTheme.colorScheme.onSurfaceVariant.copy( MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f alpha = 0.7f
@@ -146,9 +149,9 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
Switch( Switch(
checked = isEnabled, checked = isEnabled,
onCheckedChange = { enabled -> onCheckedChange = { enabled ->
coroutineScope.launch {
if (enabled && isHealthConnectAvailable) { if (enabled && isHealthConnectAvailable) {
healthConnectManager.setEnabled(true) healthConnectManager.setEnabled(true)
coroutineScope.launch {
try { try {
val permissionSet = val permissionSet =
healthConnectManager.getRequiredPermissions() healthConnectManager.getRequiredPermissions()
@@ -158,11 +161,11 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
} catch (e: Exception) { } catch (e: Exception) {
errorMessage = "Error requesting permissions: ${e.message}" errorMessage = "Error requesting permissions: ${e.message}"
} }
}
} else { } else {
healthConnectManager.setEnabled(false) healthConnectManager.setEnabled(false)
errorMessage = null errorMessage = null
} }
}
}, },
enabled = isHealthConnectAvailable && !isLoading && isCompatible enabled = isHealthConnectAvailable && !isLoading && isCompatible
) )
@@ -170,51 +173,46 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
if (isEnabled) { if (isEnabled) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
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( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
if (hasPermissions) {
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.3f
)
} else {
MaterialTheme.colorScheme.errorContainer.copy( MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f alpha = 0.3f
) )
}
) )
) { ) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = imageVector = Icons.Default.Warning,
if (hasPermissions) Icons.Default.CheckCircle
else Icons.Default.Warning,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = tint = MaterialTheme.colorScheme.error
if (hasPermissions) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = text = "Permissions needed",
if (hasPermissions) "Ready to sync"
else "Permissions needed",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
if (!hasPermissions) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
@@ -249,50 +247,6 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
) { Text("Grant Permissions") } ) { 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 { } else {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
@@ -337,103 +291,6 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
} }
} }
} }
if (isEnabled) {
Spacer(modifier = Modifier.height(12.dp))
var testResult by remember { mutableStateOf<String?>(null) }
var isTestRunning by remember { mutableStateOf(false) }
OutlinedButton(
onClick = {
isTestRunning = true
coroutineScope.launch {
try {
testResult = healthConnectManager.testHealthConnectSync()
} catch (e: Exception) {
testResult = "Test failed: ${e.message}"
} finally {
isTestRunning = false
}
}
},
enabled = !isTestRunning,
modifier = Modifier.fillMaxWidth()
) {
if (isTestRunning) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(if (isTestRunning) "Testing..." else "Test Connection")
}
testResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.5f
)
)
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = "Debug Results:",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = result,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
}
}
}
}
}
}
@Composable
fun HealthConnectStatusBanner(isConnected: Boolean, modifier: Modifier = Modifier) {
if (isConnected) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.5f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.CloudDone,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Health Connect active - sessions will sync automatically",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
} }
} }
} }

View File

@@ -40,7 +40,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
emptyList() emptyList()
} else { } else {
selectedClimbTypes selectedClimbTypes
.flatMap { climbType -> DifficultySystem.getSystemsForClimbType(climbType) } .flatMap { climbType -> DifficultySystem.systemsForClimbType(climbType) }
.distinct() .distinct()
} }
@@ -164,7 +164,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
onCheckedChange = null onCheckedChange = null
) )
Spacer(modifier = Modifier.width(8.dp)) 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 onCheckedChange = null
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text(system.getDisplayName()) Text(system.displayName)
} }
} }
} }
@@ -248,7 +248,6 @@ fun AddEditProblemScreen(
) { ) {
val isEditing = problemId != null val isEditing = problemId != null
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val context = LocalContext.current
// Problem form state // Problem form state
var selectedGym by remember { var selectedGym by remember {
@@ -295,7 +294,7 @@ fun AddEditProblemScreen(
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList() val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
val availableDifficultySystems = val availableDifficultySystems =
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
selectedGym?.difficultySystems?.contains(system) != false 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) // Reset grade when difficulty system changes (unless it's a valid grade for the new system)
LaunchedEffect(selectedDifficultySystem) { LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades() val availableGrades = selectedDifficultySystem.availableGrades
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) { if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
difficultyGrade = "" difficultyGrade = ""
} }
@@ -386,13 +385,12 @@ fun AddEditProblemScreen(
notes = notes.ifBlank { null } notes = notes.ifBlank { null }
) )
if (isEditing) { if (isEditing && problemId != null) {
viewModel.updateProblem( viewModel.updateProblem(
problem.copy(id = problemId), problem.copy(id = problemId)
context
) )
} else { } else {
viewModel.addProblem(problem, context) viewModel.addProblem(problem)
} }
onNavigateBack() onNavigateBack()
} }
@@ -505,7 +503,7 @@ fun AddEditProblemScreen(
availableClimbTypes.forEach { climbType -> availableClimbTypes.forEach { climbType ->
FilterChip( FilterChip(
onClick = { selectedClimbType = climbType }, onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) }, label = { Text(climbType.displayName) },
selected = selectedClimbType == climbType selected = selectedClimbType == climbType
) )
} }
@@ -538,7 +536,7 @@ fun AddEditProblemScreen(
items(availableDifficultySystems) { system -> items(availableDifficultySystems) { system ->
FilterChip( FilterChip(
onClick = { selectedDifficultySystem = system }, onClick = { selectedDifficultySystem = system },
label = { Text(system.getDisplayName()) }, label = { Text(system.displayName) },
selected = selectedDifficultySystem == system selected = selectedDifficultySystem == system
) )
} }
@@ -570,7 +568,7 @@ fun AddEditProblemScreen(
) )
} else { } else {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val availableGrades = selectedDifficultySystem.getAvailableGrades() val availableGrades = selectedDifficultySystem.availableGrades
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded, expanded = expanded,

View File

@@ -17,8 +17,8 @@ import com.atridad.ascently.ui.components.BarChart
import com.atridad.ascently.ui.components.BarChartDataPoint import com.atridad.ascently.ui.components.BarChartDataPoint
import com.atridad.ascently.ui.components.SyncIndicator import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.utils.DateFormatUtils
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Composable @Composable
fun AnalyticsScreen(viewModel: ClimbViewModel) { fun AnalyticsScreen(viewModel: ClimbViewModel) {
@@ -253,11 +253,8 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
systemFiltered.filter { dataPoint -> systemFiltered.filter { dataPoint ->
try { try {
val attemptDate = val attemptDate =
LocalDateTime.parse( DateFormatUtils.parseToLocalDateTime(dataPoint.date)
dataPoint.date, attemptDate?.isAfter(sevenDaysAgo) == true
DateTimeFormatter.ISO_LOCAL_DATE_TIME
)
attemptDate.isAfter(sevenDaysAgo)
} catch (_: Exception) { } catch (_: Exception) {
// If date parsing fails, include the data point // If date parsing fails, include the data point
true true

View File

@@ -16,10 +16,8 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewModelScope
import com.atridad.ascently.data.model.* import com.atridad.ascently.data.model.*
import com.atridad.ascently.ui.components.FullscreenImageViewer import com.atridad.ascently.ui.components.FullscreenImageViewer
import com.atridad.ascently.ui.components.ImageDisplaySection import com.atridad.ascently.ui.components.ImageDisplaySection
import com.atridad.ascently.ui.theme.CustomIcons import com.atridad.ascently.ui.theme.CustomIcons
import com.atridad.ascently.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime import com.atridad.ascently.utils.DateFormatUtils
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -221,7 +216,6 @@ fun SessionDetailScreen(
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
var isGeneratingShare by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showAddAttemptDialog by remember { mutableStateOf(false) } var showAddAttemptDialog by remember { mutableStateOf(false) }
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) } var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) }
@@ -234,7 +228,7 @@ fun SessionDetailScreen(
val successfulAttempts = val successfulAttempts =
attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) } attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
val uniqueProblems = attempts.map { it.problemId }.distinct() val uniqueProblems = attempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in uniqueProblems }
val completedProblems = successfulAttempts.map { it.problemId }.distinct() val completedProblems = successfulAttempts.map { it.problemId }.distinct()
val attemptsWithProblems = val attemptsWithProblems =
@@ -261,64 +255,8 @@ fun SessionDetailScreen(
} }
}, },
actions = { actions = {
if (session?.duration != null) { // No manual actions needed - Health Connect syncs automatically when
val healthConnectManager = viewModel.getHealthConnectManager() // sessions complete
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"
)
}
}
}
// Show stop icon for active sessions, delete icon for completed // Show stop icon for active sessions, delete icon for completed
// sessions // sessions
@@ -564,7 +502,7 @@ fun SessionDetailScreen(
viewModel.addAttempt(attempt) viewModel.addAttempt(attempt)
showAddAttemptDialog = false showAddAttemptDialog = false
}, },
onProblemCreated = { problem -> viewModel.addProblem(problem, context) } onProblemCreated = { problem -> viewModel.addProblem(problem) }
) )
} }
@@ -590,7 +528,7 @@ fun ProblemDetailScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToEdit: (String) -> Unit onNavigateToEdit: (String) -> Unit
) { ) {
val context = LocalContext.current
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showImageViewer by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) }
var selectedImageIndex by remember { mutableIntStateOf(0) } var selectedImageIndex by remember { mutableIntStateOf(0) }
@@ -665,7 +603,7 @@ fun ProblemDetailScreen(
problem?.let { p -> problem?.let { p ->
Text( Text(
text = text =
"${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}", "${p.difficulty.system.displayName}: ${p.difficulty.grade}",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@@ -674,7 +612,7 @@ fun ProblemDetailScreen(
problem?.let { p -> problem?.let { p ->
Text( Text(
text = p.climbType.getDisplayName(), text = p.climbType.displayName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -854,7 +792,7 @@ fun ProblemDetailScreen(
TextButton( TextButton(
onClick = { onClick = {
problem?.let { p -> problem?.let { p ->
viewModel.deleteProblem(p, context) viewModel.deleteProblem(p)
onNavigateBack() onNavigateBack()
} }
showDeleteDialog = false showDeleteDialog = false
@@ -1236,19 +1174,10 @@ fun GymDetailScreen(
} }
}, },
supportingContent = { supportingContent = {
val dateTime =
try {
LocalDateTime.parse(session.date)
} catch (_: Exception) {
null
}
val formattedDate = val formattedDate =
dateTime?.format( DateFormatUtils.formatDateForDisplay(
DateTimeFormatter.ofPattern( session.date
"MMM dd, yyyy"
) )
)
?: session.date
Text( Text(
"$formattedDate${sessionAttempts.size} attempts" "$formattedDate${sessionAttempts.size} attempts"
@@ -1463,7 +1392,7 @@ fun SessionAttemptCard(
Text( Text(
text = text =
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", "${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -1538,14 +1467,7 @@ fun SessionAttemptCard(
} }
private fun formatDate(dateString: String): String { private fun formatDate(dateString: String): String {
return try { return DateFormatUtils.formatDateForDisplay(dateString)
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
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -1584,7 +1506,7 @@ fun EnhancedAddAttemptDialog(
// Auto-select difficulty system if there's only one available for the selected climb type // Auto-select difficulty system if there's only one available for the selected climb type
LaunchedEffect(selectedClimbType, gym.difficultySystems) { LaunchedEffect(selectedClimbType, gym.difficultySystems) {
val availableSystems = val availableSystems =
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
gym.difficultySystems.contains(system) gym.difficultySystems.contains(system)
} }
@@ -1604,7 +1526,7 @@ fun EnhancedAddAttemptDialog(
// Reset grade when difficulty system changes // Reset grade when difficulty system changes
LaunchedEffect(selectedDifficultySystem) { LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades() val availableGrades = selectedDifficultySystem.availableGrades
if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) { if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) {
newProblemGrade = "" newProblemGrade = ""
} }
@@ -1721,7 +1643,7 @@ fun EnhancedAddAttemptDialog(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = text =
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", "${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
style = style =
MaterialTheme.typography MaterialTheme.typography
.bodyMedium, .bodyMedium,
@@ -1730,7 +1652,7 @@ fun EnhancedAddAttemptDialog(
MaterialTheme MaterialTheme
.colorScheme .colorScheme
.onSurface.copy( .onSurface.copy(
alpha = 0.8f alpha = 0.9f
) )
else else
MaterialTheme MaterialTheme
@@ -1807,7 +1729,7 @@ fun EnhancedAddAttemptDialog(
onClick = { selectedClimbType = climbType }, onClick = { selectedClimbType = climbType },
label = { label = {
Text( Text(
climbType.getDisplayName(), climbType.displayName,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
}, },
@@ -1838,7 +1760,7 @@ fun EnhancedAddAttemptDialog(
) )
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val availableSystems = val availableSystems =
DifficultySystem.getSystemsForClimbType( DifficultySystem.systemsForClimbType(
selectedClimbType selectedClimbType
) )
.filter { system -> .filter { system ->
@@ -1849,7 +1771,7 @@ fun EnhancedAddAttemptDialog(
onClick = { selectedDifficultySystem = system }, onClick = { selectedDifficultySystem = system },
label = { label = {
Text( Text(
system.getDisplayName(), system.displayName,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
}, },
@@ -1926,8 +1848,7 @@ fun EnhancedAddAttemptDialog(
) )
} else { } else {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val availableGrades = val availableGrades = selectedDifficultySystem.availableGrades
selectedDifficultySystem.getAvailableGrades()
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded, expanded = expanded,

View File

@@ -87,7 +87,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
gym.supportedClimbTypes.forEach { climbType -> gym.supportedClimbTypes.forEach { climbType ->
AssistChip( AssistChip(
onClick = {}, onClick = {},
label = { Text(climbType.getDisplayName()) }, label = { Text(climbType.displayName) },
modifier = Modifier.padding(end = 4.dp) modifier = Modifier.padding(end = 4.dp)
) )
} }
@@ -97,7 +97,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = text =
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}", "Systems: ${gym.difficultySystems.joinToString(", ") { it.displayName }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )

View File

@@ -104,7 +104,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
items(ClimbType.entries) { climbType -> items(ClimbType.entries) { climbType ->
FilterChip( FilterChip(
onClick = { selectedClimbType = climbType }, onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) }, label = { Text(climbType.displayName) },
selected = selectedClimbType == climbType selected = selectedClimbType == climbType
) )
} }
@@ -183,7 +183,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
onClick = { onNavigateToProblemDetail(problem.id) }, onClick = { onNavigateToProblemDetail(problem.id) },
onToggleActive = { onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive) val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem, context) viewModel.updateProblem(updatedProblem)
} }
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -268,7 +268,7 @@ fun ProblemCard(
} }
Text( Text(
text = problem.climbType.getDisplayName(), text = problem.climbType.displayName,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )

View File

@@ -22,8 +22,7 @@ import com.atridad.ascently.data.model.SessionStatus
import com.atridad.ascently.ui.components.ActiveSessionBanner import com.atridad.ascently.ui.components.ActiveSessionBanner
import com.atridad.ascently.ui.components.SyncIndicator import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime import com.atridad.ascently.utils.DateFormatUtils
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -247,10 +246,5 @@ fun EmptyStateMessage(
} }
private fun formatDate(dateString: String): String { private fun formatDate(dateString: String): String {
return try { return DateFormatUtils.formatDateForDisplay(dateString)
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
} catch (_: Exception) {
dateString
}
} }

View File

@@ -50,15 +50,15 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
var isDeletingImages by remember { mutableStateOf(false) } var isDeletingImages by remember { mutableStateOf(false) }
// Sync configuration state // Sync configuration state
var serverUrl by remember { mutableStateOf(syncService.serverURL) } var serverUrl by remember { mutableStateOf(syncService.serverUrl) }
var authToken by remember { mutableStateOf(syncService.authToken) } var authToken by remember { mutableStateOf(syncService.authToken) }
val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) } val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) }
val appVersion = packageInfo.versionName val appVersion = packageInfo.versionName
// Update local state when sync service configuration changes // Update local state when sync service configuration changes
LaunchedEffect(syncService.serverURL, syncService.authToken) { LaunchedEffect(syncService.serverUrl, syncService.authToken) {
serverUrl = syncService.serverURL serverUrl = syncService.serverUrl
authToken = syncService.authToken authToken = syncService.authToken
} }
@@ -183,7 +183,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
}, },
supportingContent = { supportingContent = {
Column { Column {
Text("Server: ${syncService.serverURL}") Text("Server: ${syncService.serverUrl}")
lastSyncTime?.let { time -> lastSyncTime?.let { time ->
Text( Text(
"Last sync: ${ "Last sync: ${
@@ -863,7 +863,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
onClick = { onClick = {
coroutineScope.launch { coroutineScope.launch {
try { try {
syncService.serverURL = serverUrl.trim() syncService.serverUrl = serverUrl.trim()
syncService.authToken = authToken.trim() syncService.authToken = authToken.trim()
viewModel.testSyncConnection() viewModel.testSyncConnection()
while (syncService.isTesting.value) { while (syncService.isTesting.value) {
@@ -905,7 +905,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
onClick = { onClick = {
coroutineScope.launch { coroutineScope.launch {
try { try {
syncService.serverURL = serverUrl.trim() syncService.serverUrl = serverUrl.trim()
syncService.authToken = authToken.trim() syncService.authToken = authToken.trim()
viewModel.testSyncConnection() viewModel.testSyncConnection()
while (syncService.isTesting.value) { while (syncService.isTesting.value) {
@@ -932,7 +932,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
dismissButton = { dismissButton = {
TextButton( TextButton(
onClick = { onClick = {
serverUrl = syncService.serverURL serverUrl = syncService.serverUrl
authToken = syncService.authToken authToken = syncService.authToken
showSyncConfigDialog = false showSyncConfigDialog = false
} }
@@ -981,7 +981,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
isDeletingImages = true isDeletingImages = true
showDeleteImagesDialog = false showDeleteImagesDialog = false
coroutineScope.launch { coroutineScope.launch {
viewModel.deleteAllImages(context) viewModel.deleteAllImages()
isDeletingImages = false isDeletingImages = false
viewModel.setMessage("All images deleted successfully!") viewModel.setMessage("All images deleted successfully!")
} }

View File

@@ -8,15 +8,11 @@ import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.sync.SyncService import com.atridad.ascently.data.sync.SyncService
import com.atridad.ascently.service.SessionTrackingService import com.atridad.ascently.service.SessionTrackingService
import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils import com.atridad.ascently.utils.ImageUtils
import com.atridad.ascently.utils.SessionShareUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ClimbViewModel( class ClimbViewModel(
private val repository: ClimbRepository, private val repository: ClimbRepository,
@@ -78,64 +74,57 @@ class ClimbViewModel(
) )
// Gym operations // Gym operations
fun addGym(gym: Gym) { fun addGym(gym: Gym, updateWidgets: Boolean = true) {
viewModelScope.launch { repository.insertGym(gym) }
}
fun addGym(gym: Gym, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertGym(gym) repository.insertGym(gym)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) 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 { viewModelScope.launch {
repository.updateGym(gym) repository.updateGym(gym)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) 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 { viewModelScope.launch {
repository.deleteGym(gym) repository.deleteGym(gym)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
}
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) } fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
// Problem operations // Problem operations
fun addProblem(problem: Problem, context: Context) { fun addProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch { viewModelScope.launch {
val finalProblem = renameTemporaryImages(problem, context) val finalProblem = renameTemporaryImages(problem)
repository.insertProblem(finalProblem) repository.insertProblem(finalProblem)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback }
} }
} }
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem { private suspend fun renameTemporaryImages(problem: Problem): Problem {
if (problem.imagePaths.isEmpty()) { if (problem.imagePaths.isEmpty()) {
return problem return problem
} }
val appContext = context ?: return problem
val finalImagePaths = mutableListOf<String>() val finalImagePaths = mutableListOf<String>()
problem.imagePaths.forEachIndexed { index, tempPath -> problem.imagePaths.forEachIndexed { index, tempPath ->
if (tempPath.startsWith("temp_")) { if (tempPath.startsWith("temp_")) {
val deterministicName = ImageNamingUtils.generateImageFilename(problem.id, index)
val finalPath = val finalPath =
ImageUtils.renameTemporaryImage(appContext, tempPath, problem.id, index) ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index)
finalImagePaths.add(finalPath ?: tempPath) finalImagePaths.add(finalPath ?: tempPath)
} else { } else {
finalImagePaths.add(tempPath) finalImagePaths.add(tempPath)
@@ -145,34 +134,34 @@ class ClimbViewModel(
return problem.copy(imagePaths = finalImagePaths) return problem.copy(imagePaths = finalImagePaths)
} }
fun updateProblem(problem: Problem, context: Context) { fun updateProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch { viewModelScope.launch {
val finalProblem = renameTemporaryImages(problem, context) val finalProblem = renameTemporaryImages(problem)
repository.updateProblem(finalProblem) repository.updateProblem(finalProblem)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
}
fun deleteProblem(problem: Problem, context: Context) { fun deleteProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch { viewModelScope.launch {
// Delete associated images
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) } problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
repository.deleteProblem(problem) repository.deleteProblem(problem)
cleanupOrphanedImages()
cleanupOrphanedImages(context) if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
}
private suspend fun cleanupOrphanedImages(context: Context) { private suspend fun cleanupOrphanedImages() {
val allProblems = repository.getAllProblems().first() val allProblems = repository.getAllProblems().first()
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths) ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
} }
fun deleteAllImages(context: Context) { fun deleteAllImages() {
viewModelScope.launch { viewModelScope.launch {
val imagesDir = ImageUtils.getImagesDirectory(context) val imagesDir = ImageUtils.getImagesDirectory(context)
var deletedCount = 0 var deletedCount = 0
@@ -212,38 +201,32 @@ class ClimbViewModel(
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId) fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
// Session operations // Session operations
fun addSession(session: ClimbSession) { fun addSession(session: ClimbSession, updateWidgets: Boolean = true) {
viewModelScope.launch { repository.insertSession(session) }
}
fun addSession(session: ClimbSession, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertSession(session) repository.insertSession(session)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) 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 { viewModelScope.launch {
repository.updateSession(session) repository.updateSession(session)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) 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 { viewModelScope.launch {
repository.deleteSession(session) repository.deleteSession(session)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
}
fun getSessionById(id: String): Flow<ClimbSession?> = flow { fun getSessionById(id: String): Flow<ClimbSession?> = flow {
emit(repository.getSessionById(id)) emit(repository.getSessionById(id))
@@ -345,38 +328,32 @@ class ClimbViewModel(
} }
// Attempt operations // Attempt operations
fun addAttempt(attempt: Attempt) { fun addAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
viewModelScope.launch { repository.insertAttempt(attempt) }
}
fun addAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertAttempt(attempt) repository.insertAttempt(attempt)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) 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 { viewModelScope.launch {
repository.deleteAttempt(attempt) repository.deleteAttempt(attempt)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) 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 { viewModelScope.launch {
repository.updateAttempt(attempt) repository.updateAttempt(attempt)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
}
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId) repository.getAttemptsBySession(sessionId)
@@ -499,107 +476,30 @@ class ClimbViewModel(
val attempts = repository.getAttemptsBySession(session.id).first() val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size val attemptCount = attempts.size
val result = healthConnectManager.autoSyncSession(session, gymName, attemptCount)
result
.onSuccess {
_uiState.value =
_uiState.value.copy(
message =
"Session synced to Health Connect successfully!"
)
}
.onFailure { error ->
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(
error =
"Failed to sync to Health Connect: ${error.message}"
)
}
}
} catch (e: Exception) {
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
}
}
}
}
fun manualSyncToHealthConnect(sessionId: String) {
viewModelScope.launch {
try {
val session = repository.getSessionById(sessionId)
if (session == null) {
_uiState.value = _uiState.value.copy(error = "Session not found")
return@launch
}
if (session.status != SessionStatus.COMPLETED) {
_uiState.value =
_uiState.value.copy(error = "Only completed sessions can be synced")
return@launch
}
val gym = repository.getGymById(session.gymId)
val gymName = gym?.name ?: "Unknown Gym"
val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size
val result = val result =
healthConnectManager.syncClimbingSession(session, gymName, attemptCount) healthConnectManager.autoSyncCompletedSession(
session,
gymName,
attemptCount
)
result result.onFailure { error ->
.onSuccess { if (healthConnectManager.isReadySync()) {
_uiState.value = android.util.Log.w(
_uiState.value.copy( "ClimbViewModel",
message = "Health Connect sync failed: ${error.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) { } catch (e: Exception) {
_uiState.value = if (healthConnectManager.isReadySync()) {
_uiState.value.copy(error = "Health Connect sync error: ${e.message}") android.util.Log.w("ClimbViewModel", "Health Connect sync error: ${e.message}")
}
} }
} }
} }
fun getHealthConnectManager(): HealthConnectManager = healthConnectManager 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( data class ClimbUiState(

View File

@@ -1,6 +1,8 @@
package com.atridad.ascently.utils package com.atridad.ascently.utils
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -43,4 +45,33 @@ object DateFormatUtils {
fun millisToISO8601(millis: Long): String { fun millisToISO8601(millis: Long): String {
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis)) 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
}
}
} }

View File

@@ -21,6 +21,7 @@ object ImageNamingUtils {
} }
/** Legacy method for backward compatibility */ /** Legacy method for backward compatibility */
@Suppress("UNUSED_PARAMETER")
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String { fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
return generateImageFilename(problemId, imageIndex) return generateImageFilename(problemId, imageIndex)
} }
@@ -97,6 +98,26 @@ object ImageNamingUtils {
} }
/** Creates a mapping of existing server filenames to canonical filenames */ /** 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( fun createServerMigrationMap(
problemId: String, problemId: String,
serverImageFilenames: List<String>, serverImageFilenames: List<String>,
@@ -124,3 +145,16 @@ object ImageNamingUtils {
return migrationMap 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
}

View File

@@ -10,8 +10,6 @@ import androidx.core.graphics.toColorInt
import com.atridad.ascently.data.model.* import com.atridad.ascently.data.model.*
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt import kotlin.math.roundToInt
object SessionShareUtils { object SessionShareUtils {
@@ -457,14 +455,7 @@ object SessionShareUtils {
} }
private fun formatSessionDate(dateString: String): String { private fun formatSessionDate(dateString: String): String {
return try { return DateFormatUtils.formatDateForDisplay(dateString, "MMMM dd, yyyy")
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)
}
} }
fun shareSessionCard(context: Context, imageFile: File) { fun shareSessionCard(context: Context, imageFile: File) {
@@ -512,7 +503,7 @@ object SessionShareUtils {
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0 if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
} }
DifficultySystem.FONT -> { DifficultySystem.FONT -> {
val list = DifficultySystem.FONT.getAvailableGrades() val list = DifficultySystem.FONT.availableGrades
val idx = list.indexOf(grade.uppercase()) val idx = list.indexOf(grade.uppercase())
if (idx >= 0) idx.toDouble() if (idx >= 0) idx.toDouble()
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0 else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0

View File

@@ -18,8 +18,8 @@ class DataModelTests {
@Test @Test
fun testClimbTypeDisplayNames() { fun testClimbTypeDisplayNames() {
assertEquals("Rope", ClimbType.ROPE.getDisplayName()) assertEquals("Rope", ClimbType.ROPE.displayName)
assertEquals("Bouldering", ClimbType.BOULDER.getDisplayName()) assertEquals("Bouldering", ClimbType.BOULDER.displayName)
} }
@Test @Test
@@ -34,58 +34,58 @@ class DataModelTests {
@Test @Test
fun testDifficultySystemDisplayNames() { fun testDifficultySystemDisplayNames() {
assertEquals("V Scale", DifficultySystem.V_SCALE.getDisplayName()) assertEquals("V Scale", DifficultySystem.V_SCALE.displayName)
assertEquals("YDS (Yosemite)", DifficultySystem.YDS.getDisplayName()) assertEquals("YDS (Yosemite)", DifficultySystem.YDS.displayName)
assertEquals("Font Scale", DifficultySystem.FONT.getDisplayName()) assertEquals("Font Scale", DifficultySystem.FONT.displayName)
assertEquals("Custom", DifficultySystem.CUSTOM.getDisplayName()) assertEquals("Custom", DifficultySystem.CUSTOM.displayName)
} }
@Test @Test
fun testDifficultySystemClimbTypeCompatibility() { fun testDifficultySystemClimbTypeCompatibility() {
// Test bouldering systems // Test bouldering systems
assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem()) assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem)
assertTrue(DifficultySystem.FONT.isBoulderingSystem()) assertTrue(DifficultySystem.FONT.isBoulderingSystem)
assertFalse(DifficultySystem.YDS.isBoulderingSystem()) assertFalse(DifficultySystem.YDS.isBoulderingSystem)
assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem()) assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem)
// Test rope systems // Test rope systems
assertTrue(DifficultySystem.YDS.isRopeSystem()) assertTrue(DifficultySystem.YDS.isRopeSystem)
assertFalse(DifficultySystem.V_SCALE.isRopeSystem()) assertFalse(DifficultySystem.V_SCALE.isRopeSystem)
assertFalse(DifficultySystem.FONT.isRopeSystem()) assertFalse(DifficultySystem.FONT.isRopeSystem)
assertTrue(DifficultySystem.CUSTOM.isRopeSystem()) assertTrue(DifficultySystem.CUSTOM.isRopeSystem)
} }
@Test @Test
fun testDifficultySystemAvailableGrades() { fun testDifficultySystemAvailableGrades() {
val vScaleGrades = DifficultySystem.V_SCALE.getAvailableGrades() val vScaleGrades = DifficultySystem.V_SCALE.availableGrades
assertTrue(vScaleGrades.contains("VB")) assertTrue(vScaleGrades.contains("VB"))
assertTrue(vScaleGrades.contains("V0")) assertTrue(vScaleGrades.contains("V0"))
assertTrue(vScaleGrades.contains("V17")) assertTrue(vScaleGrades.contains("V17"))
assertEquals("VB", vScaleGrades.first()) assertEquals("VB", vScaleGrades.first())
val ydsGrades = DifficultySystem.YDS.getAvailableGrades() val ydsGrades = DifficultySystem.YDS.availableGrades
assertTrue(ydsGrades.contains("5.0")) assertTrue(ydsGrades.contains("5.0"))
assertTrue(ydsGrades.contains("5.15d")) assertTrue(ydsGrades.contains("5.15d"))
assertTrue(ydsGrades.contains("5.10a")) assertTrue(ydsGrades.contains("5.10a"))
val fontGrades = DifficultySystem.FONT.getAvailableGrades() val fontGrades = DifficultySystem.FONT.availableGrades
assertTrue(fontGrades.contains("3")) assertTrue(fontGrades.contains("3"))
assertTrue(fontGrades.contains("8C+")) assertTrue(fontGrades.contains("8C+"))
assertTrue(fontGrades.contains("6A")) assertTrue(fontGrades.contains("6A"))
val customGrades = DifficultySystem.CUSTOM.getAvailableGrades() val customGrades = DifficultySystem.CUSTOM.availableGrades
assertTrue(customGrades.isEmpty()) assertTrue(customGrades.isEmpty())
} }
@Test @Test
fun testDifficultySystemsForClimbType() { fun testDifficultySystemsForClimbType() {
val boulderSystems = DifficultySystem.getSystemsForClimbType(ClimbType.BOULDER) val boulderSystems = DifficultySystem.systemsForClimbType(ClimbType.BOULDER)
assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE)) assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE))
assertTrue(boulderSystems.contains(DifficultySystem.FONT)) assertTrue(boulderSystems.contains(DifficultySystem.FONT))
assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM)) assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM))
assertFalse(boulderSystems.contains(DifficultySystem.YDS)) 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.YDS))
assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM)) assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM))
assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE)) assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE))