Compare commits

..

5 Commits
0.4.3 ... 0.4.5

6 changed files with 158 additions and 73 deletions

2
.idea/misc.xml generated
View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <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> </project>

View File

@@ -8,14 +8,14 @@ plugins {
android { android {
namespace = "com.atridad.openclimb" namespace = "com.atridad.openclimb"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 36
versionCode = 10 versionCode = 12
versionName = "0.4.3" versionName = "0.4.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -30,12 +30,20 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "17"
} }
// Ensure consistent JVM toolchain across all tasks
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
buildFeatures { buildFeatures {
compose = true compose = true
} }
@@ -79,8 +87,14 @@ dependencies {
// Testing // Testing
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) 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(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)

View File

@@ -412,49 +412,53 @@ fun SessionDetailScreen(sessionId: String, viewModel: ClimbViewModel, onNavigate
} }
// Show grade range if available // Show grade range(s) with better layout
val grades = attemptedProblems.map { it.difficulty } val grades = attemptedProblems.map { it.difficulty }
if (grades.isNotEmpty()) { if (grades.isNotEmpty()) {
// Separate boulder and rope problems
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER } val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE } val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
val gradeRange = when { val boulderRange = if (boulderProblems.isNotEmpty()) {
boulderProblems.isNotEmpty() && ropeProblems.isNotEmpty() -> { val boulderGrades = boulderProblems.map { it.difficulty }
val boulderRange = if (boulderProblems.isNotEmpty()) { val sorted = boulderGrades.sortedWith { a, b -> a.compareTo(b) }
val boulderGrades = boulderProblems.map { it.difficulty } "${sorted.first().grade} - ${sorted.last().grade}"
val sortedBoulderGrades = boulderGrades.sortedWith { a, b -> a.compareTo(b) } } else null
"${sortedBoulderGrades.first().grade} - ${sortedBoulderGrades.last().grade}"
} else null val ropeRange = if (ropeProblems.isNotEmpty()) {
val ropeGrades = ropeProblems.map { it.difficulty }
val ropeRange = if (ropeProblems.isNotEmpty()) { val sorted = ropeGrades.sortedWith { a, b -> a.compareTo(b) }
val ropeGrades = ropeProblems.map { it.difficulty } "${sorted.first().grade} - ${sorted.last().grade}"
val sortedRopeGrades = ropeGrades.sortedWith { a, b -> a.compareTo(b) } } else null
"${sortedRopeGrades.first().grade} - ${sortedRopeGrades.last().grade}"
} else null if (boulderRange != null && ropeRange != null) {
Row(
when { modifier = Modifier.fillMaxWidth(),
boulderRange != null && ropeRange != null -> "$boulderRange / $ropeRange" horizontalArrangement = Arrangement.SpaceEvenly
boulderRange != null -> boulderRange ) {
ropeRange != null -> ropeRange StatItem(label = "Boulder Range", value = boulderRange)
else -> "N/A" StatItem(label = "Rope Range", value = ropeRange)
}
} }
else -> { } else {
val sortedGrades = grades.sortedWith { a, b -> a.compareTo(b) } Row(
"${sortedGrades.first().grade} - ${sortedGrades.last().grade}" modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
StatItem(
label = "Grade Range",
value = boulderRange ?: ropeRange ?: "N/A"
)
} }
} }
StatItem(
label = "Grade Range",
value = gradeRange
)
} else { } else {
StatItem( Row(
label = "Grade Range", modifier = Modifier.fillMaxWidth(),
value = "N/A" horizontalArrangement = Arrangement.Center
) ) {
StatItem(
label = "Grade Range",
value = "N/A"
)
}
} }
} }
} }

View File

@@ -172,8 +172,8 @@ object SessionShareUtils {
stats: SessionStats stats: SessionStats
): File? { ): File? {
return try { return try {
val width = 1080 val width = 1242 // 3:4 aspect at higher resolution for better fit
val height = 1350 val height = 1656
val bitmap = createBitmap(width, height) val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
@@ -227,7 +227,7 @@ object SessionShareUtils {
} }
// Draw main card background // 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) canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
// Draw content // Draw content
@@ -248,31 +248,48 @@ object SessionShareUtils {
// Stats grid // Stats grid
val statsStartY = yPosition val statsStartY = yPosition
val columnWidth = width / 2f val columnWidth = width / 2f
val columnMaxTextWidth = columnWidth - 120f
// Left column stats // Left column stats
var leftY = statsStartY var leftY = statsStartY
drawStatItem(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
leftY += 140f leftY += 120f
drawStatItem(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
leftY += 140f leftY += 120f
drawStatItem(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint) drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint, columnMaxTextWidth)
// Right column stats // Right column stats
var rightY = statsStartY var rightY = statsStartY
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
rightY += 140f rightY += 120f
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
rightY += 140f rightY += 120f
var rightYAfter = rightY
stats.topGrade?.let { grade -> stats.topGrade?.let { grade ->
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint) 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 // Success rate arc
if (stats.totalAttempts > 0) { val successRate = if (stats.totalAttempts > 0) {
val successRate = (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100 (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100f
drawSuccessRateArc(canvas, width / 2f, height - 280f, successRate, statLabelPaint, statValuePaint) } else 0f
} drawSuccessRateArc(canvas, width / 2f, height - 300f, successRate, statLabelPaint, statValuePaint)
// App branding // App branding
val brandingPaint = Paint().apply { val brandingPaint = Paint().apply {
@@ -320,6 +337,41 @@ object SessionShareUtils {
canvas.drawText(label, x, y + 50f, labelPaint) 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( private fun drawSuccessRateArc(
canvas: Canvas, canvas: Canvas,
centerX: Float, centerX: Float,
@@ -328,18 +380,18 @@ object SessionShareUtils {
labelPaint: Paint, labelPaint: Paint,
valuePaint: Paint valuePaint: Paint
) { ) {
val radius = 80f val radius = 70f
val strokeWidth = 16f val strokeWidth = 14f
// Background arc // Background arc
val bgPaint = Paint().apply { val bgPaint = Paint().apply {
color = "#40FFFFFF".toColorInt() color = "#30FFFFFF".toColorInt()
style = Paint.Style.STROKE style = Paint.Style.STROKE
this.strokeWidth = strokeWidth this.strokeWidth = strokeWidth
isAntiAlias = true isAntiAlias = true
strokeCap = Paint.Cap.ROUND strokeCap = Paint.Cap.ROUND
} }
// Success arc // Success arc
val successPaint = Paint().apply { val successPaint = Paint().apply {
color = "#4CAF50".toColorInt() color = "#4CAF50".toColorInt()
@@ -348,20 +400,23 @@ object SessionShareUtils {
isAntiAlias = true isAntiAlias = true
strokeCap = Paint.Cap.ROUND strokeCap = Paint.Cap.ROUND
} }
val rect = RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius) val rect = RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius)
// Draw background arc (full circle) // Draw background arc (full circle)
canvas.drawArc(rect, -90f, 360f, false, bgPaint) canvas.drawArc(rect, -90f, 360f, false, bgPaint)
// Draw success arc // Draw success arc
val sweepAngle = (successRate / 100f) * 360f val sweepAngle = (successRate / 100f) * 360f
canvas.drawArc(rect, -90f, sweepAngle, false, successPaint) canvas.drawArc(rect, -90f, sweepAngle, false, successPaint)
// Draw percentage text // Draw percentage text
val percentText = "${successRate.roundToInt()}%" val percentText = "${successRate.roundToInt()}%"
canvas.drawText(percentText, centerX, centerY + 10f, valuePaint) canvas.drawText(percentText, centerX, centerY + 8f, valuePaint)
canvas.drawText("Success Rate", centerX, centerY + 60f, labelPaint)
// 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 { private fun formatSessionDate(dateString: String): String {

View File

@@ -1,10 +1,14 @@
[versions] [versions]
agp = "8.9.1" agp = "8.12.0"
kotlin = "2.0.21" kotlin = "2.0.21"
coreKtx = "1.15.0" coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
espressoCore = "3.7.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" lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1" activityCompose = "1.10.1"
composeBom = "2024.09.00" 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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-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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 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 # Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } 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 # Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }

View File

@@ -1,6 +1,6 @@
#Fri Aug 15 11:23:25 MDT 2025 #Fri Aug 15 11:23:25 MDT 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists