Compare commits

...

10 Commits

Author SHA1 Message Date
1980ff802a Merge remote-tracking branch 'origin/main' 2025-09-29 14:03:51 -06:00
73c4e41cac Android 1.7.1 - Added a clear sync indicator 2025-09-29 14:03:30 -06:00
3ccd0ec7ea oops 2025-09-29 13:47:57 -06:00
8fbb40d453 iOS 1.2.1 - Better auto sync and sync indicator 2025-09-29 13:47:50 -06:00
016d427ff8 Update sync/.env.example
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m22s
2025-09-29 17:49:25 +00:00
7cbb333287 Update sync/docker-compose.yml
Some checks failed
OpenClimb Docker Deploy / build-and-push (push) Has been cancelled
2025-09-29 17:47:09 +00:00
2160ce30bd Update README.md 2025-09-29 05:46:47 +00:00
de21e3270e Update README.md 2025-09-29 05:46:23 +00:00
f6e1cdcb5b Update README.md 2025-09-28 23:45:38 -06:00
fab587c351 Cleanup from dev
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m22s
2025-09-28 23:43:19 -06:00
28 changed files with 585 additions and 1097 deletions

View File

@@ -4,8 +4,18 @@ This is a FOSS app meant to help climbers track their sessions, routes/problems,
## Versions
- Android:1.4.2
- iOS: 1.0.1
- Android: 1.7.0
- iOS: 1.2.0
- Sync: 1.0.0
## Stability
- Clients: 8/10
- Server: 10/10
- Schema: 9/10 (No more breaking changes)
## Self-Hosted Sync Server
You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up. See the server docker-compose file for an example.
## Download

View File

@@ -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"
}

View File

@@ -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<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() {
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")

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.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<GradeDistributionData
},
modifier =
Modifier.menuAnchor(
type = MenuAnchorType.PrimaryNotEditable,
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
enabled = true
)
.width(120.dp),
@@ -253,7 +256,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
DateTimeFormatter.ISO_LOCAL_DATE_TIME
)
attemptDate.isAfter(sevenDaysAgo)
} catch (e: Exception) {
} catch (_: Exception) {
// If date parsing fails, include the data point
true
}
@@ -425,9 +428,7 @@ fun calculateGradeDistribution(
} else {
gradeDistribution[key] =
GradeDistributionDataPoint(
date =
attempt.timestamp
.toString(), // Use attempt timestamp for filtering
date = attempt.timestamp,
grade = problem.difficulty.grade,
gradeNumeric =
gradeToNumeric(

View File

@@ -12,21 +12,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GymsScreen(
viewModel: ClimbViewModel,
onNavigateToGymDetail: (String) -> 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,
@@ -41,8 +35,10 @@ fun GymsScreen(
Text(
text = "Climbing Gyms",
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))
@@ -57,10 +53,7 @@ fun GymsScreen(
} 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,19 +63,9 @@ 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,
@@ -104,9 +87,7 @@ fun GymCard(
gym.supportedClimbTypes.forEach { climbType ->
AssistChip(
onClick = {},
label = {
Text(climbType.getDisplayName())
},
label = { Text(climbType.getDisplayName()) },
modifier = Modifier.padding(end = 4.dp)
)
}
@@ -115,7 +96,8 @@ fun GymCard(
if (gym.difficultySystems.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
text =
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -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))

View File

@@ -20,16 +20,14 @@ 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()
@@ -38,15 +36,9 @@ fun SessionsScreen(
// 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 }
}
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -61,8 +53,10 @@ fun SessionsScreen(
Text(
text = "Climbing Sessions",
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))
@@ -71,14 +65,8 @@ fun SessionsScreen(
ActiveSessionBanner(
activeSession = activeSession,
gym = activeSessionGym,
onSessionClick = {
activeSession?.let { onNavigateToSessionDetail(it.id) }
},
onEndSession = {
activeSession?.let {
viewModel.endSession(context, it.id)
}
}
onSessionClick = { activeSession?.let { onNavigateToSessionDetail(it.id) } },
onEndSession = { activeSession?.let { viewModel.endSession(context, it.id) } }
)
if (activeSession != null) {
@@ -88,7 +76,10 @@ fun SessionsScreen(
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!",
message =
if (gyms.isEmpty())
"Add a gym first to start tracking your climbing sessions!"
else "Start your first climbing session!",
onActionClick = {},
actionText = ""
)
@@ -114,18 +105,15 @@ fun SessionsScreen(
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -150,18 +138,15 @@ fun SessionsScreen(
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -182,20 +167,9 @@ 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
@@ -267,9 +241,7 @@ fun EmptyStateMessage(
if (actionText.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onActionClick) {
Text(actionText)
}
Button(onClick = onActionClick) { 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.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
}
}

View File

@@ -396,7 +396,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -416,7 +416,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -439,7 +439,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -459,7 +459,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -481,7 +481,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -492,7 +492,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -511,7 +511,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -522,7 +522,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.2.1;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -1,6 +1,5 @@
import Combine
import Foundation
import UIKit
@MainActor
class SyncService: ObservableObject {
@@ -455,7 +454,7 @@ class SyncService: ObservableObject {
let zipData = try createMinimalZipFromBackup(updatedBackup)
// Use existing import method which properly handles data restoration
try dataManager.importData(from: zipData)
try dataManager.importData(from: zipData, showSuccessMessage: false)
// Update local data state to match imported data timestamp
DataStateManager.shared.setLastModified(backup.exportedAt)
@@ -735,180 +734,29 @@ class SyncService: ObservableObject {
}
func triggerAutoSync(dataManager: ClimbingDataManager) {
guard isConnected && isConfigured && isAutoSyncEnabled else { return }
// Early exit if sync cannot proceed - don't set isSyncing
guard isConnected && isConfigured && isAutoSyncEnabled else {
// Ensure isSyncing is false when sync is not possible
if isSyncing {
isSyncing = false
}
return
}
// Prevent multiple simultaneous syncs
guard !isSyncing else {
return
}
Task {
do {
try await syncWithServer(dataManager: dataManager)
} catch {
print("Auto-sync failed: \(error)")
// Don't show UI errors for auto-sync failures
await MainActor.run {
self.isSyncing = false
}
}
}
// DEPRECATED: Complex merge logic replaced with simple timestamp-based sync
// These methods are no longer used but kept for reference
@available(*, deprecated, message: "Use simple timestamp-based sync instead")
private func performIntelligentMerge(local: ClimbDataBackup, server: ClimbDataBackup) throws
-> ClimbDataBackup
{
print("Merging data - preserving all entities to prevent data loss")
// Merge gyms by ID, keeping most recently updated
let mergedGyms = mergeGyms(local: local.gyms, server: server.gyms)
// Merge problems by ID, keeping most recently updated
let mergedProblems = mergeProblems(local: local.problems, server: server.problems)
// Merge sessions by ID, keeping most recently updated
let mergedSessions = mergeSessions(local: local.sessions, server: server.sessions)
// Merge attempts by ID, keeping most recently updated
let mergedAttempts = mergeAttempts(local: local.attempts, server: server.attempts)
print(
"Merge results: gyms=\(mergedGyms.count), problems=\(mergedProblems.count), sessions=\(mergedSessions.count), attempts=\(mergedAttempts.count)"
)
return ClimbDataBackup(
exportedAt: ISO8601DateFormatter().string(from: Date()),
version: "2.0",
formatVersion: "2.0",
gyms: mergedGyms,
problems: mergedProblems,
sessions: mergedSessions,
attempts: mergedAttempts
)
}
private func mergeGyms(local: [BackupGym], server: [BackupGym]) -> [BackupGym] {
var merged: [String: BackupGym] = [:]
// Add all local gyms
for gym in local {
merged[gym.id] = gym
}
// Add server gyms, replacing if newer
for serverGym in server {
if let localGym = merged[serverGym.id] {
// Keep the most recently updated
if isNewerThan(serverGym.updatedAt, localGym.updatedAt) {
merged[serverGym.id] = serverGym
}
} else {
// New gym from server
merged[serverGym.id] = serverGym
}
}
return Array(merged.values)
}
private func mergeProblems(local: [BackupProblem], server: [BackupProblem]) -> [BackupProblem] {
var merged: [String: BackupProblem] = [:]
// Add all local problems
for problem in local {
merged[problem.id] = problem
}
// Add server problems, replacing if newer or merging image paths
for serverProblem in server {
if let localProblem = merged[serverProblem.id] {
// Merge image paths from both sources
let localImages = Set(localProblem.imagePaths ?? [])
let serverImages = Set(serverProblem.imagePaths ?? [])
let mergedImages = Array(localImages.union(serverImages))
// Use most recently updated problem data but with merged images
let newerProblem =
isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
? serverProblem : localProblem
merged[serverProblem.id] = BackupProblem(
id: newerProblem.id,
gymId: newerProblem.gymId,
name: newerProblem.name,
description: newerProblem.description,
climbType: newerProblem.climbType,
difficulty: newerProblem.difficulty,
tags: newerProblem.tags,
location: newerProblem.location,
imagePaths: mergedImages.isEmpty ? nil : mergedImages,
isActive: newerProblem.isActive,
dateSet: newerProblem.dateSet,
notes: newerProblem.notes,
createdAt: newerProblem.createdAt,
updatedAt: newerProblem.updatedAt
)
} else {
// New problem from server
merged[serverProblem.id] = serverProblem
}
}
return Array(merged.values)
}
private func mergeSessions(local: [BackupClimbSession], server: [BackupClimbSession])
-> [BackupClimbSession]
{
var merged: [String: BackupClimbSession] = [:]
// Add all local sessions
for session in local {
merged[session.id] = session
}
// Add server sessions, replacing if newer
for serverSession in server {
if let localSession = merged[serverSession.id] {
// Keep the most recently updated
if isNewerThan(serverSession.updatedAt, localSession.updatedAt) {
merged[serverSession.id] = serverSession
}
} else {
// New session from server
merged[serverSession.id] = serverSession
}
}
return Array(merged.values)
}
private func mergeAttempts(local: [BackupAttempt], server: [BackupAttempt]) -> [BackupAttempt] {
var merged: [String: BackupAttempt] = [:]
// Add all local attempts
for attempt in local {
merged[attempt.id] = attempt
}
// Add server attempts, replacing if newer
for serverAttempt in server {
if let localAttempt = merged[serverAttempt.id] {
// Keep the most recently created (attempts don't typically get updated)
if isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) {
merged[serverAttempt.id] = serverAttempt
}
} else {
// New attempt from server
merged[serverAttempt.id] = serverAttempt
}
}
return Array(merged.values)
}
private func isNewerThan(_ dateString1: String, _ dateString2: String) -> Bool {
let formatter = ISO8601DateFormatter()
guard let date1 = formatter.date(from: dateString1),
let date2 = formatter.date(from: dateString2)
else {
return false
}
return date1 > date2
}
func disconnect() {
@@ -931,8 +779,6 @@ class SyncService: ObservableObject {
}
}
// Removed SyncTrigger enum - now using simple auto sync on any data change
enum SyncError: LocalizedError {
case notConfigured
case notConnected

View File

@@ -32,6 +32,9 @@ class ClimbingDataManager: ObservableObject {
// Sync service for automatic syncing
let syncService = SyncService()
// Published property to propagate sync state changes
@Published var isSyncing = false
private enum Keys {
static let gyms = "openclimb_gyms"
static let problems = "openclimb_problems"
@@ -67,6 +70,10 @@ class ClimbingDataManager: ObservableObject {
migrateImagePaths()
setupLiveActivityNotifications()
// Keep our published isSyncing in sync with syncService.isSyncing
syncService.$isSyncing
.assign(to: &$isSyncing)
Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
await performImageMaintenance()
@@ -206,6 +213,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState()
successMessage = "Gym added successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func updateGym(_ gym: Gym) {
@@ -215,6 +225,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState()
successMessage = "Gym updated successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
@@ -237,6 +250,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState()
successMessage = "Gym deleted successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func gym(withId id: UUID) -> Gym? {
@@ -261,6 +277,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState()
successMessage = "Problem updated successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
@@ -276,6 +295,9 @@ class ClimbingDataManager: ObservableObject {
problems.removeAll { $0.id == problem.id }
saveProblems()
DataStateManager.shared.updateDataState()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func problem(withId id: UUID) -> Problem? {
@@ -291,7 +313,7 @@ class ClimbingDataManager: ObservableObject {
}
func startSession(gymId: UUID, notes: String? = nil) {
// End any currently active session
if let currentActive = activeSession {
endSession(currentActive.id)
}
@@ -314,6 +336,9 @@ class ClimbingDataManager: ObservableObject {
for: newSession, gymName: gym.name)
}
}
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func endSession(_ sessionId: UUID) {
@@ -358,8 +383,11 @@ class ClimbingDataManager: ObservableObject {
successMessage = "Session updated successfully"
clearMessageAfterDelay()
// Update Live Activity when session updates
// Update Live Activity when session is updated
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
@@ -368,7 +396,7 @@ class ClimbingDataManager: ObservableObject {
attempts.removeAll { $0.sessionId == session.id }
saveAttempts()
// Remove from active session if it's the current one
// If this is the active session, clear it
if activeSession?.id == session.id {
activeSession = nil
saveActiveSession()
@@ -380,6 +408,12 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState()
successMessage = "Session deleted successfully"
clearMessageAfterDelay()
// Update Live Activity when session is deleted
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func session(withId id: UUID) -> ClimbSession? {
@@ -421,6 +455,9 @@ class ClimbingDataManager: ObservableObject {
// Update Live Activity when attempt is updated
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
@@ -433,6 +470,9 @@ class ClimbingDataManager: ObservableObject {
// Update Live Activity when attempt is deleted
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func attempts(forSession sessionId: UUID) -> [Attempt] {
@@ -476,7 +516,7 @@ class ClimbingDataManager: ObservableObject {
return gym(withId: mostUsedGymId)
}
func resetAllData() {
func resetAllData(showSuccessMessage: Bool = true) {
gyms.removeAll()
problems.removeAll()
sessions.removeAll()
@@ -490,9 +530,12 @@ class ClimbingDataManager: ObservableObject {
userDefaults.removeObject(forKey: Keys.activeSession)
DataStateManager.shared.reset()
if showSuccessMessage {
successMessage = "All data has been reset"
clearMessageAfterDelay()
}
}
func exportData() -> Data? {
do {
@@ -530,7 +573,7 @@ class ClimbingDataManager: ObservableObject {
}
}
func importData(from data: Data) throws {
func importData(from data: Data, showSuccessMessage: Bool = true) throws {
do {
let importResult = try ZipUtils.extractImportZip(data: data)
@@ -566,7 +609,7 @@ class ClimbingDataManager: ObservableObject {
try validateImportData(importData)
resetAllData()
resetAllData(showSuccessMessage: showSuccessMessage)
let updatedProblems = updateProblemImagePaths(
problems: importData.problems,
@@ -586,9 +629,11 @@ class ClimbingDataManager: ObservableObject {
// Update data state to current time since we just imported new data
DataStateManager.shared.updateDataState()
if showSuccessMessage {
successMessage =
"Data imported successfully with \(importResult.imagePathMapping.count) images"
clearMessageAfterDelay()
}
} catch {
setError("Import failed: \(error.localizedDescription)")
throw error

View File

@@ -20,6 +20,27 @@ struct AnalyticsView: View {
.padding()
}
.navigationTitle("Analytics")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
}
}
}
}
}

View File

@@ -15,7 +15,25 @@ struct GymsView: View {
}
.navigationTitle("Gyms")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
Button("Add") {
showingAddGym = true
}

View File

@@ -1,9 +1,5 @@
//
// LiveActivityDebugView.swift
// OpenClimb
//
// Created by Assistant on 2025-09-15.
//
import SwiftUI

View File

@@ -62,7 +62,25 @@ struct ProblemsView: View {
.navigationTitle("Problems")
.searchable(text: $searchText, prompt: "Search problems...")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
if !dataManager.gyms.isEmpty {
Button("Add") {
showingAddProblem = true

View File

@@ -17,7 +17,25 @@ struct SessionsView: View {
.navigationTitle("Sessions")
.navigationBarTitleDisplayMode(.automatic)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
if dataManager.gyms.isEmpty {
EmptyView()
} else if dataManager.activeSession == nil {

View File

@@ -22,6 +22,27 @@ struct SettingsView: View {
AppInfoSection()
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
}
}
.sheet(
item: Binding<SheetType?>(
get: { activeSheet },
@@ -436,6 +457,7 @@ struct SyncSection: View {
.foregroundColor(.red)
.padding(.leading, 24)
}
}
}
.sheet(isPresented: $showingSyncSettings) {

View File

@@ -1,12 +1,8 @@
//
// AppIntent.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import WidgetKit
import AppIntents
import WidgetKit
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Configuration" }

View File

@@ -1,9 +1,5 @@
//
// SessionStatusLive.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import SwiftUI
import WidgetKit

View File

@@ -1,12 +1,8 @@
//
// SessionStatusLiveBundle.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import WidgetKit
import SwiftUI
import WidgetKit
@main
struct SessionStatusLiveBundle: WidgetBundle {

View File

@@ -1,9 +1,5 @@
//
// SessionStatusLiveControl.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import AppIntents
import SwiftUI
@@ -43,7 +39,8 @@ extension SessionStatusLiveControl {
func currentValue(configuration: TimerConfiguration) async throws -> Value {
let isRunning = true // Check if the timer is running
return SessionStatusLiveControl.Value(isRunning: isRunning, name: configuration.timerName)
return SessionStatusLiveControl.Value(
isRunning: isRunning, name: configuration.timerName)
}
}
}

View File

@@ -1,9 +1,5 @@
//
// SessionStatusLiveLiveActivity.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import ActivityKit
import SwiftUI

View File

@@ -1,303 +0,0 @@
# OpenClimb Sync Server Deployment Guide
This guide covers deploying the OpenClimb Sync Server using the automated Docker build and deployment system.
## Overview
The sync server is automatically built into a Docker container via GitHub Actions and can be deployed to any Docker-compatible environment.
## Prerequisites
- Docker and Docker Compose installed
- Access to the container registry (configured in GitHub secrets)
- Basic understanding of Docker deployments
## Quick Start
### 1. Automated Deployment (Recommended)
```bash
# Clone the repository
git clone <your-repo-url>
cd OpenClimb/sync-server
# Run the deployment script
./deploy.sh
```
The script will:
- Create necessary directories
- Pull the latest container image
- Stop any existing containers
- Start the new container
- Verify deployment success
### 2. Manual Deployment
```bash
# Pull the latest image
docker pull your-registry.com/username/openclimb-sync-server:latest
# Create environment file
cp .env.example .env.prod
# Edit .env.prod with your configuration
# Deploy with docker-compose
docker-compose -f docker-compose.prod.yml up -d
```
## Configuration
### Environment Variables
Create a `.env.prod` file with the following variables:
```bash
# Container registry settings
REPO_HOST=your-registry.example.com
REPO_OWNER=your-username
# Server configuration
AUTH_TOKEN=your-secure-auth-token-here-make-it-long-and-random
PORT=8080
# Optional: Custom domain (for Traefik)
TRAEFIK_HOST=sync.openclimb.example.com
```
### Required Secrets (GitHub)
Configure these secrets in your GitHub repository settings:
- `REPO_HOST`: Your container registry hostname
- `DEPLOY_TOKEN`: Authentication token for the registry
## Container Build Process
The GitHub Action (`sync-server-deploy.yml`) automatically:
1. **Triggers on:**
- Push to `main` branch (when sync-server files change)
- Pull requests to `main` branch
2. **Build Process:**
- Uses multi-stage Docker build
- Compiles Go binary in builder stage
- Creates minimal Alpine-based runtime image
- Pushes to container registry with tags:
- `latest` (always points to newest)
- `<commit-sha>` (specific version)
3. **Caching:**
- Uses GitHub Actions cache for faster builds
- Incremental builds when possible
## Deployment Options
### Option 1: Simple Docker Run
```bash
docker run -d \
--name openclimb-sync-server \
-p 8080:8080 \
-v $(pwd)/data:/root/data \
-e AUTH_TOKEN=your-token-here \
your-registry.com/username/openclimb-sync-server:latest
```
### Option 2: Docker Compose (Recommended)
```bash
docker-compose -f docker-compose.prod.yml up -d
```
### Option 3: Kubernetes
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: openclimb-sync-server
spec:
replicas: 1
selector:
matchLabels:
app: openclimb-sync-server
template:
metadata:
labels:
app: openclimb-sync-server
spec:
containers:
- name: sync-server
image: your-registry.com/username/openclimb-sync-server:latest
ports:
- containerPort: 8080
env:
- name: AUTH_TOKEN
valueFrom:
secretKeyRef:
name: openclimb-secrets
key: auth-token
volumeMounts:
- name: data-volume
mountPath: /root/data
volumes:
- name: data-volume
persistentVolumeClaim:
claimName: openclimb-data
```
## Data Persistence
The sync server stores data in `/root/data` inside the container. **Always mount a volume** to preserve data:
```bash
# Local directory mounting
-v $(pwd)/data:/root/data
# Named volume (recommended for production)
-v openclimb-data:/root/data
```
### Data Structure
```
data/
├── climb_data.json # Main sync data
├── images/ # Uploaded images
│ ├── problem_*.jpg
│ └── ...
└── logs/ # Server logs (optional)
```
## Monitoring and Maintenance
### Health Check
```bash
curl http://localhost:8080/health
```
### View Logs
```bash
# Docker Compose
docker-compose -f docker-compose.prod.yml logs -f
# Direct Docker
docker logs -f openclimb-sync-server
```
### Update to Latest Version
```bash
# Using deploy script
./deploy.sh
# Manual update
docker-compose -f docker-compose.prod.yml pull
docker-compose -f docker-compose.prod.yml up -d
```
## Reverse Proxy Setup (Optional)
### Nginx
```nginx
server {
listen 80;
server_name sync.openclimb.example.com;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Traefik (Labels included in docker-compose.prod.yml)
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.openclimb-sync.rule=Host(`sync.openclimb.example.com`)"
- "traefik.http.routers.openclimb-sync.tls.certresolver=letsencrypt"
```
## Security Considerations
1. **AUTH_TOKEN**: Use a long, random token (32+ characters)
2. **HTTPS**: Always use HTTPS in production (via reverse proxy)
3. **Firewall**: Only expose port 8080 to your reverse proxy, not publicly
4. **Updates**: Regularly update to the latest container image
5. **Backups**: Regularly backup the `data/` directory
## Troubleshooting
### Container Won't Start
```bash
# Check logs
docker logs openclimb-sync-server
# Common issues:
# - Missing AUTH_TOKEN environment variable
# - Port 8080 already in use
# - Insufficient permissions on data directory
```
### Sync Fails from Mobile Apps
```bash
# Verify server is accessible
curl -H "Authorization: Bearer your-token" http://your-server:8080/sync
# Check server logs for authentication errors
docker logs openclimb-sync-server | grep "401\|403"
```
### Image Upload Issues
```bash
# Check disk space
df -h
# Verify data directory permissions
ls -la data/
```
## Performance Tuning
For high-load deployments:
```yaml
# docker-compose.prod.yml
services:
openclimb-sync-server:
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
```
## Backup Strategy
```bash
#!/bin/bash
# backup.sh - Run daily via cron
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backups/openclimb"
# Create backup directory
mkdir -p "$BACKUP_DIR"
# Backup data directory
tar -czf "$BACKUP_DIR/openclimb_data_$DATE.tar.gz" \
-C /path/to/sync-server data/
# Keep only last 30 days
find "$BACKUP_DIR" -name "openclimb_data_*.tar.gz" -mtime +30 -delete
```
## Support
- **Issues**: Create an issue in the GitHub repository
- **Documentation**: Check the main OpenClimb README
- **Logs**: Always

View File

@@ -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

View File

@@ -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: {}

View File

@@ -1,31 +0,0 @@
#!/bin/bash
# OpenClimb Sync Server Runner
set -e
# Default values
AUTH_TOKEN=${AUTH_TOKEN:-}
PORT=${PORT:-8080}
DATA_FILE=${DATA_FILE:-./data/climb_data.json}
# Check if AUTH_TOKEN is set
if [ -z "$AUTH_TOKEN" ]; then
echo "Error: AUTH_TOKEN environment variable must be set"
echo "Usage: AUTH_TOKEN=your-secret-token ./run.sh"
echo "Or: export AUTH_TOKEN=your-secret-token && ./run.sh"
exit 1
fi
# Create data directory if it doesn't exist
mkdir -p "$(dirname "$DATA_FILE")"
# Build and run
echo "Building OpenClimb sync server..."
go build -o sync-server .
echo "Starting server on port $PORT"
echo "Data will be stored in: $DATA_FILE"
echo "Images will be stored in: ${IMAGES_DIR:-./data/images}"
echo "Use Authorization: Bearer $AUTH_TOKEN in your requests"
echo ""
exec ./sync-server