Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
96759e402d
|
|||
|
ed76fb2fb2
|
|||
|
870278f240
|
|||
|
4eef77bd3b
|
|||
|
2d957db948
|
|||
| 22bed6a961 | |||
|
b443c18a19
|
|||
|
89f1e350b3
|
|||
|
0f976f685f
|
|||
|
c07186a7df
|
|||
|
15a5e217a5
|
|||
|
b86ab591fe
|
|||
|
70c85d159e
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Gradle files
|
# Gradle files
|
||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
|
release/
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
# Local configuration file (sdk path, etc)
|
||||||
local.properties
|
local.properties
|
||||||
|
|||||||
13
.idea/caches/deviceStreaming.xml
generated
13
.idea/caches/deviceStreaming.xml
generated
@@ -817,6 +817,19 @@
|
|||||||
<option name="screenX" value="1080" />
|
<option name="screenX" value="1080" />
|
||||||
<option name="screenY" value="2424" />
|
<option name="screenY" value="2424" />
|
||||||
</PersistentDeviceSelectionData>
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="36" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="tokay" />
|
||||||
|
<option name="default" value="true" />
|
||||||
|
<option name="id" value="tokay" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2424" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
<PersistentDeviceSelectionData>
|
<PersistentDeviceSelectionData>
|
||||||
<option name="api" value="34" />
|
<option name="api" value="34" />
|
||||||
<option name="brand" value="samsung" />
|
<option name="brand" value="samsung" />
|
||||||
|
|||||||
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@@ -1,5 +1,4 @@
|
|||||||
<?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>
|
||||||
@@ -7,7 +7,7 @@ This is a FOSS Android app meant to help climbers track their sessions, routes/p
|
|||||||
You have two options:
|
You have two options:
|
||||||
|
|
||||||
1. Download the latest APK from the Released page
|
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
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 6
|
versionCode = 14
|
||||||
versionName = "0.3.3"
|
versionName = "1.0.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -30,12 +30,19 @@ 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion.set(JavaLanguageVersion.of(17))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
@@ -74,13 +81,16 @@ dependencies {
|
|||||||
// Image Loading
|
// Image Loading
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
// Charts - Placeholder for future implementation
|
|
||||||
// Charts will be implemented with a stable library in future versions
|
|
||||||
|
|
||||||
// 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)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.OpenClimb">
|
android:theme="@style/Theme.OpenClimb.Splash">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.atridad.openclimb.ui.theme.OpenClimbTheme
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
setTheme(R.style.Theme_OpenClimb)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
OpenClimbTheme {
|
OpenClimbTheme {
|
||||||
|
|||||||
@@ -68,4 +68,41 @@ data class DifficultyGrade(
|
|||||||
val system: DifficultySystem,
|
val system: DifficultySystem,
|
||||||
val grade: String,
|
val grade: String,
|
||||||
val numericValue: Int
|
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) {
|
private fun startSessionTracking(sessionId: String) {
|
||||||
notificationJob?.cancel()
|
notificationJob?.cancel()
|
||||||
notificationJob = serviceScope.launch {
|
notificationJob = serviceScope.launch {
|
||||||
while (isActive) {
|
// Initial notification update
|
||||||
|
updateNotification(sessionId)
|
||||||
|
|
||||||
|
// Then update every second
|
||||||
|
while (isActive) {
|
||||||
|
delay(1000L)
|
||||||
updateNotification(sessionId)
|
updateNotification(sessionId)
|
||||||
delay(1000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,14 +121,15 @@ class SessionTrackingService : Service() {
|
|||||||
try {
|
try {
|
||||||
val start = LocalDateTime.parse(startTime)
|
val start = LocalDateTime.parse(startTime)
|
||||||
val now = LocalDateTime.now()
|
val now = LocalDateTime.now()
|
||||||
val minutes = ChronoUnit.MINUTES.between(start, now)
|
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
|
||||||
val hours = minutes / 60
|
val hours = totalSeconds / 3600
|
||||||
val remainingMinutes = minutes % 60
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
|
||||||
when {
|
when {
|
||||||
hours > 0 -> "${hours}h ${remainingMinutes}m"
|
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||||
remainingMinutes > 0 -> "${remainingMinutes}m"
|
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||||
else -> "< 1m"
|
else -> "${totalSeconds}s"
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
"Active"
|
"Active"
|
||||||
@@ -150,6 +155,10 @@ class SessionTrackingService : Service() {
|
|||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
// Force update the notification every second
|
||||||
|
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Handle errors gracefully
|
// Handle errors gracefully
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.atridad.openclimb.ui
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -67,7 +68,7 @@ fun OpenClimbApp() {
|
|||||||
LaunchedEffect(gyms, activeSession) {
|
LaunchedEffect(gyms, activeSession) {
|
||||||
fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
|
fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
|
||||||
FabConfig(
|
FabConfig(
|
||||||
icon = Icons.Default.Add,
|
icon = Icons.Default.PlayArrow,
|
||||||
contentDescription = "Start Session",
|
contentDescription = "Start Session",
|
||||||
onClick = {
|
onClick = {
|
||||||
if (gyms.size == 1) {
|
if (gyms.size == 1) {
|
||||||
@@ -152,8 +153,8 @@ fun OpenClimbApp() {
|
|||||||
sessionId = args.sessionId,
|
sessionId = args.sessionId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToEdit = { sessionId ->
|
onNavigateToProblemDetail = { problemId ->
|
||||||
navController.navigate(Screen.AddEditSession(sessionId = sessionId))
|
navController.navigate(Screen.ProblemDetail(problemId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -180,6 +181,12 @@ fun OpenClimbApp() {
|
|||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToEdit = { gymId ->
|
onNavigateToEdit = { gymId ->
|
||||||
navController.navigate(Screen.AddEditGym(gymId = gymId))
|
navController.navigate(Screen.AddEditGym(gymId = gymId))
|
||||||
|
},
|
||||||
|
onNavigateToSessionDetail = { sessionId ->
|
||||||
|
navController.navigate(Screen.SessionDetail(sessionId))
|
||||||
|
},
|
||||||
|
onNavigateToProblemDetail = { problemId ->
|
||||||
|
navController.navigate(Screen.ProblemDetail(problemId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -208,6 +215,7 @@ fun OpenClimbApp() {
|
|||||||
|
|
||||||
composable<Screen.AddEditSession> { backStackEntry ->
|
composable<Screen.AddEditSession> { backStackEntry ->
|
||||||
val args = backStackEntry.toRoute<Screen.AddEditSession>()
|
val args = backStackEntry.toRoute<Screen.AddEditSession>()
|
||||||
|
LaunchedEffect(Unit) { fabConfig = null }
|
||||||
AddEditSessionScreen(
|
AddEditSessionScreen(
|
||||||
sessionId = args.sessionId,
|
sessionId = args.sessionId,
|
||||||
gymId = args.gymId,
|
gymId = args.gymId,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.atridad.openclimb.ui.components
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -13,6 +12,8 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.data.model.ClimbSession
|
import com.atridad.openclimb.data.model.ClimbSession
|
||||||
import com.atridad.openclimb.data.model.Gym
|
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.LocalDateTime
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
@@ -24,6 +25,16 @@ fun ActiveSessionBanner(
|
|||||||
onEndSession: () -> Unit
|
onEndSession: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (activeSession != null) {
|
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(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -67,7 +78,7 @@ fun ActiveSessionBanner(
|
|||||||
)
|
)
|
||||||
|
|
||||||
activeSession.startTime?.let { startTime ->
|
activeSession.startTime?.let { startTime ->
|
||||||
val duration = calculateDuration(startTime)
|
val duration = calculateDuration(startTime, currentTime)
|
||||||
Text(
|
Text(
|
||||||
text = duration,
|
text = duration,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -84,7 +95,7 @@ fun ActiveSessionBanner(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Close,
|
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
|
||||||
contentDescription = "End session"
|
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 {
|
return try {
|
||||||
val startTime = LocalDateTime.parse(startTimeString)
|
val startTime = LocalDateTime.parse(startTimeString)
|
||||||
val now = LocalDateTime.now()
|
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
|
||||||
val minutes = ChronoUnit.MINUTES.between(startTime, now)
|
val hours = totalSeconds / 3600
|
||||||
val hours = minutes / 60
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
val remainingMinutes = minutes % 60
|
val seconds = totalSeconds % 60
|
||||||
|
|
||||||
when {
|
when {
|
||||||
hours > 0 -> "${hours}h ${remainingMinutes}m"
|
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||||
remainingMinutes > 0 -> "${remainingMinutes}m"
|
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||||
else -> "< 1m"
|
else -> "${totalSeconds}s"
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
"Active"
|
"Active"
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.selection.selectable
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.openclimb.data.model.*
|
||||||
import com.atridad.openclimb.ui.components.ImagePicker
|
import com.atridad.openclimb.ui.components.ImagePicker
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import java.time.LocalDateTime
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AddEditGymScreen(
|
fun AddEditGymScreen(
|
||||||
@@ -278,7 +267,6 @@ fun AddEditProblemScreen(
|
|||||||
notes = p.notes ?: ""
|
notes = p.notes ?: ""
|
||||||
isActive = p.isActive
|
isActive = p.isActive
|
||||||
imagePaths = p.imagePaths
|
imagePaths = p.imagePaths
|
||||||
// Set the selected gym for the existing problem
|
|
||||||
selectedGym = gyms.find { it.id == p.gymId }
|
selectedGym = gyms.find { it.id == p.gymId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -700,7 +688,6 @@ fun AddEditSessionScreen(
|
|||||||
) {
|
) {
|
||||||
val isEditing = sessionId != null
|
val isEditing = sessionId != null
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
val problems by viewModel.problems.collectAsState()
|
|
||||||
|
|
||||||
// Session form state
|
// Session form state
|
||||||
var selectedGym by remember { mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } }) }
|
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 duration by remember { mutableStateOf("") }
|
||||||
var sessionNotes 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
|
// Load existing session data for editing
|
||||||
LaunchedEffect(sessionId) {
|
LaunchedEffect(sessionId) {
|
||||||
if (sessionId != null) {
|
if (sessionId != null) {
|
||||||
@@ -753,17 +736,6 @@ fun AddEditSessionScreen(
|
|||||||
viewModel.updateSession(session.copy(id = sessionId))
|
viewModel.updateSession(session.copy(id = sessionId))
|
||||||
} else {
|
} else {
|
||||||
viewModel.addSession(session)
|
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()
|
onNavigateBack()
|
||||||
}
|
}
|
||||||
@@ -774,15 +746,6 @@ fun AddEditSessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
if (selectedGym != null) {
|
|
||||||
FloatingActionButton(
|
|
||||||
onClick = { showAddAttemptDialog = true }
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add Attempt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
LazyColumn(
|
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)
|
val recentSessions = sessions.take(5)
|
||||||
RecentActivityCard(recentSessions = recentSessions.size)
|
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>> =
|
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||||
repository.getAttemptsBySession(sessionId)
|
repository.getAttemptsBySession(sessionId)
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,13 @@ object ImageUtils {
|
|||||||
*/
|
*/
|
||||||
fun saveImageFromUri(context: Context, imageUri: Uri): String? {
|
fun saveImageFromUri(context: Context, imageUri: Uri): String? {
|
||||||
return try {
|
return try {
|
||||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
// Decode bitmap from a fresh stream to avoid mark/reset dependency
|
||||||
inputStream?.use { input ->
|
val originalBitmap = context.contentResolver.openInputStream(imageUri)?.use { input ->
|
||||||
// Decode and compress the image
|
BitmapFactory.decodeStream(input)
|
||||||
val originalBitmap = BitmapFactory.decodeStream(input)
|
} ?: return null
|
||||||
val compressedBitmap = compressImage(originalBitmap)
|
|
||||||
|
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||||
|
val compressedBitmap = compressImage(orientedBitmap)
|
||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
val filename = "${UUID.randomUUID()}.jpg"
|
val filename = "${UUID.randomUUID()}.jpg"
|
||||||
@@ -52,17 +54,73 @@ object ImageUtils {
|
|||||||
|
|
||||||
// Clean up bitmaps
|
// Clean up bitmaps
|
||||||
originalBitmap.recycle()
|
originalBitmap.recycle()
|
||||||
|
if (orientedBitmap != originalBitmap) {
|
||||||
|
orientedBitmap.recycle()
|
||||||
|
}
|
||||||
compressedBitmap.recycle()
|
compressedBitmap.recycle()
|
||||||
|
|
||||||
// Return relative path
|
// Return relative path
|
||||||
"$IMAGES_DIR/$filename"
|
"$IMAGES_DIR/$filename"
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
null
|
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
|
* Compresses and resizes an image bitmap
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ object SessionShareUtils {
|
|||||||
val uniqueProblemsCompleted: Int,
|
val uniqueProblemsCompleted: Int,
|
||||||
val averageGrade: String?,
|
val averageGrade: String?,
|
||||||
val sessionDuration: String,
|
val sessionDuration: String,
|
||||||
val topResult: AttemptResult?
|
val topResult: AttemptResult?,
|
||||||
|
val topGrade: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
fun calculateSessionStats(
|
fun calculateSessionStats(
|
||||||
@@ -42,15 +43,34 @@ object SessionShareUtils {
|
|||||||
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
|
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
|
||||||
|
|
||||||
val attemptedProblems = problems.filter { it.id in uniqueProblems }
|
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
|
// Calculate separate averages for different climbing types and difficulty systems
|
||||||
val gradeValues = attemptedProblems.mapNotNull { problem ->
|
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
|
||||||
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
|
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
|
||||||
}
|
}
|
||||||
if (gradeValues.isNotEmpty()) {
|
|
||||||
"V${gradeValues.average().roundToInt()}"
|
|
||||||
} else null
|
|
||||||
} else null
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -70,10 +90,81 @@ object SessionShareUtils {
|
|||||||
uniqueProblemsCompleted = uniqueCompletedProblems.size,
|
uniqueProblemsCompleted = uniqueCompletedProblems.size,
|
||||||
averageGrade = averageGrade,
|
averageGrade = averageGrade,
|
||||||
sessionDuration = duration,
|
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(
|
fun generateShareCard(
|
||||||
context: Context,
|
context: Context,
|
||||||
session: ClimbSession,
|
session: ClimbSession,
|
||||||
@@ -81,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)
|
||||||
@@ -136,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
|
||||||
@@ -157,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
|
||||||
|
|
||||||
stats.averageGrade?.let { grade ->
|
var rightYAfter = rightY
|
||||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Avg Grade", grade, statLabelPaint, statValuePaint)
|
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
|
// 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 {
|
||||||
@@ -229,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,
|
||||||
@@ -237,12 +380,12 @@ 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
|
||||||
@@ -269,8 +412,11 @@ object SessionShareUtils {
|
|||||||
|
|
||||||
// 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 {
|
||||||
@@ -307,4 +453,48 @@ object SessionShareUtils {
|
|||||||
e.printStackTrace()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/src/main/res/values-night/colors.xml
Normal file
6
app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Splash background (dark) -->
|
||||||
|
<color name="splash_background">#FF121212</color>
|
||||||
|
</resources>
|
||||||
|
|
||||||
@@ -7,4 +7,7 @@
|
|||||||
<color name="teal_700">#FF018786</color>
|
<color name="teal_700">#FF018786</color>
|
||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
|
||||||
|
<!-- Splash background (light) -->
|
||||||
|
<color name="splash_background">#FFFFFFFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
|
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
|
||||||
|
<style name="Theme.OpenClimb.Splash" parent="Theme.OpenClimb">
|
||||||
|
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||||
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
|
||||||
|
<item name="android:windowSplashScreenAnimationDuration">200</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.9.1"
|
agp = "8.12.1"
|
||||||
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" }
|
||||||
|
|||||||
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
|
#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
|
||||||
|
|||||||
Reference in New Issue
Block a user