This commit is contained in:
2025-08-16 02:31:52 -06:00
parent ca770b9db3
commit cc1edbc65c
22 changed files with 52 additions and 132 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 5 versionCode = 6
versionName = "0.3.2" versionName = "0.3.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -11,8 +11,8 @@
"type": "SINGLE", "type": "SINGLE",
"filters": [], "filters": [],
"attributes": [], "attributes": [],
"versionCode": 4, "versionCode": 6,
"versionName": "0.3.1", "versionName": "0.3.3",
"outputFile": "app-release.apk" "outputFile": "app-release.apk"
} }
], ],
@@ -9,12 +9,10 @@ import java.time.LocalDateTime
@Serializable @Serializable
enum class AttemptResult { enum class AttemptResult {
SUCCESS, // Completed the problem/route SUCCESS,
FALL, // Fell but made progress FALL,
NO_PROGRESS, // Couldn't make meaningful progress NO_PROGRESS,
FLASH, // Completed on first try FLASH,
REDPOINT, // Completed after previous attempts
ONSIGHT // Completed on first try without prior knowledge
} }
@Entity( @Entity(
@@ -65,7 +65,7 @@ data class ClimbSession(
val start = LocalDateTime.parse(startTime) val start = LocalDateTime.parse(startTime)
val end = LocalDateTime.parse(endTime) val end = LocalDateTime.parse(endTime)
java.time.Duration.between(start, end).toMinutes() java.time.Duration.between(start, end).toMinutes()
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
} else null } else null
@@ -123,7 +123,7 @@ class ClimbRepository(
importData.gyms.forEach { gym -> importData.gyms.forEach { gym ->
try { try {
gymDao.insertGym(gym) gymDao.insertGym(gym)
} catch (e: Exception) { } catch (_: Exception) {
// If insertion fails, update instead // If insertion fails, update instead
gymDao.updateGym(gym) gymDao.updateGym(gym)
} }
@@ -133,7 +133,7 @@ class ClimbRepository(
importData.problems.forEach { problem -> importData.problems.forEach { problem ->
try { try {
problemDao.insertProblem(problem) problemDao.insertProblem(problem)
} catch (e: Exception) { } catch (_: Exception) {
problemDao.updateProblem(problem) problemDao.updateProblem(problem)
} }
} }
@@ -142,7 +142,7 @@ class ClimbRepository(
importData.sessions.forEach { session -> importData.sessions.forEach { session ->
try { try {
sessionDao.insertSession(session) sessionDao.insertSession(session)
} catch (e: Exception) { } catch (_: Exception) {
sessionDao.updateSession(session) sessionDao.updateSession(session)
} }
} }
@@ -151,7 +151,7 @@ class ClimbRepository(
importData.attempts.forEach { attempt -> importData.attempts.forEach { attempt ->
try { try {
attemptDao.insertAttempt(attempt) attemptDao.insertAttempt(attempt)
} catch (e: Exception) { } catch (_: Exception) {
attemptDao.updateAttempt(attempt) attemptDao.updateAttempt(attempt)
} }
} }
@@ -38,9 +38,10 @@ class SessionTrackingService : Service() {
} }
} }
fun createStopIntent(context: Context): Intent { fun createStopIntent(context: Context, sessionId: String): Intent {
return Intent(context, SessionTrackingService::class.java).apply { return Intent(context, SessionTrackingService::class.java).apply {
action = ACTION_STOP_SESSION action = ACTION_STOP_SESSION
putExtra(EXTRA_SESSION_ID, sessionId)
} }
} }
} }
@@ -63,7 +64,21 @@ class SessionTrackingService : Service() {
} }
} }
ACTION_STOP_SESSION -> { ACTION_STOP_SESSION -> {
stopSessionTracking() val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
serviceScope.launch {
try {
val targetSession = when {
sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession()
}
if (targetSession != null && targetSession.status == com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
val completed = with(com.atridad.openclimb.data.model.ClimbSession) { targetSession.complete() }
repository.updateSession(completed)
}
} finally {
stopSessionTracking()
}
}
} }
} }
return START_STICKY return START_STICKY
@@ -131,7 +146,7 @@ class SessionTrackingService : Service() {
.addAction( .addAction(
android.R.drawable.ic_menu_close_clear_cancel, android.R.drawable.ic_menu_close_clear_cancel,
"End Session", "End Session",
createStopIntent() createStopPendingIntent(sessionId)
) )
.build() .build()
@@ -154,8 +169,8 @@ class SessionTrackingService : Service() {
) )
} }
private fun createStopIntent(): PendingIntent { private fun createStopPendingIntent(sessionId: String): PendingIntent {
val intent = createStopIntent(this) val intent = createStopIntent(this, sessionId)
return PendingIntent.getService( return PendingIntent.getService(
this, this,
1, 1,
@@ -27,7 +27,6 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
fun OpenClimbApp() { fun OpenClimbApp() {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val database = remember { OpenClimbDatabase.getDatabase(context) } val database = remember { OpenClimbDatabase.getDatabase(context) }
val repository = remember { ClimbRepository(database, context) } val repository = remember { ClimbRepository(database, context) }
@@ -148,6 +147,7 @@ fun OpenClimbApp() {
// Detail screens // Detail screens
composable<Screen.SessionDetail> { backStackEntry -> composable<Screen.SessionDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.SessionDetail>() val args = backStackEntry.toRoute<Screen.SessionDetail>()
LaunchedEffect(Unit) { fabConfig = null }
SessionDetailScreen( SessionDetailScreen(
sessionId = args.sessionId, sessionId = args.sessionId,
viewModel = viewModel, viewModel = viewModel,
@@ -160,6 +160,7 @@ fun OpenClimbApp() {
composable<Screen.ProblemDetail> { backStackEntry -> composable<Screen.ProblemDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.ProblemDetail>() val args = backStackEntry.toRoute<Screen.ProblemDetail>()
LaunchedEffect(Unit) { fabConfig = null }
ProblemDetailScreen( ProblemDetailScreen(
problemId = args.problemId, problemId = args.problemId,
viewModel = viewModel, viewModel = viewModel,
@@ -172,6 +173,7 @@ fun OpenClimbApp() {
composable<Screen.GymDetail> { backStackEntry -> composable<Screen.GymDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.GymDetail>() val args = backStackEntry.toRoute<Screen.GymDetail>()
LaunchedEffect(Unit) { fabConfig = null }
GymDetailScreen( GymDetailScreen(
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
@@ -185,6 +187,7 @@ fun OpenClimbApp() {
composable<Screen.AddEditGym> { backStackEntry -> composable<Screen.AddEditGym> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditGym>() val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen( AddEditGymScreen(
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
@@ -194,6 +197,7 @@ fun OpenClimbApp() {
composable<Screen.AddEditProblem> { backStackEntry -> composable<Screen.AddEditProblem> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditProblem>() val args = backStackEntry.toRoute<Screen.AddEditProblem>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditProblemScreen( AddEditProblemScreen(
problemId = args.problemId, problemId = args.problemId,
gymId = args.gymId, gymId = args.gymId,
@@ -948,8 +948,7 @@ fun AddEditSessionScreen(
text = "Result: ${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }}", text = "Result: ${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = when (attempt.result) { color = when (attempt.result) {
AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.SUCCESS, AttemptResult.FLASH -> MaterialTheme.colorScheme.primary
AttemptResult.REDPOINT, AttemptResult.ONSIGHT -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant else -> MaterialTheme.colorScheme.onSurfaceVariant
} }
) )
@@ -59,7 +59,7 @@ fun SessionDetailScreen(
// Calculate stats // Calculate stats
val successfulAttempts = attempts.filter { val successfulAttempts = attempts.filter {
it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT) 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 attemptedProblems = problems.filter { it.id in uniqueProblems }
@@ -71,9 +71,7 @@ fun SessionDetailScreen(
}.sortedByDescending { attempt -> }.sortedByDescending { attempt ->
// Sort by result priority, then by timestamp // Sort by result priority, then by timestamp
when (attempt.first.result) { when (attempt.first.result) {
AttemptResult.ONSIGHT -> 5 AttemptResult.FLASH -> 3
AttemptResult.FLASH -> 4
AttemptResult.REDPOINT -> 3
AttemptResult.SUCCESS -> 2 AttemptResult.SUCCESS -> 2
AttemptResult.FALL -> 1 AttemptResult.FALL -> 1
else -> 0 else -> 0
@@ -130,8 +128,7 @@ fun SessionDetailScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
// Show FAB only for active sessions (those without duration) if (session?.status == SessionStatus.ACTIVE) {
if (session?.duration == null) {
FloatingActionButton( FloatingActionButton(
onClick = { showAddAttemptDialog = true } onClick = { showAddAttemptDialog = true }
) { ) {
@@ -418,7 +415,7 @@ fun ProblemDetailScreen(
// Calculate stats // Calculate stats
val successfulAttempts = attempts.filter { val successfulAttempts = attempts.filter {
it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT) it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH)
} }
val successRate = if (attempts.isNotEmpty()) { val successRate = if (attempts.isNotEmpty()) {
(successfulAttempts.size.toDouble() / attempts.size * 100).toInt() (successfulAttempts.size.toDouble() / attempts.size * 100).toInt()
@@ -750,7 +747,7 @@ fun GymDetailScreen(
} }
val successfulAttempts = gymAttempts.filter { val successfulAttempts = gymAttempts.filter {
it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT) it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH)
} }
val successRate = if (gymAttempts.isNotEmpty()) { val successRate = if (gymAttempts.isNotEmpty()) {
@@ -984,7 +981,7 @@ fun GymDetailScreen(
problems.sortedByDescending { it.createdAt }.take(5).forEach { problem -> problems.sortedByDescending { it.createdAt }.take(5).forEach { problem ->
val problemAttempts = gymAttempts.filter { it.problemId == problem.id } val problemAttempts = gymAttempts.filter { it.problemId == problem.id }
val problemSuccessful = problemAttempts.any { val problemSuccessful = problemAttempts.any {
it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT) it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH)
} }
Card( Card(
@@ -1264,13 +1261,13 @@ fun AttemptHistoryCard(
@Composable @Composable
fun AttemptResultBadge(result: AttemptResult) { fun AttemptResultBadge(result: AttemptResult) {
val backgroundColor = when (result) { val backgroundColor = when (result) {
AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT -> MaterialTheme.colorScheme.primaryContainer AttemptResult.SUCCESS, AttemptResult.FLASH -> MaterialTheme.colorScheme.primaryContainer
AttemptResult.FALL -> MaterialTheme.colorScheme.secondaryContainer AttemptResult.FALL -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant else -> MaterialTheme.colorScheme.surfaceVariant
} }
val textColor = when (result) { val textColor = when (result) {
AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT -> MaterialTheme.colorScheme.onPrimaryContainer AttemptResult.SUCCESS, AttemptResult.FLASH -> MaterialTheme.colorScheme.onPrimaryContainer
AttemptResult.FALL -> MaterialTheme.colorScheme.onSecondaryContainer AttemptResult.FALL -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant else -> MaterialTheme.colorScheme.onSurfaceVariant
} }
@@ -175,8 +175,8 @@ class ClimbViewModel(
val completedSession = with(ClimbSession) { session.complete() } val completedSession = with(ClimbSession) { session.complete() }
repository.updateSession(completedSession) repository.updateSession(completedSession)
// Stop the tracking service // Stop the tracking service, passing the session id so service can finalize if needed
val serviceIntent = SessionTrackingService.createStopIntent(context) val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
context.startService(serviceIntent) context.startService(serviceIntent)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@@ -186,32 +186,6 @@ class ClimbViewModel(
} }
} }
fun pauseSession(sessionId: String) {
viewModelScope.launch {
val session = repository.getSessionById(sessionId)
if (session != null && session.status == SessionStatus.ACTIVE) {
val pausedSession = session.copy(
status = SessionStatus.PAUSED,
updatedAt = java.time.LocalDateTime.now().toString()
)
repository.updateSession(pausedSession)
}
}
}
fun resumeSession(sessionId: String) {
viewModelScope.launch {
val session = repository.getSessionById(sessionId)
if (session != null && session.status == SessionStatus.PAUSED) {
val resumedSession = session.copy(
status = SessionStatus.ACTIVE,
updatedAt = java.time.LocalDateTime.now().toString()
)
repository.updateSession(resumedSession)
}
}
}
// Attempt operations // Attempt operations
fun addAttempt(attempt: Attempt) { fun addAttempt(attempt: Attempt) {
viewModelScope.launch { viewModelScope.launch {
@@ -219,52 +193,12 @@ class ClimbViewModel(
} }
} }
fun updateAttempt(attempt: Attempt) {
viewModelScope.launch {
repository.updateAttempt(attempt)
}
}
fun deleteAttempt(attempt: Attempt) {
viewModelScope.launch {
repository.deleteAttempt(attempt)
}
}
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId) repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
repository.getAttemptsByProblem(problemId) repository.getAttemptsByProblem(problemId)
// Analytics operations
// fun getProblemProgress(problemId: String): Flow<ProblemProgress?> =
// repository.getProblemProgress(problemId)
// fun getSessionSummary(sessionId: String): Flow<SessionSummary?> =
// repository.getSessionSummary(sessionId)
// Export operations
fun exportData(context: Context, directory: File? = null) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
val exportFile = repository.exportAllDataToJson(directory)
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Data exported to: ${exportFile.absolutePath}"
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "Export failed: ${e.message}"
)
}
}
}
fun exportDataToUri(context: Context, uri: android.net.Uri) { fun exportDataToUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch { viewModelScope.launch {
try { try {
@@ -282,26 +216,7 @@ class ClimbViewModel(
} }
} }
} }
// ZIP Export operations with images
fun exportDataToZip(context: Context, directory: File? = null) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
val exportFile = repository.exportAllDataToZip(directory)
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Data with images exported to: ${exportFile.absolutePath}"
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "Export failed: ${e.message}"
)
}
}
}
fun exportDataToZipUri(context: Context, uri: android.net.Uri) { fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch { viewModelScope.launch {
try { try {
@@ -358,10 +273,6 @@ class ClimbViewModel(
_uiState.value = _uiState.value.copy(error = message) _uiState.value = _uiState.value.copy(error = message)
} }
// Search operations
fun searchGyms(query: String): Flow<List<Gym>> = repository.searchGyms(query)
fun searchProblems(query: String): Flow<List<Problem>> = repository.searchProblems(query)
// Share operations // Share operations
suspend fun generateSessionShareCard( suspend fun generateSessionShareCard(
context: Context, context: Context,
@@ -34,9 +34,7 @@ object SessionShareUtils {
): SessionStats { ): SessionStats {
val successfulResults = listOf( val successfulResults = listOf(
AttemptResult.SUCCESS, AttemptResult.SUCCESS,
AttemptResult.FLASH, AttemptResult.FLASH
AttemptResult.REDPOINT,
AttemptResult.ONSIGHT
) )
val successfulAttempts = attempts.filter { it.result in successfulResults } val successfulAttempts = attempts.filter { it.result in successfulResults }
@@ -57,9 +55,7 @@ object SessionShareUtils {
val duration = if (session.duration != null) "${session.duration}m" else "Unknown" val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
val topResult = attempts.maxByOrNull { val topResult = attempts.maxByOrNull {
when (it.result) { when (it.result) {
AttemptResult.ONSIGHT -> 5 AttemptResult.FLASH -> 3
AttemptResult.FLASH -> 4
AttemptResult.REDPOINT -> 3
AttemptResult.SUCCESS -> 2 AttemptResult.SUCCESS -> 2
AttemptResult.FALL -> 1 AttemptResult.FALL -> 1
else -> 0 else -> 0