Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
870278f240
|
|||
|
4eef77bd3b
|
|||
|
2d957db948
|
|||
| 22bed6a961 | |||
|
b443c18a19
|
|||
|
89f1e350b3
|
|||
|
0f976f685f
|
|||
|
c07186a7df
|
|||
|
15a5e217a5
|
|||
|
b86ab591fe
|
|||
|
70c85d159e
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
release/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" />
|
||||
</project>
|
||||
@@ -7,7 +7,7 @@ This is a FOSS Android app meant to help climbers track their sessions, routes/p
|
||||
You have two options:
|
||||
|
||||
1. Download the latest APK from the Released page
|
||||
2. Use <a href="">Obtainium</a>
|
||||
2. Use <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D">Obtainium</a>
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.atridad.openclimb"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.atridad.openclimb"
|
||||
minSdk = 31
|
||||
targetSdk = 35
|
||||
versionCode = 6
|
||||
versionName = "0.3.3"
|
||||
targetSdk = 36
|
||||
versionCode = 13
|
||||
versionName = "0.5.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -30,12 +30,20 @@ android {
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
// Ensure consistent JVM toolchain across all tasks
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
@@ -79,8 +87,14 @@ dependencies {
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.test.core)
|
||||
androidTestImplementation(libs.androidx.test.ext)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.androidx.test.rules)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
@@ -68,4 +68,41 @@ data class DifficultyGrade(
|
||||
val system: DifficultySystem,
|
||||
val grade: String,
|
||||
val numericValue: Int
|
||||
)
|
||||
) {
|
||||
/**
|
||||
* Compare this grade with another grade of the same system
|
||||
* Returns negative if this grade is easier, positive if harder, 0 if equal
|
||||
*/
|
||||
fun compareTo(other: DifficultyGrade): Int {
|
||||
if (system != other.system) return 0
|
||||
|
||||
return when (system) {
|
||||
DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade)
|
||||
DifficultySystem.FONT -> compareFontGrades(grade, other.grade)
|
||||
DifficultySystem.YDS -> compareYDSGrades(grade, other.grade)
|
||||
DifficultySystem.CUSTOM -> grade.compareTo(other.grade)
|
||||
}
|
||||
}
|
||||
|
||||
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
|
||||
// Handle VB (easiest) specially
|
||||
if (grade1 == "VB" && grade2 != "VB") return -1
|
||||
if (grade2 == "VB" && grade1 != "VB") return 1
|
||||
if (grade1 == "VB" && grade2 == "VB") return 0
|
||||
|
||||
// Extract numeric values for V grades
|
||||
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
|
||||
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
|
||||
return num1.compareTo(num2)
|
||||
}
|
||||
|
||||
private fun compareFontGrades(grade1: String, grade2: String): Int {
|
||||
// Simple string comparison for Font grades
|
||||
return grade1.compareTo(grade2)
|
||||
}
|
||||
|
||||
private fun compareYDSGrades(grade1: String, grade2: String): Int {
|
||||
// Simple string comparison for YDS grades
|
||||
return grade1.compareTo(grade2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +89,13 @@ class SessionTrackingService : Service() {
|
||||
private fun startSessionTracking(sessionId: String) {
|
||||
notificationJob?.cancel()
|
||||
notificationJob = serviceScope.launch {
|
||||
// Initial notification update
|
||||
updateNotification(sessionId)
|
||||
|
||||
// Then update every second
|
||||
while (isActive) {
|
||||
delay(1000L)
|
||||
updateNotification(sessionId)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,14 +121,15 @@ class SessionTrackingService : Service() {
|
||||
try {
|
||||
val start = LocalDateTime.parse(startTime)
|
||||
val now = LocalDateTime.now()
|
||||
val minutes = ChronoUnit.MINUTES.between(start, now)
|
||||
val hours = minutes / 60
|
||||
val remainingMinutes = minutes % 60
|
||||
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
|
||||
when {
|
||||
hours > 0 -> "${hours}h ${remainingMinutes}m"
|
||||
remainingMinutes > 0 -> "${remainingMinutes}m"
|
||||
else -> "< 1m"
|
||||
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||
else -> "${totalSeconds}s"
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
"Active"
|
||||
@@ -150,6 +155,10 @@ class SessionTrackingService : Service() {
|
||||
)
|
||||
.build()
|
||||
|
||||
// Force update the notification every second
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
} catch (_: Exception) {
|
||||
// Handle errors gracefully
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.atridad.openclimb.ui
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -67,7 +68,7 @@ fun OpenClimbApp() {
|
||||
LaunchedEffect(gyms, activeSession) {
|
||||
fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
|
||||
FabConfig(
|
||||
icon = Icons.Default.Add,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
contentDescription = "Start Session",
|
||||
onClick = {
|
||||
if (gyms.size == 1) {
|
||||
@@ -151,10 +152,7 @@ fun OpenClimbApp() {
|
||||
SessionDetailScreen(
|
||||
sessionId = args.sessionId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { sessionId ->
|
||||
navController.navigate(Screen.AddEditSession(sessionId = sessionId))
|
||||
}
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -208,6 +206,7 @@ fun OpenClimbApp() {
|
||||
|
||||
composable<Screen.AddEditSession> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditSession>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AddEditSessionScreen(
|
||||
sessionId = args.sessionId,
|
||||
gymId = args.gymId,
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.atridad.openclimb.ui.components
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -13,6 +12,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.data.model.ClimbSession
|
||||
import com.atridad.openclimb.data.model.Gym
|
||||
import com.atridad.openclimb.ui.theme.CustomIcons
|
||||
import kotlinx.coroutines.delay
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@@ -24,6 +25,16 @@ fun ActiveSessionBanner(
|
||||
onEndSession: () -> Unit
|
||||
) {
|
||||
if (activeSession != null) {
|
||||
// Add a timer that updates every second for real-time duration counting
|
||||
var currentTime by remember { mutableStateOf(LocalDateTime.now()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(1000) // Update every second
|
||||
currentTime = LocalDateTime.now()
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -67,7 +78,7 @@ fun ActiveSessionBanner(
|
||||
)
|
||||
|
||||
activeSession.startTime?.let { startTime ->
|
||||
val duration = calculateDuration(startTime)
|
||||
val duration = calculateDuration(startTime, currentTime)
|
||||
Text(
|
||||
text = duration,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -84,7 +95,7 @@ fun ActiveSessionBanner(
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
|
||||
contentDescription = "End session"
|
||||
)
|
||||
}
|
||||
@@ -93,18 +104,18 @@ fun ActiveSessionBanner(
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateDuration(startTimeString: String): String {
|
||||
private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String {
|
||||
return try {
|
||||
val startTime = LocalDateTime.parse(startTimeString)
|
||||
val now = LocalDateTime.now()
|
||||
val minutes = ChronoUnit.MINUTES.between(startTime, now)
|
||||
val hours = minutes / 60
|
||||
val remainingMinutes = minutes % 60
|
||||
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
|
||||
when {
|
||||
hours > 0 -> "${hours}h ${remainingMinutes}m"
|
||||
remainingMinutes > 0 -> "${remainingMinutes}m"
|
||||
else -> "< 1m"
|
||||
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||
else -> "${totalSeconds}s"
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
"Active"
|
||||
|
||||
@@ -5,11 +5,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -18,21 +16,12 @@ import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.ui.components.ImagePicker
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.time.LocalDateTime
|
||||
|
||||
// Data class for attempt input
|
||||
data class AttemptInput(
|
||||
val problemId: String,
|
||||
val result: AttemptResult,
|
||||
val highestHold: String = "",
|
||||
val notes: String = ""
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddEditGymScreen(
|
||||
@@ -278,7 +267,6 @@ fun AddEditProblemScreen(
|
||||
notes = p.notes ?: ""
|
||||
isActive = p.isActive
|
||||
imagePaths = p.imagePaths
|
||||
// Set the selected gym for the existing problem
|
||||
selectedGym = gyms.find { it.id == p.gymId }
|
||||
}
|
||||
}
|
||||
@@ -700,7 +688,6 @@ fun AddEditSessionScreen(
|
||||
) {
|
||||
val isEditing = sessionId != null
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
val problems by viewModel.problems.collectAsState()
|
||||
|
||||
// Session form state
|
||||
var selectedGym by remember { mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } }) }
|
||||
@@ -708,10 +695,6 @@ fun AddEditSessionScreen(
|
||||
var duration by remember { mutableStateOf("") }
|
||||
var sessionNotes by remember { mutableStateOf("") }
|
||||
|
||||
// Attempt tracking state
|
||||
var attempts by remember { mutableStateOf(listOf<AttemptInput>()) }
|
||||
var showAddAttemptDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Load existing session data for editing
|
||||
LaunchedEffect(sessionId) {
|
||||
if (sessionId != null) {
|
||||
@@ -753,17 +736,6 @@ fun AddEditSessionScreen(
|
||||
viewModel.updateSession(session.copy(id = sessionId))
|
||||
} else {
|
||||
viewModel.addSession(session)
|
||||
|
||||
attempts.forEach { attemptInput ->
|
||||
val attempt = Attempt.create(
|
||||
sessionId = session.id,
|
||||
problemId = attemptInput.problemId,
|
||||
result = attemptInput.result,
|
||||
highestHold = attemptInput.highestHold.ifBlank { null },
|
||||
notes = attemptInput.notes.ifBlank { null }
|
||||
)
|
||||
viewModel.addAttempt(attempt)
|
||||
}
|
||||
}
|
||||
onNavigateBack()
|
||||
}
|
||||
@@ -774,15 +746,6 @@ fun AddEditSessionScreen(
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (selectedGym != null) {
|
||||
FloatingActionButton(
|
||||
onClick = { showAddAttemptDialog = true }
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add Attempt")
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
@@ -878,285 +841,9 @@ fun AddEditSessionScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attempts Section
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Attempts (${attempts.size})",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
if (attempts.isEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "No attempts recorded yet. Add an attempt to track your progress.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attempts List
|
||||
items(attempts.size) { index ->
|
||||
val attempt = attempts[index]
|
||||
val problem = problems.find { it.id == attempt.problemId }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = problem?.name ?: "Unknown Problem",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
problem?.difficulty?.let { difficulty ->
|
||||
Text(
|
||||
text = "${difficulty.system.getDisplayName()}: ${difficulty.grade}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Result: ${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = when (attempt.result) {
|
||||
AttemptResult.SUCCESS, AttemptResult.FLASH -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
|
||||
if (attempt.highestHold.isNotBlank()) {
|
||||
Text(
|
||||
text = "Highest hold: ${attempt.highestHold}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (attempt.notes.isNotBlank()) {
|
||||
Text(
|
||||
text = attempt.notes,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
attempts = attempts.toMutableList().apply { removeAt(index) }
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Remove attempt")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddAttemptDialog && selectedGym != null) {
|
||||
AddAttemptDialog(
|
||||
problems = problems.filter { it.gymId == selectedGym!!.id && it.isActive },
|
||||
onDismiss = { showAddAttemptDialog = false },
|
||||
onAddAttempt = { attemptInput ->
|
||||
attempts = attempts + attemptInput
|
||||
showAddAttemptDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddAttemptDialog(
|
||||
problems: List<Problem>,
|
||||
onDismiss: () -> Unit,
|
||||
onAddAttempt: (AttemptInput) -> Unit
|
||||
) {
|
||||
var selectedProblem by remember { mutableStateOf<Problem?>(null) }
|
||||
var selectedResult by remember { mutableStateOf(AttemptResult.FALL) }
|
||||
var highestHold by remember { mutableStateOf("") }
|
||||
var notes by remember { mutableStateOf("") }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Add Attempt",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
// Problem Selection
|
||||
Text(
|
||||
text = "Problem",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
if (problems.isEmpty()) {
|
||||
Text(
|
||||
text = "No active problems in this gym. Add some problems first.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.height(120.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(problems) { problem ->
|
||||
Card(
|
||||
onClick = { selectedProblem = problem },
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selectedProblem?.id == problem.id)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surface
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = problem.name ?: "Unnamed Problem",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Result Selection
|
||||
Text(
|
||||
text = "Result",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.selectableGroup()) {
|
||||
AttemptResult.entries.forEach { result ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = selectedResult == result,
|
||||
onClick = { selectedResult = result },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedResult == result,
|
||||
onClick = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = result.name.lowercase().replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Highest Hold
|
||||
OutlinedTextField(
|
||||
value = highestHold,
|
||||
onValueChange = { highestHold = it },
|
||||
label = { Text("Highest Hold (Optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
placeholder = { Text("e.g., 'jugs near the top', 'crux move'") }
|
||||
)
|
||||
|
||||
// Notes
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text("Notes (Optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
placeholder = { Text("e.g., 'need to work on heel hooks', 'pumped out'") }
|
||||
)
|
||||
|
||||
// Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
selectedProblem?.let { problem ->
|
||||
onAddAttempt(
|
||||
AttemptInput(
|
||||
problemId = problem.id,
|
||||
result = selectedResult,
|
||||
highestHold = highestHold,
|
||||
notes = notes
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = selectedProblem != null,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Add Attempt")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -93,41 +93,6 @@ fun AnalyticsScreen(
|
||||
val recentSessions = sessions.take(5)
|
||||
RecentActivityCard(recentSessions = recentSessions.size)
|
||||
}
|
||||
|
||||
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Progress Charts",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Detailed charts and analytics coming soon!",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "📊",
|
||||
style = MaterialTheme.typography.displaySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
package com.atridad.openclimb.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object CustomIcons {
|
||||
fun Stop(color: Color = Color.Black): ImageVector = ImageVector.Builder(
|
||||
name = "Stop",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f
|
||||
).path(
|
||||
fill = SolidColor(color)
|
||||
) {
|
||||
moveTo(6f, 6f)
|
||||
horizontalLineTo(18f)
|
||||
verticalLineTo(18f)
|
||||
horizontalLineTo(6f)
|
||||
close()
|
||||
}.build()
|
||||
}
|
||||
@@ -193,6 +193,18 @@ class ClimbViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteAttempt(attempt)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch {
|
||||
repository.updateAttempt(attempt)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||
repository.getAttemptsBySession(sessionId)
|
||||
|
||||
|
||||
@@ -35,34 +35,92 @@ object ImageUtils {
|
||||
*/
|
||||
fun saveImageFromUri(context: Context, imageUri: Uri): String? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||
inputStream?.use { input ->
|
||||
// Decode and compress the image
|
||||
val originalBitmap = BitmapFactory.decodeStream(input)
|
||||
val compressedBitmap = compressImage(originalBitmap)
|
||||
// Decode bitmap from a fresh stream to avoid mark/reset dependency
|
||||
val originalBitmap = context.contentResolver.openInputStream(imageUri)?.use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
} ?: return null
|
||||
|
||||
// Generate unique filename
|
||||
val filename = "${UUID.randomUUID()}.jpg"
|
||||
val imageFile = File(getImagesDirectory(context), filename)
|
||||
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||
val compressedBitmap = compressImage(orientedBitmap)
|
||||
|
||||
// Save compressed image
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||
}
|
||||
// Generate unique filename
|
||||
val filename = "${UUID.randomUUID()}.jpg"
|
||||
val imageFile = File(getImagesDirectory(context), filename)
|
||||
|
||||
// Clean up bitmaps
|
||||
originalBitmap.recycle()
|
||||
compressedBitmap.recycle()
|
||||
|
||||
// Return relative path
|
||||
"$IMAGES_DIR/$filename"
|
||||
// Save compressed image
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||
}
|
||||
|
||||
// Clean up bitmaps
|
||||
originalBitmap.recycle()
|
||||
if (orientedBitmap != originalBitmap) {
|
||||
orientedBitmap.recycle()
|
||||
}
|
||||
compressedBitmap.recycle()
|
||||
|
||||
// Return relative path
|
||||
"$IMAGES_DIR/$filename"
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects image orientation based on EXIF data
|
||||
*/
|
||||
private fun correctImageOrientation(context: Context, imageUri: Uri, bitmap: Bitmap): Bitmap {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||
inputStream?.use { input ->
|
||||
val exif = android.media.ExifInterface(input)
|
||||
val orientation = exif.getAttributeInt(
|
||||
android.media.ExifInterface.TAG_ORIENTATION,
|
||||
android.media.ExifInterface.ORIENTATION_NORMAL
|
||||
)
|
||||
|
||||
val matrix = android.graphics.Matrix()
|
||||
when (orientation) {
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
||||
matrix.postRotate(90f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
||||
matrix.postRotate(180f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
||||
matrix.postRotate(270f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||
matrix.postScale(1f, -1f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
matrix.postRotate(90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
matrix.postRotate(-90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
if (matrix.isIdentity) {
|
||||
bitmap
|
||||
} else {
|
||||
android.graphics.Bitmap.createBitmap(
|
||||
bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
|
||||
)
|
||||
}
|
||||
} ?: bitmap
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses and resizes an image bitmap
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,8 @@ object SessionShareUtils {
|
||||
val uniqueProblemsCompleted: Int,
|
||||
val averageGrade: String?,
|
||||
val sessionDuration: String,
|
||||
val topResult: AttemptResult?
|
||||
val topResult: AttemptResult?,
|
||||
val topGrade: String?
|
||||
)
|
||||
|
||||
fun calculateSessionStats(
|
||||
@@ -42,15 +43,34 @@ object SessionShareUtils {
|
||||
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
|
||||
|
||||
val attemptedProblems = problems.filter { it.id in uniqueProblems }
|
||||
val averageGrade = if (attemptedProblems.isNotEmpty()) {
|
||||
// This is a simplified average - in reality you'd need proper grade conversion
|
||||
val gradeValues = attemptedProblems.mapNotNull { problem ->
|
||||
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
|
||||
}
|
||||
if (gradeValues.isNotEmpty()) {
|
||||
"V${gradeValues.average().roundToInt()}"
|
||||
} else null
|
||||
} else null
|
||||
|
||||
// Calculate separate averages for different climbing types and difficulty systems
|
||||
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
|
||||
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
|
||||
|
||||
val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder")
|
||||
val ropeAverage = calculateAverageGrade(ropeProblems, "Rope")
|
||||
|
||||
// Combine averages for display
|
||||
val averageGrade = when {
|
||||
boulderAverage != null && ropeAverage != null -> "$boulderAverage / $ropeAverage"
|
||||
boulderAverage != null -> boulderAverage
|
||||
ropeAverage != null -> ropeAverage
|
||||
else -> null
|
||||
}
|
||||
|
||||
// Determine highest achieved grade (only from completed problems: SUCCESS or FLASH)
|
||||
val completedProblems = problems.filter { it.id in uniqueCompletedProblems }
|
||||
val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER }
|
||||
val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE }
|
||||
val topBoulder = highestGradeForProblems(completedBoulder)
|
||||
val topRope = highestGradeForProblems(completedRope)
|
||||
val topGrade = when {
|
||||
topBoulder != null && topRope != null -> "$topBoulder / $topRope"
|
||||
topBoulder != null -> topBoulder
|
||||
topRope != null -> topRope
|
||||
else -> null
|
||||
}
|
||||
|
||||
val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
|
||||
val topResult = attempts.maxByOrNull {
|
||||
@@ -70,10 +90,81 @@ object SessionShareUtils {
|
||||
uniqueProblemsCompleted = uniqueCompletedProblems.size,
|
||||
averageGrade = averageGrade,
|
||||
sessionDuration = duration,
|
||||
topResult = topResult
|
||||
topResult = topResult,
|
||||
topGrade = topGrade
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average grade for a specific set of problems, respecting their difficulty systems
|
||||
*/
|
||||
private fun calculateAverageGrade(problems: List<Problem>, climbingType: String): String? {
|
||||
if (problems.isEmpty()) return null
|
||||
|
||||
// Group problems by difficulty system
|
||||
val problemsBySystem = problems.groupBy { it.difficulty.system }
|
||||
|
||||
val averages = mutableListOf<String>()
|
||||
|
||||
problemsBySystem.forEach { (system, systemProblems) ->
|
||||
when (system) {
|
||||
DifficultySystem.V_SCALE -> {
|
||||
val gradeValues = systemProblems.mapNotNull { problem ->
|
||||
when {
|
||||
problem.difficulty.grade == "VB" -> 0
|
||||
else -> problem.difficulty.grade.removePrefix("V").toIntOrNull()
|
||||
}
|
||||
}
|
||||
if (gradeValues.isNotEmpty()) {
|
||||
val avg = gradeValues.average().roundToInt()
|
||||
averages.add(if (avg == 0) "VB" else "V$avg")
|
||||
}
|
||||
}
|
||||
DifficultySystem.FONT -> {
|
||||
val gradeValues = systemProblems.mapNotNull { problem ->
|
||||
// Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" -> 7)
|
||||
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
|
||||
}
|
||||
if (gradeValues.isNotEmpty()) {
|
||||
val avg = gradeValues.average().roundToInt()
|
||||
averages.add("$avg")
|
||||
}
|
||||
}
|
||||
DifficultySystem.YDS -> {
|
||||
val gradeValues = systemProblems.mapNotNull { problem ->
|
||||
// Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10)
|
||||
val grade = problem.difficulty.grade
|
||||
if (grade.startsWith("5.")) {
|
||||
grade.substring(2).toDoubleOrNull()
|
||||
} else null
|
||||
}
|
||||
if (gradeValues.isNotEmpty()) {
|
||||
val avg = gradeValues.average()
|
||||
averages.add("5.${String.format("%.1f", avg)}")
|
||||
}
|
||||
}
|
||||
DifficultySystem.CUSTOM -> {
|
||||
// For custom systems, try to extract numeric values
|
||||
val gradeValues = systemProblems.mapNotNull { problem ->
|
||||
problem.difficulty.grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull()
|
||||
}
|
||||
if (gradeValues.isNotEmpty()) {
|
||||
val avg = gradeValues.average()
|
||||
averages.add(String.format("%.1f", avg))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (averages.isNotEmpty()) {
|
||||
if (averages.size == 1) {
|
||||
averages.first()
|
||||
} else {
|
||||
averages.joinToString(" / ")
|
||||
}
|
||||
} else null
|
||||
}
|
||||
|
||||
fun generateShareCard(
|
||||
context: Context,
|
||||
session: ClimbSession,
|
||||
@@ -81,8 +172,8 @@ object SessionShareUtils {
|
||||
stats: SessionStats
|
||||
): File? {
|
||||
return try {
|
||||
val width = 1080
|
||||
val height = 1350
|
||||
val width = 1242 // 3:4 aspect at higher resolution for better fit
|
||||
val height = 1656
|
||||
|
||||
val bitmap = createBitmap(width, height)
|
||||
val canvas = Canvas(bitmap)
|
||||
@@ -136,7 +227,7 @@ object SessionShareUtils {
|
||||
}
|
||||
|
||||
// Draw main card background
|
||||
val cardRect = RectF(60f, 200f, width - 60f, height - 100f)
|
||||
val cardRect = RectF(60f, 200f, width - 60f, height - 120f)
|
||||
canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
|
||||
|
||||
// Draw content
|
||||
@@ -157,31 +248,48 @@ object SessionShareUtils {
|
||||
// Stats grid
|
||||
val statsStartY = yPosition
|
||||
val columnWidth = width / 2f
|
||||
val columnMaxTextWidth = columnWidth - 120f
|
||||
|
||||
// Left column stats
|
||||
var leftY = statsStartY
|
||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint)
|
||||
leftY += 140f
|
||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint)
|
||||
leftY += 140f
|
||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint)
|
||||
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
leftY += 120f
|
||||
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
leftY += 120f
|
||||
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
|
||||
// Right column stats
|
||||
var rightY = statsStartY
|
||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint)
|
||||
rightY += 140f
|
||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint)
|
||||
rightY += 140f
|
||||
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
rightY += 120f
|
||||
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
rightY += 120f
|
||||
|
||||
stats.averageGrade?.let { grade ->
|
||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Avg Grade", grade, statLabelPaint, statValuePaint)
|
||||
var rightYAfter = rightY
|
||||
stats.topGrade?.let { grade ->
|
||||
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
rightYAfter += 120f
|
||||
}
|
||||
|
||||
// Grade range(s)
|
||||
val boulderRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.BOULDER })
|
||||
val ropeRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE })
|
||||
val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f
|
||||
if (boulderRange != null && ropeRange != null) {
|
||||
// Two evenly spaced items
|
||||
drawStatItemFitting(canvas, columnWidth / 2f, rangesY, "Boulder Range", boulderRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
drawStatItemFitting(canvas, width - columnWidth / 2f, rangesY, "Rope Range", ropeRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
} else if (boulderRange != null || ropeRange != null) {
|
||||
// Single centered item
|
||||
val singleRange = boulderRange ?: ropeRange ?: ""
|
||||
drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f)
|
||||
}
|
||||
|
||||
// Success rate arc
|
||||
if (stats.totalAttempts > 0) {
|
||||
val successRate = (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100
|
||||
drawSuccessRateArc(canvas, width / 2f, height - 280f, successRate, statLabelPaint, statValuePaint)
|
||||
}
|
||||
val successRate = if (stats.totalAttempts > 0) {
|
||||
(stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100f
|
||||
} else 0f
|
||||
drawSuccessRateArc(canvas, width / 2f, height - 300f, successRate, statLabelPaint, statValuePaint)
|
||||
|
||||
// App branding
|
||||
val brandingPaint = Paint().apply {
|
||||
@@ -229,6 +337,41 @@ object SessionShareUtils {
|
||||
canvas.drawText(label, x, y + 50f, labelPaint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a stat item while fitting the value text to a max width by reducing text size if needed.
|
||||
*/
|
||||
private fun drawStatItemFitting(
|
||||
canvas: Canvas,
|
||||
x: Float,
|
||||
y: Float,
|
||||
label: String,
|
||||
value: String,
|
||||
labelPaint: Paint,
|
||||
valuePaint: Paint,
|
||||
maxTextWidth: Float
|
||||
) {
|
||||
val tempPaint = Paint(valuePaint)
|
||||
var textSize = tempPaint.textSize
|
||||
var textWidth = tempPaint.measureText(value)
|
||||
while (textWidth > maxTextWidth && textSize > 36f) {
|
||||
textSize -= 2f
|
||||
tempPaint.textSize = textSize
|
||||
textWidth = tempPaint.measureText(value)
|
||||
}
|
||||
canvas.drawText(value, x, y, tempPaint)
|
||||
canvas.drawText(label, x, y + 50f, labelPaint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a range string like "X - Y" for the given problems, based on their difficulty grades.
|
||||
*/
|
||||
private fun gradeRangeForProblems(problems: List<Problem>): String? {
|
||||
if (problems.isEmpty()) return null
|
||||
val grades = problems.map { it.difficulty }
|
||||
val sorted = grades.sortedWith { a, b -> a.compareTo(b) }
|
||||
return "${sorted.first().grade} - ${sorted.last().grade}"
|
||||
}
|
||||
|
||||
private fun drawSuccessRateArc(
|
||||
canvas: Canvas,
|
||||
centerX: Float,
|
||||
@@ -237,12 +380,12 @@ object SessionShareUtils {
|
||||
labelPaint: Paint,
|
||||
valuePaint: Paint
|
||||
) {
|
||||
val radius = 80f
|
||||
val strokeWidth = 16f
|
||||
val radius = 70f
|
||||
val strokeWidth = 14f
|
||||
|
||||
// Background arc
|
||||
val bgPaint = Paint().apply {
|
||||
color = "#40FFFFFF".toColorInt()
|
||||
color = "#30FFFFFF".toColorInt()
|
||||
style = Paint.Style.STROKE
|
||||
this.strokeWidth = strokeWidth
|
||||
isAntiAlias = true
|
||||
@@ -269,8 +412,11 @@ object SessionShareUtils {
|
||||
|
||||
// Draw percentage text
|
||||
val percentText = "${successRate.roundToInt()}%"
|
||||
canvas.drawText(percentText, centerX, centerY + 10f, valuePaint)
|
||||
canvas.drawText("Success Rate", centerX, centerY + 60f, labelPaint)
|
||||
canvas.drawText(percentText, centerX, centerY + 8f, valuePaint)
|
||||
|
||||
// Draw label below the arc (outside the ring) for better readability
|
||||
val belowLabelPaint = Paint(labelPaint)
|
||||
canvas.drawText("Success Rate", centerX, centerY + radius + 36f, belowLabelPaint)
|
||||
}
|
||||
|
||||
private fun formatSessionDate(dateString: String): String {
|
||||
@@ -307,4 +453,48 @@ object SessionShareUtils {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the highest grade string among the given problems, respecting their difficulty system.
|
||||
*/
|
||||
private fun highestGradeForProblems(problems: List<Problem>): String? {
|
||||
if (problems.isEmpty()) return null
|
||||
return problems.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }?.difficulty?.grade
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a comparable numeric rank for grades across supported systems.
|
||||
*/
|
||||
private fun gradeRank(system: DifficultySystem, grade: String): Double {
|
||||
return when (system) {
|
||||
DifficultySystem.V_SCALE -> {
|
||||
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
|
||||
}
|
||||
DifficultySystem.FONT -> {
|
||||
val list = DifficultySystem.FONT.getAvailableGrades()
|
||||
val idx = list.indexOf(grade.uppercase())
|
||||
if (idx >= 0) idx.toDouble() else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
|
||||
}
|
||||
DifficultySystem.YDS -> {
|
||||
// Parse 5.X with optional letter a-d
|
||||
val s = grade.lowercase()
|
||||
if (!s.startsWith("5.")) return -1.0
|
||||
val tail = s.removePrefix("5.")
|
||||
val numberPart = tail.takeWhile { it.isDigit() || it == '.' }
|
||||
val letterPart = tail.drop(numberPart.length).firstOrNull()
|
||||
val base = numberPart.toDoubleOrNull() ?: return -1.0
|
||||
val letterWeight = when (letterPart) {
|
||||
'a' -> 0.0
|
||||
'b' -> 0.1
|
||||
'c' -> 0.2
|
||||
'd' -> 0.3
|
||||
else -> 0.0
|
||||
}
|
||||
base + letterWeight
|
||||
}
|
||||
DifficultySystem.CUSTOM -> {
|
||||
grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() ?: -1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
[versions]
|
||||
agp = "8.9.1"
|
||||
agp = "8.12.1"
|
||||
kotlin = "2.0.21"
|
||||
coreKtx = "1.15.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
androidxTestCore = "1.6.0"
|
||||
androidxTestExt = "1.2.0"
|
||||
androidxTestRunner = "1.6.0"
|
||||
androidxTestRules = "1.6.0"
|
||||
lifecycleRuntimeKtx = "2.9.2"
|
||||
activityCompose = "1.10.1"
|
||||
composeBom = "2024.09.00"
|
||||
@@ -21,6 +25,10 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref =
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
|
||||
androidx-test-ext = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
|
||||
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
|
||||
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
@@ -48,6 +56,10 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
|
||||
|
||||
# Coroutines
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||
|
||||
# Testing
|
||||
mockk = { group = "io.mockk", name = "mockk", version = "1.13.8" }
|
||||
|
||||
# Image Loading
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
#Fri Aug 15 11:23:25 MDT 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
Reference in New Issue
Block a user