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 ## Versions
- Android:1.4.2 - Android: 1.7.0
- iOS: 1.0.1 - 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 ## Download

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,21 +12,15 @@ 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,
@@ -41,8 +35,10 @@ fun GymsScreen(
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))
@@ -57,10 +53,7 @@ fun GymsScreen(
} 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,19 +63,9 @@ 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,
@@ -104,9 +87,7 @@ fun GymCard(
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,7 +96,8 @@ 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 =
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant 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.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,15 +36,9 @@ 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,
@@ -61,8 +53,10 @@ fun SessionsScreen(
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))
@@ -71,14 +65,8 @@ fun SessionsScreen(
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) {
@@ -88,7 +76,10 @@ 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 =
if (gyms.isEmpty())
"Add a gym first to start tracking your climbing sessions!"
else "Start your first climbing session!",
onActionClick = {}, onActionClick = {},
actionText = "" actionText = ""
) )
@@ -114,18 +105,15 @@ 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()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
@@ -150,18 +138,15 @@ 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()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
@@ -182,20 +167,9 @@ 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
@@ -267,9 +241,7 @@ fun EmptyStateMessage(
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

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

View File

@@ -1,6 +1,5 @@
import Combine import Combine
import Foundation import Foundation
import UIKit
@MainActor @MainActor
class SyncService: ObservableObject { class SyncService: ObservableObject {
@@ -455,7 +454,7 @@ class SyncService: ObservableObject {
let zipData = try createMinimalZipFromBackup(updatedBackup) let zipData = try createMinimalZipFromBackup(updatedBackup)
// Use existing import method which properly handles data restoration // 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 // Update local data state to match imported data timestamp
DataStateManager.shared.setLastModified(backup.exportedAt) DataStateManager.shared.setLastModified(backup.exportedAt)
@@ -735,180 +734,29 @@ class SyncService: ObservableObject {
} }
func triggerAutoSync(dataManager: ClimbingDataManager) { 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 { Task {
do { do {
try await syncWithServer(dataManager: dataManager) try await syncWithServer(dataManager: dataManager)
} catch { } catch {
print("Auto-sync failed: \(error)") await MainActor.run {
// Don't show UI errors for auto-sync failures 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() { func disconnect() {
@@ -931,8 +779,6 @@ class SyncService: ObservableObject {
} }
} }
// Removed SyncTrigger enum - now using simple auto sync on any data change
enum SyncError: LocalizedError { enum SyncError: LocalizedError {
case notConfigured case notConfigured
case notConnected case notConnected

View File

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

View File

@@ -20,6 +20,27 @@ struct AnalyticsView: View {
.padding() .padding()
} }
.navigationTitle("Analytics") .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") .navigationTitle("Gyms")
.toolbar { .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") { Button("Add") {
showingAddGym = true showingAddGym = true
} }

View File

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

View File

@@ -62,7 +62,25 @@ struct ProblemsView: View {
.navigationTitle("Problems") .navigationTitle("Problems")
.searchable(text: $searchText, prompt: "Search problems...") .searchable(text: $searchText, prompt: "Search problems...")
.toolbar { .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 { if !dataManager.gyms.isEmpty {
Button("Add") { Button("Add") {
showingAddProblem = true showingAddProblem = true

View File

@@ -17,7 +17,25 @@ struct SessionsView: View {
.navigationTitle("Sessions") .navigationTitle("Sessions")
.navigationBarTitleDisplayMode(.automatic) .navigationBarTitleDisplayMode(.automatic)
.toolbar { .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 { if dataManager.gyms.isEmpty {
EmptyView() EmptyView()
} else if dataManager.activeSession == nil { } else if dataManager.activeSession == nil {

View File

@@ -22,6 +22,27 @@ struct SettingsView: View {
AppInfoSection() AppInfoSection()
} }
.navigationTitle("Settings") .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( .sheet(
item: Binding<SheetType?>( item: Binding<SheetType?>(
get: { activeSheet }, get: { activeSheet },
@@ -436,6 +457,7 @@ struct SyncSection: View {
.foregroundColor(.red) .foregroundColor(.red)
.padding(.leading, 24) .padding(.leading, 24)
} }
} }
} }
.sheet(isPresented: $showingSyncSettings) { .sheet(isPresented: $showingSyncSettings) {

View File

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

View File

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

View File

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

View File

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

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