Compare commits

..

1 Commits
0.3.1 ... 0.3.2

Author SHA1 Message Date
ca770b9db3 0.3.2 - Optimizations 2025-08-16 00:48:38 -06:00
32 changed files with 177 additions and 373 deletions

Binary file not shown.

View File

@@ -14,15 +14,15 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 4 versionCode = 5
versionName = "0.3.1" versionName = "0.3.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"

View File

@@ -31,10 +31,10 @@ data class ClimbSession(
@PrimaryKey @PrimaryKey
val id: String, val id: String,
val gymId: String, val gymId: String,
val date: String, // ISO date string val date: String,
val startTime: String? = null, // When session was started val startTime: String? = null,
val endTime: String? = null, // When session was completed val endTime: String? = null,
val duration: Long? = null, // Duration in minutes (calculated when completed) val duration: Long? = null,
val status: SessionStatus = SessionStatus.ACTIVE, val status: SessionStatus = SessionStatus.ACTIVE,
val notes: String? = null, val notes: String? = null,
val createdAt: String, val createdAt: String,

View File

@@ -8,7 +8,7 @@ enum class ClimbType {
BOULDER; BOULDER;
/** /**
* Get the display name for the UI * Get the display name
*/ */
fun getDisplayName(): String = when (this) { fun getDisplayName(): String = when (this) {
ROPE -> "Rope" ROPE -> "Rope"

View File

@@ -4,14 +4,14 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
enum class DifficultySystem { enum class DifficultySystem {
// Bouldering systems // Bouldering
V_SCALE, // V-Scale (VB - V17) V_SCALE, // V-Scale (VB - V17)
FONT, // Fontainebleau (3 - 8C+) FONT, // Fontainebleau (3 - 8C+)
// Rope climbing systems // Rope
YDS, // Yosemite Decimal System (5.0 - 5.15d) YDS, // Yosemite Decimal System (5.0 - 5.15d)
// Custom system for gyms that use their own colors/naming // Custom difficulty systems
CUSTOM; CUSTOM;
/** /**
@@ -39,22 +39,22 @@ enum class DifficultySystem {
fun isRopeSystem(): Boolean = when (this) { fun isRopeSystem(): Boolean = when (this) {
YDS -> true YDS -> true
V_SCALE, FONT -> false V_SCALE, FONT -> false
CUSTOM -> true // Custom is available for all CUSTOM -> true
} }
/** /**
* Get available grades for this difficulty system * Get available grades for this system
*/ */
fun getAvailableGrades(): List<String> = when (this) { fun getAvailableGrades(): List<String> = when (this) {
V_SCALE -> listOf("VB", "V0", "V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9", "V10", "V11", "V12", "V13", "V14", "V15", "V16", "V17") V_SCALE -> listOf("VB", "V0", "V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9", "V10", "V11", "V12", "V13", "V14", "V15", "V16", "V17")
FONT -> listOf("3", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6A+", "6B", "6B+", "6C", "6C+", "7A", "7A+", "7B", "7B+", "7C", "7C+", "8A", "8A+", "8B", "8B+", "8C", "8C+") FONT -> listOf("3", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6A+", "6B", "6B+", "6C", "6C+", "7A", "7A+", "7B", "7B+", "7C", "7C+", "8A", "8A+", "8B", "8B+", "8C", "8C+")
YDS -> listOf("5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "5.10a", "5.10b", "5.10c", "5.10d", "5.11a", "5.11b", "5.11c", "5.11d", "5.12a", "5.12b", "5.12c", "5.12d", "5.13a", "5.13b", "5.13c", "5.13d", "5.14a", "5.14b", "5.14c", "5.14d", "5.15a", "5.15b", "5.15c", "5.15d") YDS -> listOf("5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "5.10a", "5.10b", "5.10c", "5.10d", "5.11a", "5.11b", "5.11c", "5.11d", "5.12a", "5.12b", "5.12c", "5.12d", "5.13a", "5.13b", "5.13c", "5.13d", "5.14a", "5.14b", "5.14c", "5.14d", "5.15a", "5.15b", "5.15c", "5.15d")
CUSTOM -> emptyList() // Custom allows free text input CUSTOM -> emptyList()
} }
companion object { companion object {
/** /**
* Get all difficulty systems available for a specific climb type * Get all difficulty systems based on type
*/ */
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> = when (climbType) { fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> = when (climbType) {
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() } ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }

View File

@@ -13,10 +13,10 @@ data class Gym(
val name: String, val name: String,
val location: String? = null, val location: String? = null,
val supportedClimbTypes: List<ClimbType>, val supportedClimbTypes: List<ClimbType>,
val difficultySystems: List<DifficultySystem>, // What systems this gym uses val difficultySystems: List<DifficultySystem>,
val customDifficultyGrades: List<String> = emptyList(), // For gyms using colors/custom names val customDifficultyGrades: List<String> = emptyList(),
val notes: String? = null, val notes: String? = null,
val createdAt: String, // ISO string format for serialization val createdAt: String,
val updatedAt: String val updatedAt: String
) { ) {
companion object { companion object {

View File

@@ -28,12 +28,12 @@ data class Problem(
val description: String? = null, val description: String? = null,
val climbType: ClimbType, val climbType: ClimbType,
val difficulty: DifficultyGrade, val difficulty: DifficultyGrade,
val setter: String? = null, // Route setter name val setter: String? = null,
val tags: List<String> = emptyList(), // e.g., "overhang", "slab", "crimpy" val tags: List<String> = emptyList(),
val location: String? = null, // Wall section, area in gym val location: String? = null,
val imagePaths: List<String> = emptyList(), // Local file paths to photos val imagePaths: List<String> = emptyList(),
val isActive: Boolean = true, // Whether the problem is still up val isActive: Boolean = true,
val dateSet: String? = null, // When the problem was set val dateSet: String? = null,
val notes: String? = null, val notes: String? = null,
val createdAt: String, val createdAt: String,
val updatedAt: String val updatedAt: String

View File

@@ -1,50 +0,0 @@
package com.atridad.openclimb.data.model
import kotlinx.serialization.Serializable
import java.time.LocalDateTime
@Serializable
data class ProblemProgress(
val problemId: String,
val totalAttempts: Int,
val successfulAttempts: Int,
val firstAttemptDate: String,
val lastAttemptDate: String,
val bestResult: AttemptResult,
val averageAttempts: Double,
val successRate: Double,
val personalBest: String? = null, // Highest hold or completion details
val notes: String? = null
)
@Serializable
data class SessionSummary(
val sessionId: String,
val date: String,
val totalAttempts: Int,
val successfulAttempts: Int,
val uniqueProblems: Int,
val avgDifficulty: Double,
val maxDifficulty: DifficultyGrade,
val climbTypes: List<ClimbType>,
val duration: Long?, // in minutes
val notes: String? = null
)
@Serializable
data class ClimbingStats(
val totalSessions: Int,
val totalAttempts: Int,
val totalSuccesses: Int,
val overallSuccessRate: Double,
val uniqueProblemsAttempted: Int,
val uniqueProblemsCompleted: Int,
val averageSessionDuration: Double, // in minutes
val favoriteGym: String?,
val mostAttemptedDifficulty: DifficultyGrade?,
val currentStreak: Int, // consecutive sessions
val longestStreak: Int,
val firstClimbDate: String?,
val lastClimbDate: String?,
val improvementTrend: String? = null // "improving", "stable", "declining"
)

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.os.Environment import android.os.Environment
import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.utils.ImageUtils
import com.atridad.openclimb.utils.ZipExportImportUtils import com.atridad.openclimb.utils.ZipExportImportUtils
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -14,7 +13,7 @@ import java.io.File
import java.time.LocalDateTime import java.time.LocalDateTime
class ClimbRepository( class ClimbRepository(
private val database: OpenClimbDatabase, database: OpenClimbDatabase,
private val context: Context private val context: Context
) { ) {
private val gymDao = database.gymDao() private val gymDao = database.gymDao()
@@ -40,7 +39,6 @@ class ClimbRepository(
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems() fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId) fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
fun getActiveProblems(): Flow<List<Problem>> = problemDao.getActiveProblems()
suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem) suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem)
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
@@ -50,17 +48,14 @@ class ClimbRepository(
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions() fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = sessionDao.getSessionsByGym(gymId) fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = sessionDao.getSessionsByGym(gymId)
fun getRecentSessions(limit: Int = 10): Flow<List<ClimbSession>> = sessionDao.getRecentSessions(limit)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow() fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
fun getSessionsByStatus(status: SessionStatus): Flow<List<ClimbSession>> = sessionDao.getSessionsByStatus(status)
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session) suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session) suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session) suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
// Attempt operations // Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts() fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
suspend fun getAttemptById(id: String): Attempt? = attemptDao.getAttemptById(id)
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = attemptDao.getAttemptsBySession(sessionId) fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = attemptDao.getAttemptsByProblem(problemId) fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt) suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
@@ -69,7 +64,7 @@ class ClimbRepository(
// JSON Export functionality // JSON Export
suspend fun exportAllDataToJson(directory: File? = null): File { suspend fun exportAllDataToJson(directory: File? = null): File {
val exportDir = directory ?: File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "OpenClimb") val exportDir = directory ?: File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "OpenClimb")
if (!exportDir.exists()) { if (!exportDir.exists()) {
@@ -124,12 +119,12 @@ class ClimbRepository(
val jsonContent = file.readText() val jsonContent = file.readText()
val importData = json.decodeFromString<ClimbDataExport>(jsonContent) val importData = json.decodeFromString<ClimbDataExport>(jsonContent)
// Import gyms (replace if exists due to primary key constraint) // Import gyms
importData.gyms.forEach { gym -> importData.gyms.forEach { gym ->
try { try {
gymDao.insertGym(gym) gymDao.insertGym(gym)
} catch (e: Exception) { } catch (e: Exception) {
// If insertion fails due to primary key conflict, update instead // If insertion fails, update instead
gymDao.updateGym(gym) gymDao.updateGym(gym)
} }
} }
@@ -166,7 +161,7 @@ class ClimbRepository(
} }
} }
// ZIP Export functionality with images // ZIP Export with images
suspend fun exportAllDataToZip(directory: File? = null): File { suspend fun exportAllDataToZip(directory: File? = null): File {
val allGyms = gymDao.getAllGyms().first() val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first() val allProblems = problemDao.getAllProblems().first()
@@ -206,7 +201,7 @@ class ClimbRepository(
attempts = attempts attempts = attempts
) )
// Collect all referenced image paths // Collect all image paths
val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet()
ZipExportImportUtils.createExportZipToUri( ZipExportImportUtils.createExportZipToUri(
@@ -228,12 +223,12 @@ class ClimbRepository(
importResult.importedImagePaths importResult.importedImagePaths
) )
// Import gyms (replace if exists due to primary key constraint) // Import gyms
importData.gyms.forEach { gym -> importData.gyms.forEach { gym ->
try { try {
gymDao.insertGym(gym) gymDao.insertGym(gym)
} catch (e: Exception) { } catch (e: Exception) {
// If insertion fails due to primary key conflict, update instead // If insertion fails update instead
gymDao.updateGym(gym) gymDao.updateGym(gym)
} }
} }

View File

@@ -6,7 +6,6 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.atridad.openclimb.MainActivity import com.atridad.openclimb.MainActivity
@@ -16,7 +15,6 @@ import com.atridad.openclimb.data.repository.ClimbRepository
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
class SessionTrackingService : Service() { class SessionTrackingService : Service() {
@@ -113,31 +111,32 @@ class SessionTrackingService : Service() {
remainingMinutes > 0 -> "${remainingMinutes}m" remainingMinutes > 0 -> "${remainingMinutes}m"
else -> "< 1m" else -> "< 1m"
} }
} catch (e: Exception) { } catch (_: Exception) {
"Active" "Active"
} }
} ?: "Active" } ?: "Active"
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("OpenClimb Session Active") .setContentTitle("Climbing Session Active")
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts") .setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts")
.setSmallIcon(R.drawable.ic_launcher_foreground) .setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true) .setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentIntent(createOpenAppIntent()) .setContentIntent(createOpenAppIntent())
.addAction( .addAction(
R.drawable.ic_launcher_foreground, R.drawable.ic_mountains,
"Open Session", "Open Session",
createOpenAppIntent() createOpenAppIntent()
) )
.addAction( .addAction(
R.drawable.ic_launcher_foreground, android.R.drawable.ic_menu_close_clear_cancel,
"End Session", "End Session",
createStopIntent() createStopIntent()
) )
.build() .build()
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
} catch (e: Exception) { } catch (_: Exception) {
// Handle errors gracefully // Handle errors gracefully
stopSessionTracking() stopSessionTracking()
} }
@@ -166,7 +165,6 @@ class SessionTrackingService : Service() {
} }
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Session Tracking", "Session Tracking",
@@ -176,10 +174,9 @@ class SessionTrackingService : Service() {
setShowBadge(false) setShowBadge(false)
} }
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()

View File

@@ -86,9 +86,6 @@ fun OpenClimbApp() {
viewModel = viewModel, viewModel = viewModel,
onNavigateToSessionDetail = { sessionId -> onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId)) navController.navigate(Screen.SessionDetail(sessionId))
},
onNavigateToAddSession = { gymId ->
navController.navigate(Screen.AddEditSession(gymId = gymId))
} }
) )
} }
@@ -112,9 +109,6 @@ fun OpenClimbApp() {
viewModel = viewModel, viewModel = viewModel,
onNavigateToProblemDetail = { problemId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId)) navController.navigate(Screen.ProblemDetail(problemId))
},
onNavigateToAddProblem = { gymId ->
navController.navigate(Screen.AddEditProblem(gymId = gymId))
} }
) )
} }
@@ -140,9 +134,6 @@ fun OpenClimbApp() {
viewModel = viewModel, viewModel = viewModel,
onNavigateToGymDetail = { gymId -> onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId)) navController.navigate(Screen.GymDetail(gymId))
},
onNavigateToAddGym = {
navController.navigate(Screen.AddEditGym())
} }
) )
} }

View File

@@ -5,12 +5,10 @@ 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.Close
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.rounded.Close
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
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight 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
@@ -95,80 +93,6 @@ fun ActiveSessionBanner(
} }
} }
@Composable
fun StartSessionButton(
gyms: List<Gym>,
onStartSession: (String) -> Unit
) {
var showGymSelection by remember { mutableStateOf(false) }
if (gyms.isEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No gyms available",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Add a gym first to start a session",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
Button(
onClick = { showGymSelection = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Start Session")
}
}
if (showGymSelection) {
AlertDialog(
onDismissRequest = { showGymSelection = false },
title = { Text("Select Gym") },
text = {
Column {
gyms.forEach { gym ->
TextButton(
onClick = {
onStartSession(gym.id)
showGymSelection = false
},
modifier = Modifier.fillMaxWidth()
) {
Text(
text = gym.name,
modifier = Modifier.fillMaxWidth()
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showGymSelection = false }) {
Text("Cancel")
}
}
)
}
}
private fun calculateDuration(startTimeString: String): String { private fun calculateDuration(startTimeString: String): String {
return try { return try {
val startTime = LocalDateTime.parse(startTimeString) val startTime = LocalDateTime.parse(startTimeString)
@@ -182,7 +106,7 @@ private fun calculateDuration(startTimeString: String): String {
remainingMinutes > 0 -> "${remainingMinutes}m" remainingMinutes > 0 -> "${remainingMinutes}m"
else -> "< 1m" else -> "< 1m"
} }
} catch (e: Exception) { } catch (_: Exception) {
"Active" "Active"
} }
} }

View File

@@ -50,7 +50,7 @@ fun FullscreenImageViewer(
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
thumbnailListState.animateScrollToItem( thumbnailListState.animateScrollToItem(
index = pagerState.currentPage, index = pagerState.currentPage,
scrollOffset = -200 // Center the item scrollOffset = -200
) )
} }

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale

View File

@@ -1,6 +1,5 @@
package com.atridad.openclimb.ui.components package com.atridad.openclimb.ui.components
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -20,7 +19,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils import com.atridad.openclimb.utils.ImageUtils
import java.io.File
@Composable @Composable
fun ImagePicker( fun ImagePicker(
@@ -41,7 +39,7 @@ fun ImagePicker(
val remainingSlots = maxImages - currentCount val remainingSlots = maxImages - currentCount
val urisToProcess = uris.take(remainingSlots) val urisToProcess = uris.take(remainingSlots)
// Process each selected image // Process images
val newImagePaths = mutableListOf<String>() val newImagePaths = mutableListOf<String>()
urisToProcess.forEach { uri -> urisToProcess.forEach { uri ->
val imagePath = ImageUtils.saveImageFromUri(context, uri) val imagePath = ImageUtils.saveImageFromUri(context, uri)

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup 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.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -81,7 +82,7 @@ fun AddEditGymScreen(
title = { Text(if (isEditing) "Edit Gym" else "Add Gym") }, title = { Text(if (isEditing) "Edit Gym" else "Add Gym") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
actions = { actions = {
@@ -90,7 +91,7 @@ fun AddEditGymScreen(
val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes) val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
if (isEditing) { if (isEditing) {
viewModel.updateGym(gym.copy(id = gymId!!)) viewModel.updateGym(gym.copy(id = gymId))
} else { } else {
viewModel.addGym(gym) viewModel.addGym(gym)
} }
@@ -291,7 +292,7 @@ fun AddEditProblemScreen(
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList() val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
val availableDifficultySystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> val availableDifficultySystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
selectedGym?.difficultySystems?.contains(system) ?: true selectedGym?.difficultySystems?.contains(system) != false
} }
// Auto-select climb type if there's only one available // Auto-select climb type if there's only one available
@@ -329,7 +330,7 @@ fun AddEditProblemScreen(
title = { Text(if (isEditing) "Edit Problem" else "Add Problem") }, title = { Text(if (isEditing) "Edit Problem" else "Add Problem") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
actions = { actions = {
@@ -359,7 +360,7 @@ fun AddEditProblemScreen(
) )
if (isEditing) { if (isEditing) {
viewModel.updateProblem(problem.copy(id = problemId!!)) viewModel.updateProblem(problem.copy(id = problemId))
} else { } else {
viewModel.addProblem(problem) viewModel.addProblem(problem)
} }
@@ -646,7 +647,7 @@ fun AddEditProblemScreen(
label = { Text("Tags (Optional)") }, label = { Text("Tags (Optional)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
placeholder = { Text("e.g., overhang, crimpy, dynamic (comma-separated)") } placeholder = { Text("e.g., crimpy, dynamic (comma-separated)") }
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -736,7 +737,7 @@ fun AddEditSessionScreen(
title = { Text(if (isEditing) "Edit Session" else "Add Session") }, title = { Text(if (isEditing) "Edit Session" else "Add Session") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
actions = { actions = {
@@ -749,7 +750,7 @@ fun AddEditSessionScreen(
) )
if (isEditing) { if (isEditing) {
viewModel.updateSession(session.copy(id = sessionId!!)) viewModel.updateSession(session.copy(id = sessionId))
} else { } else {
viewModel.addSession(session) viewModel.addSession(session)

View File

@@ -8,10 +8,9 @@ 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.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
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.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@@ -87,7 +86,7 @@ fun SessionDetailScreen(
title = { Text("Session Details") }, title = { Text("Session Details") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
actions = { actions = {
@@ -403,7 +402,7 @@ fun ProblemDetailScreen(
val context = LocalContext.current val context = LocalContext.current
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showImageViewer by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) }
var selectedImageIndex by remember { mutableStateOf(0) } var selectedImageIndex by remember { mutableIntStateOf(0) }
val attempts by viewModel.getAttemptsByProblem(problemId).collectAsState(initial = emptyList()) val attempts by viewModel.getAttemptsByProblem(problemId).collectAsState(initial = emptyList())
val sessions by viewModel.sessions.collectAsState() val sessions by viewModel.sessions.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
@@ -436,7 +435,7 @@ fun ProblemDetailScreen(
title = { Text("Problem Details") }, title = { Text("Problem Details") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
actions = { actions = {
@@ -770,7 +769,7 @@ fun GymDetailScreen(
title = { Text(gym?.name ?: "Gym Details") }, title = { Text(gym?.name ?: "Gym Details") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
actions = { actions = {
@@ -1087,7 +1086,7 @@ fun GymDetailScreen(
supportingContent = { supportingContent = {
val dateTime = try { val dateTime = try {
LocalDateTime.parse(session.date) LocalDateTime.parse(session.date)
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
val formattedDate = dateTime?.format( val formattedDate = dateTime?.format(
@@ -1348,42 +1347,11 @@ private fun formatDate(dateString: String): String {
val date = LocalDateTime.parse(dateString, formatter) val date = LocalDateTime.parse(dateString, formatter)
val displayFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy") val displayFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy")
date.format(displayFormatter) date.format(displayFormatter)
} catch (e: Exception) { } catch (_: Exception) {
dateString.take(10) // Fallback to just the date part dateString.take(10) // Fallback to just the date part
} }
} }
private fun formatTime(timeString: String): String {
return try {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val time = LocalDateTime.parse(timeString, formatter)
val displayFormatter = DateTimeFormatter.ofPattern("h:mm a")
time.format(displayFormatter)
} catch (e: Exception) {
timeString.take(8) // Fallback to time part
}
}
private fun calculateSessionDuration(startTime: String, endTime: String): String {
return try {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val start = LocalDateTime.parse(startTime, formatter)
val end = LocalDateTime.parse(endTime, formatter)
val duration = java.time.Duration.between(start, end)
val hours = duration.toHours()
val minutes = duration.toMinutes() % 60
when {
hours > 0 -> "${hours}h ${minutes}m"
minutes > 0 -> "${minutes}m"
else -> "< 1m"
}
} catch (e: Exception) {
"Unknown"
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EnhancedAddAttemptDialog( fun EnhancedAddAttemptDialog(
@@ -1896,28 +1864,5 @@ fun EnhancedAddAttemptDialog(
} }
} }
} }
@Composable
fun StatisticItem(
label: String,
value: String,
valueColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = value,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = valueColor
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} }
} }

View File

@@ -3,15 +3,12 @@ package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.Gym import com.atridad.openclimb.data.model.Gym
@@ -21,8 +18,7 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@Composable @Composable
fun GymsScreen( fun GymsScreen(
viewModel: ClimbViewModel, viewModel: ClimbViewModel,
onNavigateToGymDetail: (String) -> Unit, onNavigateToGymDetail: (String) -> Unit
onNavigateToAddGym: () -> Unit
) { ) {
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()

View File

@@ -4,15 +4,12 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn 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.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbType import com.atridad.openclimb.data.model.ClimbType
@@ -26,14 +23,13 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@Composable @Composable
fun ProblemsScreen( fun ProblemsScreen(
viewModel: ClimbViewModel, viewModel: ClimbViewModel,
onNavigateToProblemDetail: (String) -> Unit, onNavigateToProblemDetail: (String) -> Unit
onNavigateToAddProblem: (String?) -> Unit
) { ) {
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
var showImageViewer by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) }
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) } var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedImageIndex by remember { mutableStateOf(0) } var selectedImageIndex by remember { mutableIntStateOf(0) }
// Filter state // Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) } var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
@@ -41,8 +37,8 @@ fun ProblemsScreen(
// Apply filters // Apply filters
val filteredProblems = problems.filter { problem -> val filteredProblems = problems.filter { problem ->
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } ?: true val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
val gymMatch = selectedGym?.let { it.id == problem.gymId } ?: true val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
climbTypeMatch && gymMatch climbTypeMatch && gymMatch
} }

View File

@@ -3,8 +3,10 @@ package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
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.CheckCircle
import androidx.compose.material.icons.filled.Warning
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
@@ -26,13 +28,13 @@ import java.time.format.DateTimeFormatter
@Composable @Composable
fun SessionsScreen( fun SessionsScreen(
viewModel: ClimbViewModel, viewModel: ClimbViewModel,
onNavigateToSessionDetail: (String) -> Unit, onNavigateToSessionDetail: (String) -> Unit
onNavigateToAddSession: (String?) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val sessions by viewModel.sessions.collectAsState() val sessions by viewModel.sessions.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val activeSession by viewModel.activeSession.collectAsState() val activeSession by viewModel.activeSession.collectAsState()
val uiState by viewModel.uiState.collectAsState()
// Filter out active sessions from regular session list // Filter out active sessions from regular session list
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED } val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
@@ -103,6 +105,79 @@ fun SessionsScreen(
} }
} }
} }
// Show UI state messages and errors
uiState.message?.let { message ->
LaunchedEffect(message) {
kotlinx.coroutines.delay(5000)
viewModel.clearMessage()
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
uiState.error?.let { error ->
LaunchedEffect(error) {
kotlinx.coroutines.delay(5000)
viewModel.clearError()
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -141,7 +216,7 @@ fun SessionCard(
session.duration?.let { duration -> session.duration?.let { duration ->
Text( Text(
text = "Duration: ${duration} minutes", text = "Duration: $duration minutes",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
} }
@@ -203,7 +278,7 @@ private fun formatDate(dateString: String): String {
return try { return try {
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00") val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy")) date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
} catch (e: Exception) { } catch (_: Exception) {
dateString dateString
} }
} }

View File

@@ -2,7 +2,6 @@ package com.atridad.openclimb.ui.screens
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import android.os.Environment
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape

View File

@@ -64,12 +64,3 @@ val ClimbNeutralVariant50 = Color(0xFF797979)
val ClimbNeutralVariant60 = Color(0xFF939393) val ClimbNeutralVariant60 = Color(0xFF939393)
val ClimbNeutralVariant80 = Color(0xFFC7C7C7) val ClimbNeutralVariant80 = Color(0xFFC7C7C7)
val ClimbNeutralVariant90 = Color(0xFFE3E3E3) val ClimbNeutralVariant90 = Color(0xFFE3E3E3)
// Legacy colors for backward compatibility
val Purple80 = ClimbOrange80
val PurpleGrey80 = ClimbGrey80
val Pink80 = ClimbBlue80
val Purple40 = ClimbOrange40
val PurpleGrey40 = ClimbGrey40
val Pink40 = ClimbBlue40

View File

@@ -1,7 +1,6 @@
package com.atridad.openclimb.ui.theme package com.atridad.openclimb.ui.theme
import android.app.Activity import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
@@ -10,7 +9,6 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@@ -98,7 +96,7 @@ fun OpenClimbTheme(
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { dynamicColor && true -> {
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }

View File

@@ -1,13 +1,14 @@
package com.atridad.openclimb.utils package com.atridad.openclimb.utils
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException
import java.util.UUID import java.util.UUID
import androidx.core.graphics.scale
object ImageUtils { object ImageUtils {
@@ -65,6 +66,7 @@ object ImageUtils {
/** /**
* Compresses and resizes an image bitmap * Compresses and resizes an image bitmap
*/ */
@SuppressLint("UseKtx")
private fun compressImage(original: Bitmap): Bitmap { private fun compressImage(original: Bitmap): Bitmap {
val width = original.width val width = original.width
val height = original.height val height = original.height
@@ -79,7 +81,7 @@ object ImageUtils {
return if (scaleFactor < 1f) { return if (scaleFactor < 1f) {
val newWidth = (width * scaleFactor).toInt() val newWidth = (width * scaleFactor).toInt()
val newHeight = (height * scaleFactor).toInt() val newHeight = (height * scaleFactor).toInt()
Bitmap.createScaledBitmap(original, newWidth, newHeight, true) original.scale(newWidth, newHeight)
} else { } else {
original original
} }
@@ -111,29 +113,6 @@ object ImageUtils {
} }
} }
/**
* Copies an image file to export directory
* @param context Android context
* @param relativePath The relative path of the image
* @param exportDir The directory to copy to
* @return The filename in the export directory, null if failed
*/
fun copyImageForExport(context: Context, relativePath: String, exportDir: File): String? {
return try {
val sourceFile = getImageFile(context, relativePath)
if (!sourceFile.exists()) return null
val filename = sourceFile.name
val destFile = File(exportDir, filename)
sourceFile.copyTo(destFile, overwrite = true)
filename
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/** /**
* Imports an image file from the import directory * Imports an image file from the import directory
* @param context Android context * @param context Android context

View File

@@ -11,6 +11,8 @@ import java.io.FileOutputStream
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt import kotlin.math.roundToInt
import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt
object SessionShareUtils { object SessionShareUtils {
@@ -76,30 +78,6 @@ object SessionShareUtils {
) )
} }
private fun calculateDuration(startTime: String?, endTime: String?): String {
return try {
if (startTime != null && endTime != null) {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val start = LocalDateTime.parse(startTime, formatter)
val end = LocalDateTime.parse(endTime, formatter)
val duration = java.time.Duration.between(start, end)
val hours = duration.toHours()
val minutes = duration.toMinutes() % 60
when {
hours > 0 -> "${hours}h ${minutes}m"
minutes > 0 -> "${minutes}m"
else -> "< 1m"
}
} else {
"Unknown"
}
} catch (e: Exception) {
"Unknown"
}
}
fun generateShareCard( fun generateShareCard(
context: Context, context: Context,
session: ClimbSession, session: ClimbSession,
@@ -110,14 +88,14 @@ object SessionShareUtils {
val width = 1080 val width = 1080
val height = 1350 val height = 1350
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
val gradientDrawable = GradientDrawable( val gradientDrawable = GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM, GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf( intArrayOf(
Color.parseColor("#667eea"), "#667eea".toColorInt(),
Color.parseColor("#764ba2") "#764ba2".toColorInt()
) )
) )
gradientDrawable.setBounds(0, 0, width, height) gradientDrawable.setBounds(0, 0, width, height)
@@ -133,7 +111,7 @@ object SessionShareUtils {
} }
val subtitlePaint = Paint().apply { val subtitlePaint = Paint().apply {
color = Color.parseColor("#E8E8E8") color = "#E8E8E8".toColorInt()
textSize = 48f textSize = 48f
typeface = Typeface.DEFAULT typeface = Typeface.DEFAULT
isAntiAlias = true isAntiAlias = true
@@ -141,7 +119,7 @@ object SessionShareUtils {
} }
val statLabelPaint = Paint().apply { val statLabelPaint = Paint().apply {
color = Color.parseColor("#B8B8B8") color = "#B8B8B8".toColorInt()
textSize = 36f textSize = 36f
typeface = Typeface.DEFAULT typeface = Typeface.DEFAULT
isAntiAlias = true isAntiAlias = true
@@ -157,7 +135,7 @@ object SessionShareUtils {
} }
val cardPaint = Paint().apply { val cardPaint = Paint().apply {
color = Color.parseColor("#40FFFFFF") color = "#40FFFFFF".toColorInt()
isAntiAlias = true isAntiAlias = true
} }
@@ -211,7 +189,7 @@ object SessionShareUtils {
// App branding // App branding
val brandingPaint = Paint().apply { val brandingPaint = Paint().apply {
color = Color.parseColor("#80FFFFFF") color = "#80FFFFFF".toColorInt()
textSize = 32f textSize = 32f
typeface = Typeface.DEFAULT typeface = Typeface.DEFAULT
isAntiAlias = true isAntiAlias = true
@@ -268,7 +246,7 @@ object SessionShareUtils {
// Background arc // Background arc
val bgPaint = Paint().apply { val bgPaint = Paint().apply {
color = Color.parseColor("#40FFFFFF") color = "#40FFFFFF".toColorInt()
style = Paint.Style.STROKE style = Paint.Style.STROKE
this.strokeWidth = strokeWidth this.strokeWidth = strokeWidth
isAntiAlias = true isAntiAlias = true
@@ -277,7 +255,7 @@ object SessionShareUtils {
// Success arc // Success arc
val successPaint = Paint().apply { val successPaint = Paint().apply {
color = Color.parseColor("#4CAF50") color = "#4CAF50".toColorInt()
style = Paint.Style.STROKE style = Paint.Style.STROKE
this.strokeWidth = strokeWidth this.strokeWidth = strokeWidth
isAntiAlias = true isAntiAlias = true
@@ -305,7 +283,7 @@ object SessionShareUtils {
val date = LocalDateTime.parse(dateString, formatter) val date = LocalDateTime.parse(dateString, formatter)
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy") val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
date.format(displayFormatter) date.format(displayFormatter)
} catch (e: Exception) { } catch (_: Exception) {
dateString.take(10) dateString.take(10)
} }
} }

View File

@@ -1,7 +1,6 @@
package com.atridad.openclimb.utils package com.atridad.openclimb.utils
import android.content.Context import android.content.Context
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
@@ -180,13 +179,6 @@ object ZipExportImportUtils {
return ImportResult(jsonContent, importedImagePaths) return ImportResult(jsonContent, importedImagePaths)
} }
/**
* Utility function to determine if a file is a ZIP file based on extension
*/
fun isZipFile(filename: String): Boolean {
return filename.lowercase().endsWith(".zip")
}
/** /**
* Updates image paths in a problem list after import * Updates image paths in a problem list after import
* This function maps the old image paths to the new ones after import * This function maps the old image paths to the new ones after import