0.4.0 - Bug fixes and improvements
This commit is contained in:
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.openclimb"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 6
|
versionCode = 7
|
||||||
versionName = "0.3.3"
|
versionName = "0.4.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -151,10 +151,7 @@ fun OpenClimbApp() {
|
|||||||
SessionDetailScreen(
|
SessionDetailScreen(
|
||||||
sessionId = args.sessionId,
|
sessionId = args.sessionId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() }
|
||||||
onNavigateToEdit = { sessionId ->
|
|
||||||
navController.navigate(Screen.AddEditSession(sessionId = sessionId))
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +205,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,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.atridad.openclimb.data.model.ClimbSession
|
|||||||
import com.atridad.openclimb.data.model.Gym
|
import com.atridad.openclimb.data.model.Gym
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ActiveSessionBanner(
|
fun ActiveSessionBanner(
|
||||||
@@ -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,
|
||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,18 @@ object ImageUtils {
|
|||||||
return try {
|
return try {
|
||||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||||
inputStream?.use { input ->
|
inputStream?.use { input ->
|
||||||
// Decode and compress the image
|
// Decode with options to get EXIF data
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
}
|
||||||
|
input.reset()
|
||||||
|
BitmapFactory.decodeStream(input, null, options)
|
||||||
|
|
||||||
|
// Reset stream and decode with proper orientation
|
||||||
|
input.reset()
|
||||||
val originalBitmap = BitmapFactory.decodeStream(input)
|
val originalBitmap = BitmapFactory.decodeStream(input)
|
||||||
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,6 +61,9 @@ 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
|
||||||
@@ -63,6 +75,60 @@ object ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -42,15 +42,21 @@ 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
|
||||||
}
|
}
|
||||||
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 {
|
||||||
@@ -74,6 +80,76 @@ object SessionShareUtils {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user