diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a169fc6..2033726 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 28 - versionName = "1.7.0" + versionCode = 29 + versionName = "1.7.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt index 9d193e6..45a033b 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt @@ -17,7 +17,6 @@ import com.atridad.openclimb.utils.ImageUtils import java.io.IOException import java.time.Instant import java.util.concurrent.TimeUnit -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,12 +26,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import androidx.core.content.edit class SyncService(private val context: Context, private val repository: ClimbRepository) { @@ -91,13 +90,13 @@ class SyncService(private val context: Context, private val repository: ClimbRep var serverURL: String get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: "" set(value) { - sharedPreferences.edit().putString(Keys.SERVER_URL, value).apply() + sharedPreferences.edit { putString(Keys.SERVER_URL, value) } } var authToken: String get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: "" set(value) { - sharedPreferences.edit().putString(Keys.AUTH_TOKEN, value).apply() + sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) } } val isConfigured: Boolean @@ -116,7 +115,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep // Register auto-sync callback with repository repository.setAutoSyncCallback { - kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() } } @@ -491,21 +490,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep return imagePathMapping } - private suspend fun syncImagesToServer() { - val allProblems = repository.getAllProblems().first() - val backup = - ClimbDataBackup( - exportedAt = DateFormatUtils.nowISO8601(), - version = "2.0", - formatVersion = "2.0", - gyms = emptyList(), - problems = allProblems.map { BackupProblem.fromProblem(it) }, - sessions = emptyList(), - attempts = emptyList() - ) - syncImagesForBackup(backup) - } - private suspend fun syncImagesForBackup(backup: ClimbDataBackup) { Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems") @@ -626,7 +610,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep val updatedProblem = if (imagePathMapping.isNotEmpty()) { val newImagePaths = - backupProblem.imagePaths?.mapNotNull { oldPath -> + backupProblem.imagePaths?.map { oldPath -> // Extract filename and check mapping val filename = oldPath.substringAfterLast('/') // Use mapped full path or fallback to consistent naming @@ -696,11 +680,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - /** Converts milliseconds to ISO8601 timestamp */ - private fun millisToISO8601(millis: Long): String { - return DateFormatUtils.millisToISO8601(millis) - } - /** * Fixes existing image paths in the database to include the proper directory structure. This * corrects paths like "problem_abc_0.jpg" to "problem_images/problem_abc_0.jpg" @@ -833,141 +812,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // DEPRECATED: Complex merge logic replaced with simple timestamp-based sync - // These methods are no longer used but kept for reference - @Deprecated("Use simple timestamp-based sync instead") - private fun performIntelligentMerge( - local: ClimbDataBackup, - server: ClimbDataBackup - ): ClimbDataBackup { - Log.d(TAG, "Merging data - preserving all entities to prevent data loss") - - val mergedGyms = mergeGyms(local.gyms, server.gyms) - val mergedProblems = mergeProblems(local.problems, server.problems) - val mergedSessions = mergeSessions(local.sessions, server.sessions) - val mergedAttempts = mergeAttempts(local.attempts, server.attempts) - - Log.d( - TAG, - "Merge results: gyms=${mergedGyms.size}, problems=${mergedProblems.size}, " + - "sessions=${mergedSessions.size}, attempts=${mergedAttempts.size}" - ) - - return ClimbDataBackup( - exportedAt = DateFormatUtils.nowISO8601(), - version = "2.0", - formatVersion = "2.0", - gyms = mergedGyms, - problems = mergedProblems, - sessions = mergedSessions, - attempts = mergedAttempts - ) - } - - private fun mergeGyms(local: List, server: List): List { - val merged = mutableMapOf() - - // Add all local gyms - local.forEach { gym -> merged[gym.id] = gym } - - // Add server gyms, preferring newer updates - server.forEach { serverGym -> - val localGym = merged[serverGym.id] - if (localGym == null || isNewerThan(serverGym.updatedAt, localGym.updatedAt)) { - merged[serverGym.id] = serverGym - } - } - - return merged.values.toList() - } - - private fun mergeProblems( - local: List, - server: List - ): List { - val merged = mutableMapOf() - - // Add all local problems - local.forEach { problem -> merged[problem.id] = problem } - - // Add server problems, preferring newer updates - server.forEach { serverProblem -> - val localProblem = merged[serverProblem.id] - if (localProblem == null || isNewerThan(serverProblem.updatedAt, localProblem.updatedAt) - ) { - // Merge image paths to preserve all images - val allImagePaths = mutableSetOf() - localProblem?.imagePaths?.let { allImagePaths.addAll(it) } - serverProblem.imagePaths?.let { allImagePaths.addAll(it) } - - merged[serverProblem.id] = - serverProblem.withUpdatedImagePaths(allImagePaths.toList()) - } - } - - return merged.values.toList() - } - - private fun mergeSessions( - local: List, - server: List - ): List { - val merged = mutableMapOf() - - // Add all local sessions - local.forEach { session -> merged[session.id] = session } - - // Add server sessions, preferring newer updates - server.forEach { serverSession -> - val localSession = merged[serverSession.id] - if (localSession == null || isNewerThan(serverSession.updatedAt, localSession.updatedAt) - ) { - merged[serverSession.id] = serverSession - } - } - - return merged.values.toList() - } - - private fun mergeAttempts( - local: List, - server: List - ): List { - val merged = mutableMapOf() - - // Add all local attempts - local.forEach { attempt -> merged[attempt.id] = attempt } - - // Add server attempts, preferring newer updates - server.forEach { serverAttempt -> - val localAttempt = merged[serverAttempt.id] - if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) - ) { - merged[serverAttempt.id] = serverAttempt - } - } - - return merged.values.toList() - } - - private fun isNewerThan(dateString1: String, dateString2: String): Boolean { - return try { - // Try parsing as instant first - val date1 = Instant.parse(dateString1) - val date2 = Instant.parse(dateString2) - date1.isAfter(date2) - } catch (e: Exception) { - // Fallback to string comparison - dateString1 > dateString2 - } - } - - fun disconnect() { - _isConnected.value = false - sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply() - _syncError.value = null - } - fun clearConfiguration() { serverURL = "" authToken = "" @@ -980,14 +824,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } -// Removed SyncTrigger enum - now using simple auto sync on any data change - sealed class SyncException(message: String) : Exception(message) { object NotConfigured : SyncException("Sync is not configured. Please set server URL and auth token.") object NotConnected : SyncException("Not connected to server. Please test connection first.") object Unauthorized : SyncException("Unauthorized. Please check your auth token.") - object InvalidURL : SyncException("Invalid server URL.") data class ServerError(val code: Int) : SyncException("Server error: HTTP $code") data class InvalidResponse(val details: String) : SyncException("Invalid server response: $details") diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/components/SyncIndicator.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/SyncIndicator.kt new file mode 100644 index 0000000..3403f15 --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/ui/components/SyncIndicator.kt @@ -0,0 +1,49 @@ +package com.atridad.openclimb.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun SyncIndicator(isSyncing: StateFlow, modifier: Modifier = Modifier) { + val syncing by isSyncing.collectAsState() + + AnimatedVisibility( + visible = syncing, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + modifier = modifier + ) { + Box( + modifier = + Modifier.size(28.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape) + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } +} diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt index 33acbed..bd092fa 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt @@ -15,6 +15,7 @@ import com.atridad.openclimb.data.model.ClimbType import com.atridad.openclimb.data.model.DifficultySystem import com.atridad.openclimb.ui.components.BarChart import com.atridad.openclimb.ui.components.BarChartDataPoint +import com.atridad.openclimb.ui.components.SyncIndicator import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -45,8 +46,10 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) { Text( text = "Analytics", style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) ) + SyncIndicator(isSyncing = viewModel.syncService.isSyncing) } } @@ -197,7 +200,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List Unit -) { +fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Unit) { val gyms by viewModel.gyms.collectAsState() - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + 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 + 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 + text = "Climbing Gyms", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) ) + SyncIndicator(isSyncing = viewModel.syncService.isSyncing) } - + Spacer(modifier = Modifier.height(16.dp)) - + if (gyms.isEmpty()) { EmptyStateMessage( - title = "No Gyms Added", - message = "Add your favorite climbing gyms to start tracking your progress!", - onActionClick = { }, - actionText = "" + title = "No Gyms Added", + message = "Add your favorite climbing gyms to start tracking your progress!", + onActionClick = {}, + actionText = "" ) } else { LazyColumn { items(gyms) { gym -> - GymCard( - gym = gym, - onClick = { onNavigateToGymDetail(gym.id) } - ) + GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) }) Spacer(modifier = Modifier.height(8.dp)) } } @@ -70,65 +63,54 @@ fun GymsScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun GymCard( - gym: Gym, - onClick: () -> Unit -) { - Card( - onClick = onClick, - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { +fun GymCard(gym: Gym, onClick: () -> Unit) { + Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text( - text = gym.name, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + text = gym.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold ) - + gym.location?.let { location -> Spacer(modifier = Modifier.height(4.dp)) Text( - text = location, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = location, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - + Spacer(modifier = Modifier.height(8.dp)) - + Row { gym.supportedClimbTypes.forEach { climbType -> AssistChip( - onClick = { }, - label = { - Text(climbType.getDisplayName()) - }, - modifier = Modifier.padding(end = 4.dp) + onClick = {}, + label = { Text(climbType.getDisplayName()) }, + modifier = Modifier.padding(end = 4.dp) ) } } - + if (gym.difficultySystems.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = + "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - + gym.notes?.let { notes -> if (notes.isNotBlank()) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = notes, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2 + text = notes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2 ) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt index d7de626..7277579 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt @@ -17,6 +17,7 @@ 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 +import com.atridad.openclimb.ui.components.SyncIndicator import com.atridad.openclimb.ui.viewmodel.ClimbViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -58,10 +59,12 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String tint = MaterialTheme.colorScheme.primary ) Text( - text = "Problems & Routes", + text = "Climbing Problems", style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) ) + SyncIndicator(isSyncing = viewModel.syncService.isSyncing) } Spacer(modifier = Modifier.height(16.dp)) diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt index 22a439e..4248b27 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt @@ -20,160 +20,145 @@ 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 +import com.atridad.openclimb.ui.components.SyncIndicator import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import java.time.LocalDateTime import java.time.format.DateTimeFormatter @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SessionsScreen( - viewModel: ClimbViewModel, - onNavigateToSessionDetail: (String) -> Unit -) { +fun SessionsScreen(viewModel: ClimbViewModel, 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 } - val activeSessionGym = activeSession?.let { session -> - gyms.find { it.id == session.gymId } - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { + val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + 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 + 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 + text = "Climbing Sessions", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) ) + SyncIndicator(isSyncing = viewModel.syncService.isSyncing) } - + Spacer(modifier = Modifier.height(16.dp)) - + // Active session banner ActiveSessionBanner( - activeSession = activeSession, - gym = activeSessionGym, - onSessionClick = { - activeSession?.let { onNavigateToSessionDetail(it.id) } - }, - onEndSession = { - activeSession?.let { - viewModel.endSession(context, it.id) - } - } + activeSession = activeSession, + gym = activeSessionGym, + onSessionClick = { activeSession?.let { onNavigateToSessionDetail(it.id) } }, + onEndSession = { activeSession?.let { viewModel.endSession(context, it.id) } } ) - + if (activeSession != null) { Spacer(modifier = Modifier.height(16.dp)) } - + if (completedSessions.isEmpty() && activeSession == null) { EmptyStateMessage( - title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet", - message = if (gyms.isEmpty()) "Add a gym first to start tracking your climbing sessions!" else "Start your first climbing session!", - onActionClick = { }, - actionText = "" + title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet", + message = + if (gyms.isEmpty()) + "Add a gym first to start tracking your climbing sessions!" + else "Start your first climbing session!", + onActionClick = {}, + actionText = "" ) } else { LazyColumn { items(completedSessions) { session -> SessionCard( - session = session, - gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym", - onClick = { onNavigateToSessionDetail(session.id) } + session = session, + gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym", + onClick = { onNavigateToSessionDetail(session.id) } ) Spacer(modifier = Modifier.height(8.dp)) } } } } - + // 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) + 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 + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - Icons.Default.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary + 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 + 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) + 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 + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error + 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 + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer ) } } @@ -182,53 +167,42 @@ fun SessionsScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SessionCard( - session: ClimbSession, - gymName: String, - onClick: () -> Unit -) { - Card( - onClick = onClick, - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { +fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) { + Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = gymName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = gymName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) Text( - text = formatDate(session.date), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = formatDate(session.date), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - + Spacer(modifier = Modifier.height(4.dp)) - + session.duration?.let { duration -> Text( - text = "Duration: $duration minutes", - style = MaterialTheme.typography.bodyMedium + text = "Duration: $duration minutes", + style = MaterialTheme.typography.bodyMedium ) } - + session.notes?.let { notes -> if (notes.isNotBlank()) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = notes, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2 + text = notes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2 ) } } @@ -238,38 +212,36 @@ fun SessionCard( @Composable fun EmptyStateMessage( - title: String, - message: String, - onActionClick: () -> Unit, - actionText: String + title: String, + message: String, + onActionClick: () -> Unit, + actionText: String ) { Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center ) - + Spacer(modifier = Modifier.height(8.dp)) - + Text( - text = message, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center ) - + if (actionText.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) - - Button(onClick = onActionClick) { - Text(actionText) - } + + Button(onClick = onActionClick) { Text(actionText) } } } } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt index d5ea537..ec31063 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt @@ -16,6 +16,7 @@ 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.components.SyncIndicator import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import java.io.File import java.time.Instant @@ -122,153 +123,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) { Text( text = "Settings", style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) ) - } - } - - // Data Management Section - item { - Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - Text( - text = "Data Management", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(12.dp)) - - // Export Data - Card( - shape = RoundedCornerShape(12.dp), - colors = - CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.surfaceVariant.copy( - alpha = 0.3f - ) - ) - ) { - ListItem( - headlineContent = { Text("Export Data with Images") }, - supportingContent = { - Text( - "Export all your climbing data and images to ZIP file (recommended)" - ) - }, - leadingContent = { - Icon(Icons.Default.Share, contentDescription = null) - }, - trailingContent = { - TextButton( - onClick = { - val defaultFileName = - "openclimb_export_${ - java.time.LocalDateTime.now() - .toString() - .replace(":", "-") - .replace(".", "-") - }.zip" - exportZipLauncher.launch(defaultFileName) - }, - enabled = !uiState.isLoading - ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Text("Export ZIP") - } - } - } - ) - } - - 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("Import Data") }, - supportingContent = { - Text("Import climbing data from ZIP file (recommended format)") - }, - leadingContent = { - Icon(Icons.Default.Add, contentDescription = null) - }, - trailingContent = { - TextButton( - onClick = { importLauncher.launch("application/zip") }, - enabled = !uiState.isLoading - ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Text("Import") - } - } - } - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Card( - shape = RoundedCornerShape(12.dp), - colors = - CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.errorContainer.copy( - alpha = 0.3f - ) - ) - ) { - ListItem( - headlineContent = { Text("Reset All Data") }, - supportingContent = { - Text( - "Permanently delete all gyms, problems, sessions, attempts, and images" - ) - }, - leadingContent = { - Icon( - Icons.Default.Delete, - contentDescription = null, - tint = MaterialTheme.colorScheme.error - ) - }, - trailingContent = { - TextButton( - onClick = { showResetDialog = true }, - enabled = !uiState.isLoading - ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Text("Reset", color = MaterialTheme.colorScheme.error) - } - } - } - ) - } - } + SyncIndicator(isSyncing = viewModel.syncService.isSyncing) } } @@ -318,7 +176,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) { "Last sync: ${ try { Instant.parse(time).toString() - } catch (e: Exception) { + } catch (_: Exception) { time } }", @@ -510,6 +368,151 @@ fun SettingsScreen(viewModel: ClimbViewModel) { } } + // Data Management Section + item { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text( + text = "Data Management", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Export Data + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.3f + ) + ) + ) { + ListItem( + headlineContent = { Text("Export Data with Images") }, + supportingContent = { + Text( + "Export all your climbing data and images to ZIP file (recommended)" + ) + }, + leadingContent = { + Icon(Icons.Default.Share, contentDescription = null) + }, + trailingContent = { + TextButton( + onClick = { + val defaultFileName = + "openclimb_export_${ + java.time.LocalDateTime.now() + .toString() + .replace(":", "-") + .replace(".", "-") + }.zip" + exportZipLauncher.launch(defaultFileName) + }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Export ZIP") + } + } + } + ) + } + + 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("Import Data") }, + supportingContent = { + Text("Import climbing data from ZIP file (recommended format)") + }, + leadingContent = { + Icon(Icons.Default.Add, contentDescription = null) + }, + trailingContent = { + TextButton( + onClick = { importLauncher.launch("application/zip") }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Import") + } + } + } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.errorContainer.copy( + alpha = 0.3f + ) + ) + ) { + ListItem( + headlineContent = { Text("Reset All Data") }, + supportingContent = { + Text( + "Permanently delete all gyms, problems, sessions, attempts, and images" + ) + }, + leadingContent = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + trailingContent = { + TextButton( + onClick = { showResetDialog = true }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Reset", color = MaterialTheme.colorScheme.error) + } + } + } + ) + } + } + } + } + // App Information Section item { Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { @@ -754,7 +757,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) { syncService.authToken = authToken.trim() viewModel.testSyncConnection() showSyncConfigDialog = false - } catch (e: Exception) { + } catch (_: Exception) { // Error will be shown via syncError state } } diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 26db1e9..328c1d7 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/sync/.env.example b/sync/.env.example index bc6aa89..f39de10 100644 --- a/sync/.env.example +++ b/sync/.env.example @@ -1,14 +1,9 @@ -# OpenClimb Sync Server Configuration - -# Required: Secret token for authentication -# Generate a secure random token and share it between your apps and server +# Required AUTH_TOKEN=your-secure-secret-token-here +IMAGE="git.atri.dad/atridad/openclimb-sync:latest" +APP_PORT=1337 +ROOT_DIR="./data" -# Optional: Port to run the server on (default: 8080) -PORT=8080 - -# Optional: Path to store the sync data (default: ./data/climb_data.json) -DATA_FILE=./data/climb_data.json - -# Optional: Directory to store images (default: ./data/images) -IMAGES_DIR=./data/images +# Optional +DATA_FILE=/data/data.json +IMAGES_DIR=/data/images diff --git a/sync/docker-compose.yml b/sync/docker-compose.yml index ca7977d..951b61a 100644 --- a/sync/docker-compose.yml +++ b/sync/docker-compose.yml @@ -2,11 +2,12 @@ services: openclimb-sync: image: ${IMAGE} ports: - - "8080:8080" + - ${APP_PORT}:8080 environment: - - AUTH_TOKEN=${AUTH_TOKEN:-your-secret-token-here} - - DATA_FILE=/data/climb_data.json - - IMAGES_DIR=/data/images + - AUTH_TOKEN=${AUTH_TOKEN} + - DATA_FILE=${DATA_FILE} + - IMAGES_DIR=${IMAGES_DIR} volumes: - - ./data:/data + - ${ROOT_DIR}:/data restart: unless-stopped +networks: {}