This commit is contained in:
2025-08-16 02:31:52 -06:00
parent d222ef8126
commit fb5028446d
91 changed files with 84 additions and 2785 deletions

View File

@@ -9,12 +9,10 @@ import java.time.LocalDateTime
@Serializable
enum class AttemptResult {
SUCCESS, // Completed the problem/route
FALL, // Fell but made progress
NO_PROGRESS, // Couldn't make meaningful progress
FLASH, // Completed on first try
REDPOINT, // Completed after previous attempts
ONSIGHT // Completed on first try without prior knowledge
SUCCESS,
FALL,
NO_PROGRESS,
FLASH,
}
@Entity(

View File

@@ -65,7 +65,7 @@ data class ClimbSession(
val start = LocalDateTime.parse(startTime)
val end = LocalDateTime.parse(endTime)
java.time.Duration.between(start, end).toMinutes()
} catch (e: Exception) {
} catch (_: Exception) {
null
}
} else null

View File

@@ -123,7 +123,7 @@ class ClimbRepository(
importData.gyms.forEach { gym ->
try {
gymDao.insertGym(gym)
} catch (e: Exception) {
} catch (_: Exception) {
// If insertion fails, update instead
gymDao.updateGym(gym)
}
@@ -133,7 +133,7 @@ class ClimbRepository(
importData.problems.forEach { problem ->
try {
problemDao.insertProblem(problem)
} catch (e: Exception) {
} catch (_: Exception) {
problemDao.updateProblem(problem)
}
}
@@ -142,7 +142,7 @@ class ClimbRepository(
importData.sessions.forEach { session ->
try {
sessionDao.insertSession(session)
} catch (e: Exception) {
} catch (_: Exception) {
sessionDao.updateSession(session)
}
}
@@ -151,7 +151,7 @@ class ClimbRepository(
importData.attempts.forEach { attempt ->
try {
attemptDao.insertAttempt(attempt)
} catch (e: Exception) {
} catch (_: Exception) {
attemptDao.updateAttempt(attempt)
}
}

View File

@@ -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 {
action = ACTION_STOP_SESSION
putExtra(EXTRA_SESSION_ID, sessionId)
}
}
}
@@ -63,7 +64,21 @@ class SessionTrackingService : Service() {
}
}
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
@@ -131,7 +146,7 @@ class SessionTrackingService : Service() {
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"End Session",
createStopIntent()
createStopPendingIntent(sessionId)
)
.build()
@@ -154,8 +169,8 @@ class SessionTrackingService : Service() {
)
}
private fun createStopIntent(): PendingIntent {
val intent = createStopIntent(this)
private fun createStopPendingIntent(sessionId: String): PendingIntent {
val intent = createStopIntent(this, sessionId)
return PendingIntent.getService(
this,
1,

View File

@@ -27,7 +27,6 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
fun OpenClimbApp() {
val navController = rememberNavController()
val context = LocalContext.current
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val database = remember { OpenClimbDatabase.getDatabase(context) }
val repository = remember { ClimbRepository(database, context) }
@@ -148,6 +147,7 @@ fun OpenClimbApp() {
// Detail screens
composable<Screen.SessionDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.SessionDetail>()
LaunchedEffect(Unit) { fabConfig = null }
SessionDetailScreen(
sessionId = args.sessionId,
viewModel = viewModel,
@@ -160,6 +160,7 @@ fun OpenClimbApp() {
composable<Screen.ProblemDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
LaunchedEffect(Unit) { fabConfig = null }
ProblemDetailScreen(
problemId = args.problemId,
viewModel = viewModel,
@@ -172,6 +173,7 @@ fun OpenClimbApp() {
composable<Screen.GymDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.GymDetail>()
LaunchedEffect(Unit) { fabConfig = null }
GymDetailScreen(
gymId = args.gymId,
viewModel = viewModel,
@@ -185,6 +187,7 @@ fun OpenClimbApp() {
composable<Screen.AddEditGym> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen(
gymId = args.gymId,
viewModel = viewModel,
@@ -194,6 +197,7 @@ fun OpenClimbApp() {
composable<Screen.AddEditProblem> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditProblemScreen(
problemId = args.problemId,
gymId = args.gymId,

View File

@@ -948,8 +948,7 @@ fun AddEditSessionScreen(
text = "Result: ${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }}",
style = MaterialTheme.typography.bodyMedium,
color = when (attempt.result) {
AttemptResult.SUCCESS, AttemptResult.FLASH,
AttemptResult.REDPOINT, AttemptResult.ONSIGHT -> MaterialTheme.colorScheme.primary
AttemptResult.SUCCESS, AttemptResult.FLASH -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)

View File

@@ -59,7 +59,7 @@ fun SessionDetailScreen(
// Calculate stats
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 attemptedProblems = problems.filter { it.id in uniqueProblems }
@@ -71,9 +71,7 @@ fun SessionDetailScreen(
}.sortedByDescending { attempt ->
// Sort by result priority, then by timestamp
when (attempt.first.result) {
AttemptResult.ONSIGHT -> 5
AttemptResult.FLASH -> 4
AttemptResult.REDPOINT -> 3
AttemptResult.FLASH -> 3
AttemptResult.SUCCESS -> 2
AttemptResult.FALL -> 1
else -> 0
@@ -130,8 +128,7 @@ fun SessionDetailScreen(
)
},
floatingActionButton = {
// Show FAB only for active sessions (those without duration)
if (session?.duration == null) {
if (session?.status == SessionStatus.ACTIVE) {
FloatingActionButton(
onClick = { showAddAttemptDialog = true }
) {
@@ -418,7 +415,7 @@ fun ProblemDetailScreen(
// Calculate stats
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()) {
(successfulAttempts.size.toDouble() / attempts.size * 100).toInt()
@@ -750,7 +747,7 @@ fun GymDetailScreen(
}
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()) {
@@ -984,7 +981,7 @@ fun GymDetailScreen(
problems.sortedByDescending { it.createdAt }.take(5).forEach { problem ->
val problemAttempts = gymAttempts.filter { it.problemId == problem.id }
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(
@@ -1264,13 +1261,13 @@ fun AttemptHistoryCard(
@Composable
fun AttemptResultBadge(result: AttemptResult) {
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
else -> MaterialTheme.colorScheme.surfaceVariant
}
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
else -> MaterialTheme.colorScheme.onSurfaceVariant
}

View File

@@ -175,8 +175,8 @@ class ClimbViewModel(
val completedSession = with(ClimbSession) { session.complete() }
repository.updateSession(completedSession)
// Stop the tracking service
val serviceIntent = SessionTrackingService.createStopIntent(context)
// Stop the tracking service, passing the session id so service can finalize if needed
val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
context.startService(serviceIntent)
_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
fun addAttempt(attempt: Attempt) {
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>> =
repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
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) {
viewModelScope.launch {
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) {
viewModelScope.launch {
try {
@@ -358,10 +273,6 @@ class ClimbViewModel(
_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
suspend fun generateSessionShareCard(
context: Context,

View File

@@ -34,9 +34,7 @@ object SessionShareUtils {
): SessionStats {
val successfulResults = listOf(
AttemptResult.SUCCESS,
AttemptResult.FLASH,
AttemptResult.REDPOINT,
AttemptResult.ONSIGHT
AttemptResult.FLASH
)
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 topResult = attempts.maxByOrNull {
when (it.result) {
AttemptResult.ONSIGHT -> 5
AttemptResult.FLASH -> 4
AttemptResult.REDPOINT -> 3
AttemptResult.FLASH -> 3
AttemptResult.SUCCESS -> 2
AttemptResult.FALL -> 1
else -> 0