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"
minSdk = 31
targetSdk = 35
versionCode = 5
versionName = "0.3.2"
versionCode = 6
versionName = "0.3.3"
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",
"filters": [],
"attributes": [],
"versionCode": 4,
"versionName": "0.3.1",
"versionCode": 6,
"versionName": "0.3.3",
"outputFile": "app-release.apk"
}
],
@@ -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(
@@ -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
@@ -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)
}
}
@@ -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,
@@ -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,
@@ -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
}
)
@@ -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
}
@@ -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,
@@ -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