Android 1.7.1 - Added a clear sync indicator

This commit is contained in:
2025-09-29 14:03:30 -06:00
parent b930d5ce96
commit 72203dd18a
11 changed files with 388 additions and 541 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 28 versionCode = 29
versionName = "1.7.0" versionName = "1.7.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -17,7 +17,6 @@ import com.atridad.openclimb.utils.ImageUtils
import java.io.IOException import java.io.IOException
import java.time.Instant import java.time.Instant
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -27,12 +26,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import androidx.core.content.edit
class SyncService(private val context: Context, private val repository: ClimbRepository) { 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 var serverURL: String
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: "" get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
set(value) { set(value) {
sharedPreferences.edit().putString(Keys.SERVER_URL, value).apply() sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
} }
var authToken: String var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: "" get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
set(value) { set(value) {
sharedPreferences.edit().putString(Keys.AUTH_TOKEN, value).apply() sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
} }
val isConfigured: Boolean val isConfigured: Boolean
@@ -116,7 +115,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
// Register auto-sync callback with repository // Register auto-sync callback with repository
repository.setAutoSyncCallback { repository.setAutoSyncCallback {
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
triggerAutoSync() triggerAutoSync()
} }
} }
@@ -491,21 +490,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
return imagePathMapping 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) { private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems") 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 = val updatedProblem =
if (imagePathMapping.isNotEmpty()) { if (imagePathMapping.isNotEmpty()) {
val newImagePaths = val newImagePaths =
backupProblem.imagePaths?.mapNotNull { oldPath -> backupProblem.imagePaths?.map { oldPath ->
// Extract filename and check mapping // Extract filename and check mapping
val filename = oldPath.substringAfterLast('/') val filename = oldPath.substringAfterLast('/')
// Use mapped full path or fallback to consistent naming // 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 * 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" * 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<BackupGym>, server: List<BackupGym>): List<BackupGym> {
val merged = mutableMapOf<String, BackupGym>()
// 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<BackupProblem>,
server: List<BackupProblem>
): List<BackupProblem> {
val merged = mutableMapOf<String, BackupProblem>()
// 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<String>()
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<BackupClimbSession>,
server: List<BackupClimbSession>
): List<BackupClimbSession> {
val merged = mutableMapOf<String, BackupClimbSession>()
// 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<BackupAttempt>,
server: List<BackupAttempt>
): List<BackupAttempt> {
val merged = mutableMapOf<String, BackupAttempt>()
// 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() { fun clearConfiguration() {
serverURL = "" serverURL = ""
authToken = "" 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) { sealed class SyncException(message: String) : Exception(message) {
object NotConfigured : object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.") SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.") object NotConnected : SyncException("Not connected to server. Please test connection first.")
object Unauthorized : SyncException("Unauthorized. Please check your auth token.") 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 ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) : data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details") SyncException("Invalid server response: $details")

View File

@@ -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<Boolean>, 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
)
}
}
}

View File

@@ -15,6 +15,7 @@ import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.DifficultySystem import com.atridad.openclimb.data.model.DifficultySystem
import com.atridad.openclimb.ui.components.BarChart import com.atridad.openclimb.ui.components.BarChart
import com.atridad.openclimb.ui.components.BarChartDataPoint import com.atridad.openclimb.ui.components.BarChartDataPoint
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -45,8 +46,10 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
Text( Text(
text = "Analytics", text = "Analytics",
style = MaterialTheme.typography.headlineMedium, 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<GradeDistributionData
}, },
modifier = modifier =
Modifier.menuAnchor( Modifier.menuAnchor(
type = MenuAnchorType.PrimaryNotEditable, type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
enabled = true enabled = true
) )
.width(120.dp), .width(120.dp),
@@ -253,7 +256,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
DateTimeFormatter.ISO_LOCAL_DATE_TIME DateTimeFormatter.ISO_LOCAL_DATE_TIME
) )
attemptDate.isAfter(sevenDaysAgo) attemptDate.isAfter(sevenDaysAgo)
} catch (e: Exception) { } catch (_: Exception) {
// If date parsing fails, include the data point // If date parsing fails, include the data point
true true
} }
@@ -425,9 +428,7 @@ fun calculateGradeDistribution(
} else { } else {
gradeDistribution[key] = gradeDistribution[key] =
GradeDistributionDataPoint( GradeDistributionDataPoint(
date = date = attempt.timestamp,
attempt.timestamp
.toString(), // Use attempt timestamp for filtering
grade = problem.difficulty.grade, grade = problem.difficulty.grade,
gradeNumeric = gradeNumeric =
gradeToNumeric( gradeToNumeric(

View File

@@ -12,55 +12,48 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.Gym import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun GymsScreen( fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Unit) {
viewModel: ClimbViewModel,
onNavigateToGymDetail: (String) -> Unit
) {
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
Column( Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_mountains), painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo", contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Text( Text(
text = "Climbing Gyms", text = "Climbing Gyms",
style = MaterialTheme.typography.headlineMedium, 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)) Spacer(modifier = Modifier.height(16.dp))
if (gyms.isEmpty()) { if (gyms.isEmpty()) {
EmptyStateMessage( EmptyStateMessage(
title = "No Gyms Added", title = "No Gyms Added",
message = "Add your favorite climbing gyms to start tracking your progress!", message = "Add your favorite climbing gyms to start tracking your progress!",
onActionClick = { }, onActionClick = {},
actionText = "" actionText = ""
) )
} else { } else {
LazyColumn { LazyColumn {
items(gyms) { gym -> items(gyms) { gym ->
GymCard( GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) })
gym = gym,
onClick = { onNavigateToGymDetail(gym.id) }
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
} }
@@ -70,31 +63,21 @@ fun GymsScreen(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun GymCard( fun GymCard(gym: Gym, onClick: () -> Unit) {
gym: Gym, Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
onClick: () -> Unit Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text( Text(
text = gym.name, text = gym.name,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
gym.location?.let { location -> gym.location?.let { location ->
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = location, text = location,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -103,11 +86,9 @@ fun GymCard(
Row { Row {
gym.supportedClimbTypes.forEach { climbType -> gym.supportedClimbTypes.forEach { climbType ->
AssistChip( AssistChip(
onClick = { }, onClick = {},
label = { label = { Text(climbType.getDisplayName()) },
Text(climbType.getDisplayName()) modifier = Modifier.padding(end = 4.dp)
},
modifier = Modifier.padding(end = 4.dp)
) )
} }
} }
@@ -115,9 +96,10 @@ fun GymCard(
if (gym.difficultySystems.isNotEmpty()) { if (gym.difficultySystems.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}", text =
style = MaterialTheme.typography.bodySmall, "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
color = MaterialTheme.colorScheme.onSurfaceVariant style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -125,10 +107,10 @@ fun GymCard(
if (notes.isNotBlank()) { if (notes.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = notes, text = notes,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2 maxLines = 2
) )
} }
} }

View File

@@ -17,6 +17,7 @@ import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.data.model.Problem import com.atridad.openclimb.data.model.Problem
import com.atridad.openclimb.ui.components.FullscreenImageViewer import com.atridad.openclimb.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplay import com.atridad.openclimb.ui.components.ImageDisplay
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -58,10 +59,12 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Text( Text(
text = "Problems & Routes", text = "Climbing Problems",
style = MaterialTheme.typography.headlineMedium, 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)) Spacer(modifier = Modifier.height(16.dp))

View File

@@ -20,16 +20,14 @@ import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbSession import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus import com.atridad.openclimb.data.model.SessionStatus
import com.atridad.openclimb.ui.components.ActiveSessionBanner import com.atridad.openclimb.ui.components.ActiveSessionBanner
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SessionsScreen( fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String) -> Unit) {
viewModel: ClimbViewModel,
onNavigateToSessionDetail: (String) -> Unit
) {
val context = LocalContext.current val context = LocalContext.current
val sessions by viewModel.sessions.collectAsState() val sessions by viewModel.sessions.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
@@ -38,47 +36,37 @@ fun SessionsScreen(
// Filter out active sessions from regular session list // Filter out active sessions from regular session list
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED } val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
val activeSessionGym = activeSession?.let { session -> val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
gyms.find { it.id == session.gymId }
}
Column( Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_mountains), painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo", contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Text( Text(
text = "Climbing Sessions", text = "Climbing Sessions",
style = MaterialTheme.typography.headlineMedium, 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)) Spacer(modifier = Modifier.height(16.dp))
// Active session banner // Active session banner
ActiveSessionBanner( ActiveSessionBanner(
activeSession = activeSession, activeSession = activeSession,
gym = activeSessionGym, gym = activeSessionGym,
onSessionClick = { onSessionClick = { activeSession?.let { onNavigateToSessionDetail(it.id) } },
activeSession?.let { onNavigateToSessionDetail(it.id) } onEndSession = { activeSession?.let { viewModel.endSession(context, it.id) } }
},
onEndSession = {
activeSession?.let {
viewModel.endSession(context, it.id)
}
}
) )
if (activeSession != null) { if (activeSession != null) {
@@ -87,18 +75,21 @@ fun SessionsScreen(
if (completedSessions.isEmpty() && activeSession == null) { if (completedSessions.isEmpty() && activeSession == null) {
EmptyStateMessage( EmptyStateMessage(
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet", 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!", message =
onActionClick = { }, if (gyms.isEmpty())
actionText = "" "Add a gym first to start tracking your climbing sessions!"
else "Start your first climbing session!",
onActionClick = {},
actionText = ""
) )
} else { } else {
LazyColumn { LazyColumn {
items(completedSessions) { session -> items(completedSessions) { session ->
SessionCard( SessionCard(
session = session, session = session,
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym", gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
onClick = { onNavigateToSessionDetail(session.id) } onClick = { onNavigateToSessionDetail(session.id) }
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
@@ -114,30 +105,27 @@ fun SessionsScreen(
} }
Card( Card(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(16.dp),
.fillMaxWidth() colors =
.padding(16.dp), CardDefaults.cardColors(
colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer
containerColor = MaterialTheme.colorScheme.primaryContainer ),
), shape = RoundedCornerShape(12.dp)
shape = RoundedCornerShape(12.dp)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(16.dp),
.fillMaxWidth() verticalAlignment = Alignment.CenterVertically
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
Icons.Default.CheckCircle, Icons.Default.CheckCircle,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = message, text = message,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer color = MaterialTheme.colorScheme.onPrimaryContainer
) )
} }
} }
@@ -150,30 +138,27 @@ fun SessionsScreen(
} }
Card( Card(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(16.dp),
.fillMaxWidth() colors =
.padding(16.dp), CardDefaults.cardColors(
colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.errorContainer
containerColor = MaterialTheme.colorScheme.errorContainer ),
), shape = RoundedCornerShape(12.dp)
shape = RoundedCornerShape(12.dp)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(16.dp),
.fillMaxWidth() verticalAlignment = Alignment.CenterVertically
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
Icons.Default.Warning, Icons.Default.Warning,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = error, text = error,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer color = MaterialTheme.colorScheme.onErrorContainer
) )
} }
} }
@@ -182,33 +167,22 @@ fun SessionsScreen(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SessionCard( fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
session: ClimbSession, Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
gymName: String, Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = gymName, text = gymName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
text = formatDate(session.date), text = formatDate(session.date),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -216,8 +190,8 @@ fun SessionCard(
session.duration?.let { duration -> session.duration?.let { duration ->
Text( Text(
text = "Duration: $duration minutes", text = "Duration: $duration minutes",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
} }
@@ -225,10 +199,10 @@ fun SessionCard(
if (notes.isNotBlank()) { if (notes.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = notes, text = notes,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2 maxLines = 2
) )
} }
} }
@@ -238,38 +212,36 @@ fun SessionCard(
@Composable @Composable
fun EmptyStateMessage( fun EmptyStateMessage(
title: String, title: String,
message: String, message: String,
onActionClick: () -> Unit, onActionClick: () -> Unit,
actionText: String actionText: String
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = message, text = message,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (actionText.isNotEmpty()) { if (actionText.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onActionClick) { Button(onClick = onActionClick) { Text(actionText) }
Text(actionText)
}
} }
} }
} }

View File

@@ -16,6 +16,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.openclimb.R
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.io.File import java.io.File
import java.time.Instant import java.time.Instant
@@ -122,153 +123,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
Text( Text(
text = "Settings", text = "Settings",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
) )
} SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
}
// 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)
}
}
}
)
}
}
} }
} }
@@ -318,7 +176,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
"Last sync: ${ "Last sync: ${
try { try {
Instant.parse(time).toString() Instant.parse(time).toString()
} catch (e: Exception) { } catch (_: Exception) {
time 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 // App Information Section
item { item {
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
@@ -754,7 +757,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
syncService.authToken = authToken.trim() syncService.authToken = authToken.trim()
viewModel.testSyncConnection() viewModel.testSyncConnection()
showSyncConfigDialog = false showSyncConfigDialog = false
} catch (e: Exception) { } catch (_: Exception) {
// Error will be shown via syncError state // Error will be shown via syncError state
} }
} }

View File

@@ -1,14 +1,9 @@
# OpenClimb Sync Server Configuration # Required
# Required: Secret token for authentication
# Generate a secure random token and share it between your apps and server
AUTH_TOKEN=your-secure-secret-token-here 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) # Optional
PORT=8080 DATA_FILE=/data/data.json
IMAGES_DIR=/data/images
# 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

View File

@@ -2,11 +2,12 @@ services:
openclimb-sync: openclimb-sync:
image: ${IMAGE} image: ${IMAGE}
ports: ports:
- "8080:8080" - ${APP_PORT}:8080
environment: environment:
- AUTH_TOKEN=${AUTH_TOKEN:-your-secret-token-here} - AUTH_TOKEN=${AUTH_TOKEN}
- DATA_FILE=/data/climb_data.json - DATA_FILE=${DATA_FILE}
- IMAGES_DIR=/data/images - IMAGES_DIR=${IMAGES_DIR}
volumes: volumes:
- ./data:/data - ${ROOT_DIR}:/data
restart: unless-stopped restart: unless-stopped
networks: {}