Compare commits
10 Commits
ANDROID_1.
...
ANDROID_1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
1980ff802a
|
|||
|
73c4e41cac
|
|||
|
3ccd0ec7ea
|
|||
|
8fbb40d453
|
|||
| 016d427ff8 | |||
| 7cbb333287 | |||
| 2160ce30bd | |||
| de21e3270e | |||
|
f6e1cdcb5b
|
|||
|
fab587c351
|
14
README.md
14
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -12,55 +12,48 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.openclimb.R
|
||||||
import com.atridad.openclimb.data.model.Gym
|
import com.atridad.openclimb.data.model.Gym
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun GymsScreen(
|
fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Unit) {
|
||||||
viewModel: ClimbViewModel,
|
|
||||||
onNavigateToGymDetail: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "OpenClimb Logo",
|
contentDescription = "OpenClimb Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Climbing Gyms",
|
text = "Climbing Gyms",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
if (gyms.isEmpty()) {
|
if (gyms.isEmpty()) {
|
||||||
EmptyStateMessage(
|
EmptyStateMessage(
|
||||||
title = "No Gyms Added",
|
title = "No Gyms Added",
|
||||||
message = "Add your favorite climbing gyms to start tracking your progress!",
|
message = "Add your favorite climbing gyms to start tracking your progress!",
|
||||||
onActionClick = { },
|
onActionClick = {},
|
||||||
actionText = ""
|
actionText = ""
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(gyms) { gym ->
|
items(gyms) { gym ->
|
||||||
GymCard(
|
GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) })
|
||||||
gym = gym,
|
|
||||||
onClick = { onNavigateToGymDetail(gym.id) }
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,31 +63,21 @@ fun GymsScreen(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun GymCard(
|
fun GymCard(gym: Gym, onClick: () -> Unit) {
|
||||||
gym: Gym,
|
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||||
onClick: () -> Unit
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = gym.name,
|
text = gym.name,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
|
|
||||||
gym.location?.let { location ->
|
gym.location?.let { location ->
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = location,
|
text = location,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,11 +86,9 @@ fun GymCard(
|
|||||||
Row {
|
Row {
|
||||||
gym.supportedClimbTypes.forEach { climbType ->
|
gym.supportedClimbTypes.forEach { climbType ->
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = { },
|
onClick = {},
|
||||||
label = {
|
label = { Text(climbType.getDisplayName()) },
|
||||||
Text(climbType.getDisplayName())
|
modifier = Modifier.padding(end = 4.dp)
|
||||||
},
|
|
||||||
modifier = Modifier.padding(end = 4.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,9 +96,10 @@ fun GymCard(
|
|||||||
if (gym.difficultySystems.isNotEmpty()) {
|
if (gym.difficultySystems.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
text =
|
||||||
style = MaterialTheme.typography.bodySmall,
|
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +107,10 @@ fun GymCard(
|
|||||||
if (notes.isNotBlank()) {
|
if (notes.isNotBlank()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = notes,
|
text = notes,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2
|
maxLines = 2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -20,16 +20,14 @@ import com.atridad.openclimb.R
|
|||||||
import com.atridad.openclimb.data.model.ClimbSession
|
import com.atridad.openclimb.data.model.ClimbSession
|
||||||
import com.atridad.openclimb.data.model.SessionStatus
|
import com.atridad.openclimb.data.model.SessionStatus
|
||||||
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionsScreen(
|
fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String) -> Unit) {
|
||||||
viewModel: ClimbViewModel,
|
|
||||||
onNavigateToSessionDetail: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sessions by viewModel.sessions.collectAsState()
|
val sessions by viewModel.sessions.collectAsState()
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
@@ -38,47 +36,37 @@ fun SessionsScreen(
|
|||||||
|
|
||||||
// Filter out active sessions from regular session list
|
// Filter out active sessions from regular session list
|
||||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||||
val activeSessionGym = activeSession?.let { session ->
|
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
||||||
gyms.find { it.id == session.gymId }
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "OpenClimb Logo",
|
contentDescription = "OpenClimb Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Climbing Sessions",
|
text = "Climbing Sessions",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Active session banner
|
// Active session banner
|
||||||
ActiveSessionBanner(
|
ActiveSessionBanner(
|
||||||
activeSession = activeSession,
|
activeSession = activeSession,
|
||||||
gym = activeSessionGym,
|
gym = activeSessionGym,
|
||||||
onSessionClick = {
|
onSessionClick = { activeSession?.let { onNavigateToSessionDetail(it.id) } },
|
||||||
activeSession?.let { onNavigateToSessionDetail(it.id) }
|
onEndSession = { activeSession?.let { viewModel.endSession(context, it.id) } }
|
||||||
},
|
|
||||||
onEndSession = {
|
|
||||||
activeSession?.let {
|
|
||||||
viewModel.endSession(context, it.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (activeSession != null) {
|
if (activeSession != null) {
|
||||||
@@ -87,18 +75,21 @@ fun SessionsScreen(
|
|||||||
|
|
||||||
if (completedSessions.isEmpty() && activeSession == null) {
|
if (completedSessions.isEmpty() && activeSession == null) {
|
||||||
EmptyStateMessage(
|
EmptyStateMessage(
|
||||||
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
||||||
message = if (gyms.isEmpty()) "Add a gym first to start tracking your climbing sessions!" else "Start your first climbing session!",
|
message =
|
||||||
onActionClick = { },
|
if (gyms.isEmpty())
|
||||||
actionText = ""
|
"Add a gym first to start tracking your climbing sessions!"
|
||||||
|
else "Start your first climbing session!",
|
||||||
|
onActionClick = {},
|
||||||
|
actionText = ""
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(completedSessions) { session ->
|
items(completedSessions) { session ->
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
||||||
onClick = { onNavigateToSessionDetail(session.id) }
|
onClick = { onNavigateToSessionDetail(session.id) }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
@@ -114,30 +105,27 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
colors =
|
||||||
.padding(16.dp),
|
CardDefaults.cardColors(
|
||||||
colors = CardDefaults.cardColors(
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
),
|
||||||
),
|
shape = RoundedCornerShape(12.dp)
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
verticalAlignment = Alignment.CenterVertically
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.CheckCircle,
|
Icons.Default.CheckCircle,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = message,
|
text = message,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,30 +138,27 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
colors =
|
||||||
.padding(16.dp),
|
CardDefaults.cardColors(
|
||||||
colors = CardDefaults.cardColors(
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
),
|
||||||
),
|
shape = RoundedCornerShape(12.dp)
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
verticalAlignment = Alignment.CenterVertically
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Warning,
|
Icons.Default.Warning,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = error,
|
text = error,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,33 +167,22 @@ fun SessionsScreen(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionCard(
|
fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
|
||||||
session: ClimbSession,
|
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||||
gymName: String,
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = gymName,
|
text = gymName,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatDate(session.date),
|
text = formatDate(session.date),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,8 +190,8 @@ fun SessionCard(
|
|||||||
|
|
||||||
session.duration?.let { duration ->
|
session.duration?.let { duration ->
|
||||||
Text(
|
Text(
|
||||||
text = "Duration: $duration minutes",
|
text = "Duration: $duration minutes",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,10 +199,10 @@ fun SessionCard(
|
|||||||
if (notes.isNotBlank()) {
|
if (notes.isNotBlank()) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = notes,
|
text = notes,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2
|
maxLines = 2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,38 +212,36 @@ fun SessionCard(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EmptyStateMessage(
|
fun EmptyStateMessage(
|
||||||
title: String,
|
title: String,
|
||||||
message: String,
|
message: String,
|
||||||
onActionClick: () -> Unit,
|
onActionClick: () -> Unit,
|
||||||
actionText: String
|
actionText: String
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = message,
|
text = message,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
if (actionText.isNotEmpty()) {
|
if (actionText.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Button(onClick = onActionClick) {
|
Button(onClick = onActionClick) { Text(actionText) }
|
||||||
Text(actionText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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,8 +530,11 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
userDefaults.removeObject(forKey: Keys.activeSession)
|
userDefaults.removeObject(forKey: Keys.activeSession)
|
||||||
|
|
||||||
DataStateManager.shared.reset()
|
DataStateManager.shared.reset()
|
||||||
successMessage = "All data has been reset"
|
|
||||||
clearMessageAfterDelay()
|
if showSuccessMessage {
|
||||||
|
successMessage = "All data has been reset"
|
||||||
|
clearMessageAfterDelay()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func exportData() -> Data? {
|
func exportData() -> Data? {
|
||||||
@@ -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()
|
||||||
|
|
||||||
successMessage =
|
if showSuccessMessage {
|
||||||
"Data imported successfully with \(importResult.imagePathMapping.count) images"
|
successMessage =
|
||||||
clearMessageAfterDelay()
|
"Data imported successfully with \(importResult.imagePathMapping.count) images"
|
||||||
|
clearMessageAfterDelay()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError("Import failed: \(error.localizedDescription)")
|
setError("Import failed: \(error.localizedDescription)")
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
//
|
//
|
||||||
// LiveActivityDebugView.swift
|
// LiveActivityDebugView.swift
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by Assistant on 2025-09-15.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -42,8 +38,9 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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: {}
|
||||||
|
|||||||
31
sync/run.sh
31
sync/run.sh
@@ -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
|
|
||||||
Reference in New Issue
Block a user