Compare commits

...

5 Commits
0.1.0 ... 0.3.2

Author SHA1 Message Date
ca770b9db3 0.3.2 - Optimizations 2025-08-16 00:48:38 -06:00
7edb7c8191 0.3.1 - Bugfix for status bar 2025-08-15 19:38:01 -06:00
1ca6b33882 Build 2025-08-15 19:31:35 -06:00
bd6b5cc652 0.3.0 - Filtering and Better Scales 2025-08-15 19:30:50 -06:00
6e16a30429 0.2.0 quick fixes 2025-08-15 14:51:30 -06:00
37 changed files with 679 additions and 506 deletions

Binary file not shown.

View File

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

Binary file not shown.

View File

@@ -11,8 +11,8 @@
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "0.1.0",
"versionCode": 4,
"versionName": "0.3.1",
"outputFile": "app-release.apk"
}
],

View File

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

View File

@@ -5,5 +5,13 @@ import kotlinx.serialization.Serializable
@Serializable
enum class ClimbType {
ROPE,
BOULDER
BOULDER;
/**
* Get the display name
*/
fun getDisplayName(): String = when (this) {
ROPE -> "Rope"
BOULDER -> "Bouldering"
}
}

View File

@@ -4,23 +4,68 @@ import kotlinx.serialization.Serializable
@Serializable
enum class DifficultySystem {
// Rope climbing systems
YDS, // Yosemite Decimal System (5.1 - 5.15d)
FRENCH, // French system (3 - 9c+)
UIAA, // UIAA system (I - XII+)
BRITISH, // British system (Mod - E11)
// Bouldering systems
// Bouldering
V_SCALE, // V-Scale (VB - V17)
FONT, // Fontainebleau (3 - 9A+)
FONT, // Fontainebleau (3 - 8C+)
// Custom system for gyms that use their own colors/naming
CUSTOM
// Rope
YDS, // Yosemite Decimal System (5.0 - 5.15d)
// Custom difficulty systems
CUSTOM;
/**
* Get the display name for the UI
*/
fun getDisplayName(): String = when (this) {
V_SCALE -> "V Scale"
FONT -> "Font Scale"
YDS -> "YDS (Yosemite)"
CUSTOM -> "Custom"
}
/**
* Check if this system is for bouldering
*/
fun isBoulderingSystem(): Boolean = when (this) {
V_SCALE, FONT -> true
YDS -> false
CUSTOM -> true // Custom is available for all
}
/**
* Check if this system is for rope climbing
*/
fun isRopeSystem(): Boolean = when (this) {
YDS -> true
V_SCALE, FONT -> false
CUSTOM -> true
}
/**
* Get available grades for this system
*/
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")
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")
CUSTOM -> emptyList()
}
companion object {
/**
* Get all difficulty systems based on type
*/
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> = when (climbType) {
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }
ClimbType.ROPE -> entries.filter { it.isRopeSystem() }
}
}
}
@Serializable
data class DifficultyGrade(
val system: DifficultySystem,
val grade: String,
val numericValue: Int // For comparison and analytics
val numericValue: Int
)

View File

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

View File

@@ -28,12 +28,12 @@ data class Problem(
val description: String? = null,
val climbType: ClimbType,
val difficulty: DifficultyGrade,
val setter: String? = null, // Route setter name
val tags: List<String> = emptyList(), // e.g., "overhang", "slab", "crimpy"
val location: String? = null, // Wall section, area in gym
val imagePaths: List<String> = emptyList(), // Local file paths to photos
val isActive: Boolean = true, // Whether the problem is still up
val dateSet: String? = null, // When the problem was set
val setter: String? = null,
val tags: List<String> = emptyList(),
val location: String? = null,
val imagePaths: List<String> = emptyList(),
val isActive: Boolean = true,
val dateSet: String? = null,
val notes: String? = null,
val createdAt: 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 com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.utils.ImageUtils
import com.atridad.openclimb.utils.ZipExportImportUtils
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
@@ -14,7 +13,7 @@ import java.io.File
import java.time.LocalDateTime
class ClimbRepository(
private val database: OpenClimbDatabase,
database: OpenClimbDatabase,
private val context: Context
) {
private val gymDao = database.gymDao()
@@ -40,7 +39,6 @@ class ClimbRepository(
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
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 updateProblem(problem: Problem) = problemDao.updateProblem(problem)
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
@@ -50,17 +48,14 @@ class ClimbRepository(
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
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()
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 updateSession(session: ClimbSession) = sessionDao.updateSession(session)
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
// Attempt operations
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 getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = attemptDao.getAttemptsByProblem(problemId)
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 {
val exportDir = directory ?: File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "OpenClimb")
if (!exportDir.exists()) {
@@ -124,12 +119,12 @@ class ClimbRepository(
val jsonContent = file.readText()
val importData = json.decodeFromString<ClimbDataExport>(jsonContent)
// Import gyms (replace if exists due to primary key constraint)
// Import gyms
importData.gyms.forEach { gym ->
try {
gymDao.insertGym(gym)
} catch (e: Exception) {
// If insertion fails due to primary key conflict, update instead
// If insertion fails, update instead
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 {
val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first()
@@ -206,7 +201,7 @@ class ClimbRepository(
attempts = attempts
)
// Collect all referenced image paths
// Collect all image paths
val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet()
ZipExportImportUtils.createExportZipToUri(
@@ -228,12 +223,12 @@ class ClimbRepository(
importResult.importedImagePaths
)
// Import gyms (replace if exists due to primary key constraint)
// Import gyms
importData.gyms.forEach { gym ->
try {
gymDao.insertGym(gym)
} catch (e: Exception) {
// If insertion fails due to primary key conflict, update instead
// If insertion fails update instead
gymDao.updateGym(gym)
}
}

View File

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

View File

@@ -28,7 +28,6 @@ fun OpenClimbApp() {
val navController = rememberNavController()
val context = LocalContext.current
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = currentBackStackEntry?.destination?.route
val database = remember { OpenClimbDatabase.getDatabase(context) }
val repository = remember { ClimbRepository(database, context) }
@@ -87,9 +86,6 @@ fun OpenClimbApp() {
viewModel = viewModel,
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
},
onNavigateToAddSession = { gymId ->
navController.navigate(Screen.AddEditSession(gymId = gymId))
}
)
}
@@ -113,9 +109,6 @@ fun OpenClimbApp() {
viewModel = viewModel,
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
},
onNavigateToAddProblem = { gymId ->
navController.navigate(Screen.AddEditProblem(gymId = gymId))
}
)
}
@@ -141,9 +134,6 @@ fun OpenClimbApp() {
viewModel = viewModel,
onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId))
},
onNavigateToAddGym = {
navController.navigate(Screen.AddEditGym())
}
)
}
@@ -247,17 +237,15 @@ fun OpenClimbBottomNavigation(navController: NavHostController) {
selected = isSelected,
onClick = {
navController.navigate(item.screen) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(Screen.Sessions) {
saveState = true
// Clear the entire back stack and go to the selected tab's root screen
popUpTo(0) {
inclusive = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
// Don't restore state - always start fresh when switching tabs
restoreState = false
}
}
)

View File

@@ -5,12 +5,10 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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 {
return try {
val startTime = LocalDateTime.parse(startTimeString)
@@ -182,7 +106,7 @@ private fun calculateDuration(startTimeString: String): String {
remainingMinutes > 0 -> "${remainingMinutes}m"
else -> "< 1m"
}
} catch (e: Exception) {
} catch (_: Exception) {
"Active"
}
}

View File

@@ -50,7 +50,7 @@ fun FullscreenImageViewer(
LaunchedEffect(pagerState.currentPage) {
thumbnailListState.animateScrollToItem(
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale

View File

@@ -1,6 +1,5 @@
package com.atridad.openclimb.ui.components
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
@@ -20,7 +19,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils
import java.io.File
@Composable
fun ImagePicker(
@@ -41,7 +39,7 @@ fun ImagePicker(
val remainingSlots = maxImages - currentCount
val urisToProcess = uris.take(remainingSlots)
// Process each selected image
// Process images
val newImagePaths = mutableListOf<String>()
urisToProcess.forEach { 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.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -47,32 +48,56 @@ fun AddEditGymScreen(
val isEditing = gymId != null
// Calculate available difficulty systems based on selected climb types
val availableDifficultySystems = if (selectedClimbTypes.isEmpty()) {
emptyList()
} else {
selectedClimbTypes.flatMap { climbType ->
DifficultySystem.getSystemsForClimbType(climbType)
}.distinct()
}
// Reset selected difficulty systems when available systems change
LaunchedEffect(availableDifficultySystems) {
selectedDifficultySystems = selectedDifficultySystems.filter { it in availableDifficultySystems }.toSet()
}
// Load existing gym data for editing
LaunchedEffect(gymId) {
if (gymId != null) {
val existingGym = viewModel.getGymById(gymId).first()
existingGym?.let { gym ->
name = gym.name
location = gym.location ?: ""
notes = gym.notes ?: ""
selectedClimbTypes = gym.supportedClimbTypes.toSet()
selectedDifficultySystems = gym.difficultySystems.toSet()
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isEditing) "Edit Gym" else "Add Gym") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(
onClick = {
val gym = if (isEditing) {
Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
} else {
Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
}
val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
if (isEditing) {
viewModel.updateGym(gym)
viewModel.updateGym(gym.copy(id = gymId))
} else {
viewModel.addGym(gym)
}
onNavigateBack()
},
enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty()
enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty() && selectedDifficultySystems.isNotEmpty()
) {
Text("Save")
}
@@ -142,7 +167,7 @@ fun AddEditGymScreen(
onCheckedChange = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() })
Text(climbType.getDisplayName())
}
}
}
@@ -163,7 +188,15 @@ fun AddEditGymScreen(
Spacer(modifier = Modifier.height(8.dp))
DifficultySystem.entries.forEach { system ->
if (selectedClimbTypes.isEmpty()) {
Text(
text = "Select climb types first to see available difficulty systems",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
availableDifficultySystems.forEach { system ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -185,7 +218,8 @@ fun AddEditGymScreen(
onCheckedChange = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(system.name)
Text(system.getDisplayName())
}
}
}
}
@@ -244,6 +278,8 @@ fun AddEditProblemScreen(
notes = p.notes ?: ""
isActive = p.isActive
imagePaths = p.imagePaths
// Set the selected gym for the existing problem
selectedGym = gyms.find { it.id == p.gymId }
}
}
}
@@ -254,8 +290,39 @@ fun AddEditProblemScreen(
}
}
val availableDifficultySystems = selectedGym?.difficultySystems ?: DifficultySystem.entries.toList()
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
val availableDifficultySystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
selectedGym?.difficultySystems?.contains(system) != false
}
// Auto-select climb type if there's only one available
LaunchedEffect(availableClimbTypes) {
if (availableClimbTypes.size == 1 && selectedClimbType != availableClimbTypes.first()) {
selectedClimbType = availableClimbTypes.first()
}
}
// Auto-select or reset difficulty system based on climb type
LaunchedEffect(selectedClimbType, availableDifficultySystems) {
when {
// If current system is not compatible, select the first available one
selectedDifficultySystem !in availableDifficultySystems -> {
selectedDifficultySystem = availableDifficultySystems.firstOrNull() ?: DifficultySystem.CUSTOM
}
// If there's only one available system and nothing is selected, auto-select it
availableDifficultySystems.size == 1 && selectedDifficultySystem != availableDifficultySystems.first() -> {
selectedDifficultySystem = availableDifficultySystems.first()
}
}
}
// Reset grade when difficulty system changes (unless it's a valid grade for the new system)
LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades()
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
difficultyGrade = ""
}
}
Scaffold(
topBar = {
@@ -263,7 +330,7 @@ fun AddEditProblemScreen(
title = { Text(if (isEditing) "Edit Problem" else "Add Problem") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
@@ -293,7 +360,7 @@ fun AddEditProblemScreen(
)
if (isEditing) {
viewModel.updateProblem(problem.copy(id = problemId!!))
viewModel.updateProblem(problem.copy(id = problemId))
} else {
viewModel.addProblem(problem)
}
@@ -437,7 +504,7 @@ fun AddEditProblemScreen(
availableClimbTypes.forEach { climbType ->
FilterChip(
onClick = { selectedClimbType = climbType },
label = { Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) },
label = { Text(climbType.getDisplayName()) },
selected = selectedClimbType == climbType
)
}
@@ -476,7 +543,7 @@ fun AddEditProblemScreen(
items(availableDifficultySystems) { system ->
FilterChip(
onClick = { selectedDifficultySystem = system },
label = { Text(system.name) },
label = { Text(system.getDisplayName()) },
selected = selectedDifficultySystem == system
)
}
@@ -484,27 +551,55 @@ fun AddEditProblemScreen(
Spacer(modifier = Modifier.height(8.dp))
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
OutlinedTextField(
value = difficultyGrade,
onValueChange = { difficultyGrade = it },
label = { Text("Grade *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = {
Text(when (selectedDifficultySystem) {
DifficultySystem.V_SCALE -> "e.g., V0, V4, V10"
DifficultySystem.FONT -> "e.g., 3, 6A+, 8B"
DifficultySystem.YDS -> "e.g., 5.8, 5.12a"
DifficultySystem.FRENCH -> "e.g., 6a, 7c+"
DifficultySystem.CUSTOM -> "Custom grade"
else -> "Enter grade"
})
placeholder = { Text("Enter custom grade") }
)
} else {
var expanded by remember { mutableStateOf(false) }
val availableGrades = selectedDifficultySystem.getAvailableGrades()
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = difficultyGrade,
onValueChange = { },
readOnly = true,
label = { Text("Grade *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
availableGrades.forEach { grade ->
DropdownMenuItem(
text = { Text(grade) },
onClick = {
difficultyGrade = grade
expanded = false
}
)
}
}
}
}
}
}
}
}
// Images Section
item {
@@ -552,7 +647,7 @@ fun AddEditProblemScreen(
label = { Text("Tags (Optional)") },
modifier = Modifier.fillMaxWidth(),
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))
@@ -617,6 +712,19 @@ fun AddEditSessionScreen(
var attempts by remember { mutableStateOf(listOf<AttemptInput>()) }
var showAddAttemptDialog by remember { mutableStateOf(false) }
// Load existing session data for editing
LaunchedEffect(sessionId) {
if (sessionId != null) {
val existingSession = viewModel.getSessionById(sessionId).first()
existingSession?.let { session ->
selectedGym = gyms.find { it.id == session.gymId }
sessionDate = session.date.split("T")[0] // Extract date part
duration = session.duration?.toString() ?: ""
sessionNotes = session.notes ?: ""
}
}
}
LaunchedEffect(gymId, gyms) {
if (gymId != null && selectedGym == null) {
selectedGym = gyms.find { it.id == gymId }
@@ -629,7 +737,7 @@ fun AddEditSessionScreen(
title = { Text(if (isEditing) "Edit Session" else "Add Session") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
@@ -642,7 +750,7 @@ fun AddEditSessionScreen(
)
if (isEditing) {
viewModel.updateSession(session.copy(id = sessionId!!))
viewModel.updateSession(session.copy(id = sessionId))
} else {
viewModel.addSession(session)
@@ -830,7 +938,7 @@ fun AddEditSessionScreen(
problem?.difficulty?.let { difficulty ->
Text(
text = "${difficulty.system.name}: ${difficulty.grade}",
text = "${difficulty.system.getDisplayName()}: ${difficulty.grade}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
@@ -956,7 +1064,7 @@ fun AddAttemptDialog(
fontWeight = FontWeight.Medium
)
Text(
text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}",
text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)

View File

@@ -6,8 +6,10 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@Composable
@@ -26,12 +28,24 @@ fun AnalyticsScreen(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Analytics",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
}
// Overall Stats
item {

View File

@@ -8,10 +8,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
@@ -87,7 +86,7 @@ fun SessionDetailScreen(
title = { Text("Session Details") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
@@ -403,7 +402,7 @@ fun ProblemDetailScreen(
val context = LocalContext.current
var showDeleteDialog 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 sessions by viewModel.sessions.collectAsState()
val gyms by viewModel.gyms.collectAsState()
@@ -436,7 +435,7 @@ fun ProblemDetailScreen(
title = { Text("Problem Details") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
@@ -481,7 +480,7 @@ fun ProblemDetailScreen(
Column {
problem?.let { p ->
Text(
text = "${p.difficulty.system.name}: ${p.difficulty.grade}",
text = "${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
@@ -490,7 +489,7 @@ fun ProblemDetailScreen(
problem?.let { p ->
Text(
text = p.climbType.name.lowercase().replaceFirstChar { it.uppercase() },
text = p.climbType.getDisplayName(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -770,7 +769,7 @@ fun GymDetailScreen(
title = { Text(gym?.name ?: "Gym Details") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
@@ -1087,7 +1086,7 @@ fun GymDetailScreen(
supportingContent = {
val dateTime = try {
LocalDateTime.parse(session.date)
} catch (e: Exception) {
} catch (_: Exception) {
null
}
val formattedDate = dateTime?.format(
@@ -1314,7 +1313,7 @@ fun SessionAttemptCard(
)
Text(
text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}",
text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
@@ -1348,42 +1347,11 @@ private fun formatDate(dateString: String): String {
val date = LocalDateTime.parse(dateString, formatter)
val displayFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy")
date.format(displayFormatter)
} catch (e: Exception) {
} catch (_: Exception) {
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)
@Composable
fun EnhancedAddAttemptDialog(
@@ -1406,6 +1374,39 @@ fun EnhancedAddAttemptDialog(
var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) }
var selectedDifficultySystem by remember { mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE) }
// Auto-select climb type if there's only one available
LaunchedEffect(gym.supportedClimbTypes) {
if (gym.supportedClimbTypes.size == 1 && selectedClimbType != gym.supportedClimbTypes.first()) {
selectedClimbType = gym.supportedClimbTypes.first()
}
}
// Auto-select difficulty system if there's only one available for the selected climb type
LaunchedEffect(selectedClimbType, gym.difficultySystems) {
val availableSystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
gym.difficultySystems.contains(system)
}
when {
// If current system is not compatible, select the first available one
selectedDifficultySystem !in availableSystems -> {
selectedDifficultySystem = availableSystems.firstOrNull() ?: gym.difficultySystems.firstOrNull() ?: DifficultySystem.CUSTOM
}
// If there's only one available system, auto-select it
availableSystems.size == 1 && selectedDifficultySystem != availableSystems.first() -> {
selectedDifficultySystem = availableSystems.first()
}
}
}
// Reset grade when difficulty system changes
LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades()
if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) {
newProblemGrade = ""
}
}
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
@@ -1509,7 +1510,7 @@ fun EnhancedAddAttemptDialog(
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}",
text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
@@ -1584,7 +1585,7 @@ fun EnhancedAddAttemptDialog(
onClick = { selectedClimbType = climbType },
label = {
Text(
climbType.name.lowercase().replaceFirstChar { it.uppercase() },
climbType.getDisplayName(),
fontWeight = FontWeight.Medium
)
},
@@ -1611,12 +1612,15 @@ fun EnhancedAddAttemptDialog(
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(gym.difficultySystems) { system ->
val availableSystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
gym.difficultySystems.contains(system)
}
items(availableSystems) { system ->
FilterChip(
onClick = { selectedDifficultySystem = system },
label = {
Text(
system.name,
system.getDisplayName(),
fontWeight = FontWeight.Medium
)
},
@@ -1630,20 +1634,12 @@ fun EnhancedAddAttemptDialog(
}
}
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
OutlinedTextField(
value = newProblemGrade,
onValueChange = { newProblemGrade = it },
label = { Text("Grade *") },
placeholder = {
Text(when (selectedDifficultySystem) {
DifficultySystem.V_SCALE -> "e.g., V0, V4, V10"
DifficultySystem.FONT -> "e.g., 3, 6A+, 8B"
DifficultySystem.YDS -> "e.g., 5.8, 5.12a"
DifficultySystem.FRENCH -> "e.g., 6a, 7c+"
DifficultySystem.CUSTOM -> "Custom grade"
else -> "Enter grade"
})
},
placeholder = { Text("Enter custom grade") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
@@ -1655,6 +1651,46 @@ fun EnhancedAddAttemptDialog(
{ Text("Grade is required", color = MaterialTheme.colorScheme.error) }
} else null
)
} else {
var expanded by remember { mutableStateOf(false) }
val availableGrades = selectedDifficultySystem.getAvailableGrades()
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = newProblemGrade,
onValueChange = { },
readOnly = true,
label = { Text("Grade *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier
.menuAnchor()
.fillMaxWidth(),
isError = newProblemGrade.isBlank(),
supportingText = if (newProblemGrade.isBlank()) {
{ Text("Grade is required", color = MaterialTheme.colorScheme.error) }
} else null
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
availableGrades.forEach { grade ->
DropdownMenuItem(
text = { Text(grade) },
onClick = {
newProblemGrade = grade
expanded = false
}
)
}
}
}
}
}
}
}
@@ -1828,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,14 @@ package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@@ -19,8 +18,7 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@Composable
fun GymsScreen(
viewModel: ClimbViewModel,
onNavigateToGymDetail: (String) -> Unit,
onNavigateToAddGym: () -> Unit
onNavigateToGymDetail: (String) -> Unit
) {
val gyms by viewModel.gyms.collectAsState()
@@ -29,11 +27,23 @@ fun GymsScreen(
.fillMaxSize()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Climbing Gyms",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(16.dp))
@@ -95,7 +105,7 @@ fun GymCard(
AssistChip(
onClick = { },
label = {
Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() })
Text(climbType.getDisplayName())
},
modifier = Modifier.padding(end = 4.dp)
)
@@ -105,7 +115,7 @@ fun GymCard(
if (gym.difficultySystems.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.name }}",
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -2,16 +2,18 @@ package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.data.model.Problem
import com.atridad.openclimb.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplay
@@ -21,38 +23,157 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@Composable
fun ProblemsScreen(
viewModel: ClimbViewModel,
onNavigateToProblemDetail: (String) -> Unit,
onNavigateToAddProblem: (String?) -> Unit
onNavigateToProblemDetail: (String) -> Unit
) {
val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState()
var showImageViewer by remember { mutableStateOf(false) }
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedImageIndex by remember { mutableStateOf(0) }
var selectedImageIndex by remember { mutableIntStateOf(0) }
// Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
var selectedGym by remember { mutableStateOf<Gym?>(null) }
// Apply filters
val filteredProblems = problems.filter { problem ->
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
climbTypeMatch && gymMatch
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Problems & Routes",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(16.dp))
if (problems.isEmpty()) {
// Filters Section
if (problems.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Filters",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
// Climb Type Filter
Text(
text = "Climb Type",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
FilterChip(
onClick = { selectedClimbType = null },
label = { Text("All Types") },
selected = selectedClimbType == null
)
}
items(ClimbType.entries) { climbType ->
FilterChip(
onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) },
selected = selectedClimbType == climbType
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Gym Filter
Text(
text = "Gym",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
FilterChip(
onClick = { selectedGym = null },
label = { Text("All Gyms") },
selected = selectedGym == null
)
}
items(gyms) { gym ->
FilterChip(
onClick = { selectedGym = gym },
label = { Text(gym.name) },
selected = selectedGym?.id == gym.id
)
}
}
// Filter result count
if (selectedClimbType != null || selectedGym != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Showing ${filteredProblems.size} of ${problems.size} problems",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
if (filteredProblems.isEmpty()) {
EmptyStateMessage(
title = if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet",
message = if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!",
title = if (problems.isEmpty()) {
if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet"
} else {
"No Problems Match Filters"
},
message = if (problems.isEmpty()) {
if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!"
} else {
"Try adjusting your filters to see more problems."
},
onActionClick = { },
actionText = ""
)
} else {
LazyColumn {
items(problems) { problem ->
items(filteredProblems) { problem ->
ProblemCard(
problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
@@ -124,7 +245,7 @@ fun ProblemCard(
)
Text(
text = problem.climbType.name.lowercase().replaceFirstChar { it.uppercase() },
text = problem.climbType.getDisplayName(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -3,16 +3,20 @@ package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus
import com.atridad.openclimb.ui.components.ActiveSessionBanner
@@ -24,13 +28,13 @@ import java.time.format.DateTimeFormatter
@Composable
fun SessionsScreen(
viewModel: ClimbViewModel,
onNavigateToSessionDetail: (String) -> Unit,
onNavigateToAddSession: (String?) -> Unit
onNavigateToSessionDetail: (String) -> Unit
) {
val context = LocalContext.current
val sessions by viewModel.sessions.collectAsState()
val gyms by viewModel.gyms.collectAsState()
val activeSession by viewModel.activeSession.collectAsState()
val uiState by viewModel.uiState.collectAsState()
// Filter out active sessions from regular session list
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
@@ -45,16 +49,20 @@ fun SessionsScreen(
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Climbing Sessions",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(16.dp))
@@ -97,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)
@@ -135,7 +216,7 @@ fun SessionCard(
session.duration?.let { duration ->
Text(
text = "Duration: ${duration} minutes",
text = "Duration: $duration minutes",
style = MaterialTheme.typography.bodyMedium
)
}
@@ -197,7 +278,7 @@ private fun formatDate(dateString: String): String {
return try {
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
} catch (e: Exception) {
} catch (_: Exception) {
dateString
}
}

View File

@@ -2,7 +2,6 @@ package com.atridad.openclimb.ui.screens
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import android.os.Environment
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -303,21 +302,6 @@ fun SettingsScreen(
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
)
) {
ListItem(
headlineContent = { Text("About") },
supportingContent = { Text("OpenClimb - Track your climbing progress") },
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
)
}
}
}
}

View File

@@ -64,12 +64,3 @@ val ClimbNeutralVariant50 = Color(0xFF797979)
val ClimbNeutralVariant60 = Color(0xFF939393)
val ClimbNeutralVariant80 = Color(0xFFC7C7C7)
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
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
@@ -10,7 +9,6 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
@@ -98,7 +96,7 @@ fun OpenClimbTheme(
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
dynamicColor && true -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
@@ -110,8 +108,8 @@ fun OpenClimbTheme(
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}

View File

@@ -1,13 +1,14 @@
package com.atridad.openclimb.utils
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.UUID
import androidx.core.graphics.scale
object ImageUtils {
@@ -65,6 +66,7 @@ object ImageUtils {
/**
* Compresses and resizes an image bitmap
*/
@SuppressLint("UseKtx")
private fun compressImage(original: Bitmap): Bitmap {
val width = original.width
val height = original.height
@@ -79,7 +81,7 @@ object ImageUtils {
return if (scaleFactor < 1f) {
val newWidth = (width * scaleFactor).toInt()
val newHeight = (height * scaleFactor).toInt()
Bitmap.createScaledBitmap(original, newWidth, newHeight, true)
original.scale(newWidth, newHeight)
} else {
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
* @param context Android context

View File

@@ -11,6 +11,8 @@ import java.io.FileOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt
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(
context: Context,
session: ClimbSession,
@@ -110,14 +88,14 @@ object SessionShareUtils {
val width = 1080
val height = 1350
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val gradientDrawable = GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf(
Color.parseColor("#667eea"),
Color.parseColor("#764ba2")
"#667eea".toColorInt(),
"#764ba2".toColorInt()
)
)
gradientDrawable.setBounds(0, 0, width, height)
@@ -133,7 +111,7 @@ object SessionShareUtils {
}
val subtitlePaint = Paint().apply {
color = Color.parseColor("#E8E8E8")
color = "#E8E8E8".toColorInt()
textSize = 48f
typeface = Typeface.DEFAULT
isAntiAlias = true
@@ -141,7 +119,7 @@ object SessionShareUtils {
}
val statLabelPaint = Paint().apply {
color = Color.parseColor("#B8B8B8")
color = "#B8B8B8".toColorInt()
textSize = 36f
typeface = Typeface.DEFAULT
isAntiAlias = true
@@ -157,7 +135,7 @@ object SessionShareUtils {
}
val cardPaint = Paint().apply {
color = Color.parseColor("#40FFFFFF")
color = "#40FFFFFF".toColorInt()
isAntiAlias = true
}
@@ -211,7 +189,7 @@ object SessionShareUtils {
// App branding
val brandingPaint = Paint().apply {
color = Color.parseColor("#80FFFFFF")
color = "#80FFFFFF".toColorInt()
textSize = 32f
typeface = Typeface.DEFAULT
isAntiAlias = true
@@ -268,7 +246,7 @@ object SessionShareUtils {
// Background arc
val bgPaint = Paint().apply {
color = Color.parseColor("#40FFFFFF")
color = "#40FFFFFF".toColorInt()
style = Paint.Style.STROKE
this.strokeWidth = strokeWidth
isAntiAlias = true
@@ -277,7 +255,7 @@ object SessionShareUtils {
// Success arc
val successPaint = Paint().apply {
color = Color.parseColor("#4CAF50")
color = "#4CAF50".toColorInt()
style = Paint.Style.STROKE
this.strokeWidth = strokeWidth
isAntiAlias = true
@@ -305,7 +283,7 @@ object SessionShareUtils {
val date = LocalDateTime.parse(dateString, formatter)
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
date.format(displayFormatter)
} catch (e: Exception) {
} catch (_: Exception) {
dateString.take(10)
}
}

View File

@@ -1,7 +1,6 @@
package com.atridad.openclimb.utils
import android.content.Context
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.io.FileInputStream
@@ -180,13 +179,6 @@ object ZipExportImportUtils {
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
* This function maps the old image paths to the new ones after import