Compare commits
10 Commits
SYNC_1.0.0
...
IOS_1.2.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
|
||||
|
||||
- Android:1.4.2
|
||||
- iOS: 1.0.1
|
||||
- Android: 1.7.0
|
||||
- iOS: 1.2.0
|
||||
- Sync: 1.0.0
|
||||
|
||||
## Stability
|
||||
- Clients: 8/10
|
||||
- Server: 10/10
|
||||
- Schema: 9/10 (No more breaking changes)
|
||||
|
||||
## Self-Hosted Sync Server
|
||||
|
||||
You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up. See the server docker-compose file for an example.
|
||||
|
||||
## Download
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.atridad.openclimb"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 28
|
||||
versionName = "1.7.0"
|
||||
versionCode = 29
|
||||
versionName = "1.7.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import com.atridad.openclimb.utils.ImageUtils
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -27,12 +26,12 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import androidx.core.content.edit
|
||||
|
||||
class SyncService(private val context: Context, private val repository: ClimbRepository) {
|
||||
|
||||
@@ -91,13 +90,13 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
var serverURL: String
|
||||
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
|
||||
set(value) {
|
||||
sharedPreferences.edit().putString(Keys.SERVER_URL, value).apply()
|
||||
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
|
||||
}
|
||||
|
||||
var authToken: String
|
||||
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
|
||||
set(value) {
|
||||
sharedPreferences.edit().putString(Keys.AUTH_TOKEN, value).apply()
|
||||
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
|
||||
}
|
||||
|
||||
val isConfigured: Boolean
|
||||
@@ -116,7 +115,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
|
||||
// Register auto-sync callback with repository
|
||||
repository.setAutoSyncCallback {
|
||||
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||
triggerAutoSync()
|
||||
}
|
||||
}
|
||||
@@ -491,21 +490,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
return imagePathMapping
|
||||
}
|
||||
|
||||
private suspend fun syncImagesToServer() {
|
||||
val allProblems = repository.getAllProblems().first()
|
||||
val backup =
|
||||
ClimbDataBackup(
|
||||
exportedAt = DateFormatUtils.nowISO8601(),
|
||||
version = "2.0",
|
||||
formatVersion = "2.0",
|
||||
gyms = emptyList(),
|
||||
problems = allProblems.map { BackupProblem.fromProblem(it) },
|
||||
sessions = emptyList(),
|
||||
attempts = emptyList()
|
||||
)
|
||||
syncImagesForBackup(backup)
|
||||
}
|
||||
|
||||
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
|
||||
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems")
|
||||
|
||||
@@ -626,7 +610,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
val updatedProblem =
|
||||
if (imagePathMapping.isNotEmpty()) {
|
||||
val newImagePaths =
|
||||
backupProblem.imagePaths?.mapNotNull { oldPath ->
|
||||
backupProblem.imagePaths?.map { oldPath ->
|
||||
// Extract filename and check mapping
|
||||
val filename = oldPath.substringAfterLast('/')
|
||||
// Use mapped full path or fallback to consistent naming
|
||||
@@ -696,11 +680,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts milliseconds to ISO8601 timestamp */
|
||||
private fun millisToISO8601(millis: Long): String {
|
||||
return DateFormatUtils.millisToISO8601(millis)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes existing image paths in the database to include the proper directory structure. This
|
||||
* corrects paths like "problem_abc_0.jpg" to "problem_images/problem_abc_0.jpg"
|
||||
@@ -833,141 +812,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
}
|
||||
|
||||
// DEPRECATED: Complex merge logic replaced with simple timestamp-based sync
|
||||
// These methods are no longer used but kept for reference
|
||||
@Deprecated("Use simple timestamp-based sync instead")
|
||||
private fun performIntelligentMerge(
|
||||
local: ClimbDataBackup,
|
||||
server: ClimbDataBackup
|
||||
): ClimbDataBackup {
|
||||
Log.d(TAG, "Merging data - preserving all entities to prevent data loss")
|
||||
|
||||
val mergedGyms = mergeGyms(local.gyms, server.gyms)
|
||||
val mergedProblems = mergeProblems(local.problems, server.problems)
|
||||
val mergedSessions = mergeSessions(local.sessions, server.sessions)
|
||||
val mergedAttempts = mergeAttempts(local.attempts, server.attempts)
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Merge results: gyms=${mergedGyms.size}, problems=${mergedProblems.size}, " +
|
||||
"sessions=${mergedSessions.size}, attempts=${mergedAttempts.size}"
|
||||
)
|
||||
|
||||
return ClimbDataBackup(
|
||||
exportedAt = DateFormatUtils.nowISO8601(),
|
||||
version = "2.0",
|
||||
formatVersion = "2.0",
|
||||
gyms = mergedGyms,
|
||||
problems = mergedProblems,
|
||||
sessions = mergedSessions,
|
||||
attempts = mergedAttempts
|
||||
)
|
||||
}
|
||||
|
||||
private fun mergeGyms(local: List<BackupGym>, server: List<BackupGym>): List<BackupGym> {
|
||||
val merged = mutableMapOf<String, BackupGym>()
|
||||
|
||||
// Add all local gyms
|
||||
local.forEach { gym -> merged[gym.id] = gym }
|
||||
|
||||
// Add server gyms, preferring newer updates
|
||||
server.forEach { serverGym ->
|
||||
val localGym = merged[serverGym.id]
|
||||
if (localGym == null || isNewerThan(serverGym.updatedAt, localGym.updatedAt)) {
|
||||
merged[serverGym.id] = serverGym
|
||||
}
|
||||
}
|
||||
|
||||
return merged.values.toList()
|
||||
}
|
||||
|
||||
private fun mergeProblems(
|
||||
local: List<BackupProblem>,
|
||||
server: List<BackupProblem>
|
||||
): List<BackupProblem> {
|
||||
val merged = mutableMapOf<String, BackupProblem>()
|
||||
|
||||
// Add all local problems
|
||||
local.forEach { problem -> merged[problem.id] = problem }
|
||||
|
||||
// Add server problems, preferring newer updates
|
||||
server.forEach { serverProblem ->
|
||||
val localProblem = merged[serverProblem.id]
|
||||
if (localProblem == null || isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
|
||||
) {
|
||||
// Merge image paths to preserve all images
|
||||
val allImagePaths = mutableSetOf<String>()
|
||||
localProblem?.imagePaths?.let { allImagePaths.addAll(it) }
|
||||
serverProblem.imagePaths?.let { allImagePaths.addAll(it) }
|
||||
|
||||
merged[serverProblem.id] =
|
||||
serverProblem.withUpdatedImagePaths(allImagePaths.toList())
|
||||
}
|
||||
}
|
||||
|
||||
return merged.values.toList()
|
||||
}
|
||||
|
||||
private fun mergeSessions(
|
||||
local: List<BackupClimbSession>,
|
||||
server: List<BackupClimbSession>
|
||||
): List<BackupClimbSession> {
|
||||
val merged = mutableMapOf<String, BackupClimbSession>()
|
||||
|
||||
// Add all local sessions
|
||||
local.forEach { session -> merged[session.id] = session }
|
||||
|
||||
// Add server sessions, preferring newer updates
|
||||
server.forEach { serverSession ->
|
||||
val localSession = merged[serverSession.id]
|
||||
if (localSession == null || isNewerThan(serverSession.updatedAt, localSession.updatedAt)
|
||||
) {
|
||||
merged[serverSession.id] = serverSession
|
||||
}
|
||||
}
|
||||
|
||||
return merged.values.toList()
|
||||
}
|
||||
|
||||
private fun mergeAttempts(
|
||||
local: List<BackupAttempt>,
|
||||
server: List<BackupAttempt>
|
||||
): List<BackupAttempt> {
|
||||
val merged = mutableMapOf<String, BackupAttempt>()
|
||||
|
||||
// Add all local attempts
|
||||
local.forEach { attempt -> merged[attempt.id] = attempt }
|
||||
|
||||
// Add server attempts, preferring newer updates
|
||||
server.forEach { serverAttempt ->
|
||||
val localAttempt = merged[serverAttempt.id]
|
||||
if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt)
|
||||
) {
|
||||
merged[serverAttempt.id] = serverAttempt
|
||||
}
|
||||
}
|
||||
|
||||
return merged.values.toList()
|
||||
}
|
||||
|
||||
private fun isNewerThan(dateString1: String, dateString2: String): Boolean {
|
||||
return try {
|
||||
// Try parsing as instant first
|
||||
val date1 = Instant.parse(dateString1)
|
||||
val date2 = Instant.parse(dateString2)
|
||||
date1.isAfter(date2)
|
||||
} catch (e: Exception) {
|
||||
// Fallback to string comparison
|
||||
dateString1 > dateString2
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
_isConnected.value = false
|
||||
sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply()
|
||||
_syncError.value = null
|
||||
}
|
||||
|
||||
fun clearConfiguration() {
|
||||
serverURL = ""
|
||||
authToken = ""
|
||||
@@ -980,14 +824,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
}
|
||||
|
||||
// Removed SyncTrigger enum - now using simple auto sync on any data change
|
||||
|
||||
sealed class SyncException(message: String) : Exception(message) {
|
||||
object NotConfigured :
|
||||
SyncException("Sync is not configured. Please set server URL and auth token.")
|
||||
object NotConnected : SyncException("Not connected to server. Please test connection first.")
|
||||
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
|
||||
object InvalidURL : SyncException("Invalid server URL.")
|
||||
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
|
||||
data class InvalidResponse(val details: String) :
|
||||
SyncException("Invalid server response: $details")
|
||||
|
||||
@@ -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.ui.components.BarChart
|
||||
import com.atridad.openclimb.ui.components.BarChartDataPoint
|
||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -45,8 +46,10 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
||||
Text(
|
||||
text = "Analytics",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +200,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
},
|
||||
modifier =
|
||||
Modifier.menuAnchor(
|
||||
type = MenuAnchorType.PrimaryNotEditable,
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true
|
||||
)
|
||||
.width(120.dp),
|
||||
@@ -253,7 +256,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
||||
DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||
)
|
||||
attemptDate.isAfter(sevenDaysAgo)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// If date parsing fails, include the data point
|
||||
true
|
||||
}
|
||||
@@ -425,9 +428,7 @@ fun calculateGradeDistribution(
|
||||
} else {
|
||||
gradeDistribution[key] =
|
||||
GradeDistributionDataPoint(
|
||||
date =
|
||||
attempt.timestamp
|
||||
.toString(), // Use attempt timestamp for filtering
|
||||
date = attempt.timestamp,
|
||||
grade = problem.difficulty.grade,
|
||||
gradeNumeric =
|
||||
gradeToNumeric(
|
||||
|
||||
@@ -12,55 +12,48 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.R
|
||||
import com.atridad.openclimb.data.model.Gym
|
||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GymsScreen(
|
||||
viewModel: ClimbViewModel,
|
||||
onNavigateToGymDetail: (String) -> Unit
|
||||
) {
|
||||
fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Unit) {
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "OpenClimb Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "OpenClimb Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Climbing Gyms",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
text = "Climbing Gyms",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
|
||||
if (gyms.isEmpty()) {
|
||||
EmptyStateMessage(
|
||||
title = "No Gyms Added",
|
||||
message = "Add your favorite climbing gyms to start tracking your progress!",
|
||||
onActionClick = { },
|
||||
actionText = ""
|
||||
title = "No Gyms Added",
|
||||
message = "Add your favorite climbing gyms to start tracking your progress!",
|
||||
onActionClick = {},
|
||||
actionText = ""
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(gyms) { gym ->
|
||||
GymCard(
|
||||
gym = gym,
|
||||
onClick = { onNavigateToGymDetail(gym.id) }
|
||||
)
|
||||
GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) })
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
@@ -70,65 +63,54 @@ fun GymsScreen(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GymCard(
|
||||
gym: Gym,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
fun GymCard(gym: Gym, onClick: () -> Unit) {
|
||||
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Text(
|
||||
text = gym.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
text = gym.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
|
||||
gym.location?.let { location ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
text = location,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
|
||||
Row {
|
||||
gym.supportedClimbTypes.forEach { climbType ->
|
||||
AssistChip(
|
||||
onClick = { },
|
||||
label = {
|
||||
Text(climbType.getDisplayName())
|
||||
},
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
onClick = {},
|
||||
label = { Text(climbType.getDisplayName()) },
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (gym.difficultySystems.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
text =
|
||||
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
gym.notes?.let { notes ->
|
||||
if (notes.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = notes,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
text = notes,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.atridad.openclimb.data.model.Gym
|
||||
import com.atridad.openclimb.data.model.Problem
|
||||
import com.atridad.openclimb.ui.components.FullscreenImageViewer
|
||||
import com.atridad.openclimb.ui.components.ImageDisplay
|
||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -58,10 +59,12 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Problems & Routes",
|
||||
text = "Climbing Problems",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -20,160 +20,145 @@ import com.atridad.openclimb.R
|
||||
import com.atridad.openclimb.data.model.ClimbSession
|
||||
import com.atridad.openclimb.data.model.SessionStatus
|
||||
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SessionsScreen(
|
||||
viewModel: ClimbViewModel,
|
||||
onNavigateToSessionDetail: (String) -> Unit
|
||||
) {
|
||||
fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val sessions by viewModel.sessions.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
val activeSession by viewModel.activeSession.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
|
||||
// Filter out active sessions from regular session list
|
||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||
val activeSessionGym = activeSession?.let { session ->
|
||||
gyms.find { it.id == session.gymId }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "OpenClimb Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "OpenClimb Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Climbing Sessions",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
text = "Climbing Sessions",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
|
||||
// Active session banner
|
||||
ActiveSessionBanner(
|
||||
activeSession = activeSession,
|
||||
gym = activeSessionGym,
|
||||
onSessionClick = {
|
||||
activeSession?.let { onNavigateToSessionDetail(it.id) }
|
||||
},
|
||||
onEndSession = {
|
||||
activeSession?.let {
|
||||
viewModel.endSession(context, it.id)
|
||||
}
|
||||
}
|
||||
activeSession = activeSession,
|
||||
gym = activeSessionGym,
|
||||
onSessionClick = { activeSession?.let { onNavigateToSessionDetail(it.id) } },
|
||||
onEndSession = { activeSession?.let { viewModel.endSession(context, it.id) } }
|
||||
)
|
||||
|
||||
|
||||
if (activeSession != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
|
||||
if (completedSessions.isEmpty() && activeSession == null) {
|
||||
EmptyStateMessage(
|
||||
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
||||
message = if (gyms.isEmpty()) "Add a gym first to start tracking your climbing sessions!" else "Start your first climbing session!",
|
||||
onActionClick = { },
|
||||
actionText = ""
|
||||
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
||||
message =
|
||||
if (gyms.isEmpty())
|
||||
"Add a gym first to start tracking your climbing sessions!"
|
||||
else "Start your first climbing session!",
|
||||
onActionClick = {},
|
||||
actionText = ""
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(completedSessions) { session ->
|
||||
SessionCard(
|
||||
session = session,
|
||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
||||
onClick = { onNavigateToSessionDetail(session.id) }
|
||||
session = session,
|
||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
||||
onClick = { onNavigateToSessionDetail(session.id) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Show UI state messages and errors
|
||||
uiState.message?.let { message ->
|
||||
LaunchedEffect(message) {
|
||||
kotlinx.coroutines.delay(5000)
|
||||
viewModel.clearMessage()
|
||||
}
|
||||
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
uiState.error?.let { error ->
|
||||
LaunchedEffect(error) {
|
||||
kotlinx.coroutines.delay(5000)
|
||||
viewModel.clearError()
|
||||
}
|
||||
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -182,53 +167,42 @@ fun SessionsScreen(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SessionCard(
|
||||
session: ClimbSession,
|
||||
gymName: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
|
||||
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = gymName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
text = gymName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = formatDate(session.date),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
text = formatDate(session.date),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
|
||||
session.duration?.let { duration ->
|
||||
Text(
|
||||
text = "Duration: $duration minutes",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
text = "Duration: $duration minutes",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
session.notes?.let { notes ->
|
||||
if (notes.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = notes,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
text = notes,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -238,38 +212,36 @@ fun SessionCard(
|
||||
|
||||
@Composable
|
||||
fun EmptyStateMessage(
|
||||
title: String,
|
||||
message: String,
|
||||
onActionClick: () -> Unit,
|
||||
actionText: String
|
||||
title: String,
|
||||
message: String,
|
||||
onActionClick: () -> Unit,
|
||||
actionText: String
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
|
||||
if (actionText.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(onClick = onActionClick) {
|
||||
Text(actionText)
|
||||
}
|
||||
|
||||
Button(onClick = onActionClick) { Text(actionText) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.R
|
||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
@@ -122,153 +123,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Data Management Section
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Text(
|
||||
text = "Data Management",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Export Data
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Export Data with Images") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Export all your climbing data and images to ZIP file (recommended)"
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Share, contentDescription = null)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val defaultFileName =
|
||||
"openclimb_export_${
|
||||
java.time.LocalDateTime.now()
|
||||
.toString()
|
||||
.replace(":", "-")
|
||||
.replace(".", "-")
|
||||
}.zip"
|
||||
exportZipLauncher.launch(defaultFileName)
|
||||
},
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Export ZIP")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Import Data") },
|
||||
supportingContent = {
|
||||
Text("Import climbing data from ZIP file (recommended format)")
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = { importLauncher.launch("application/zip") },
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Import")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Reset All Data") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Permanently delete all gyms, problems, sessions, attempts, and images"
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = { showResetDialog = true },
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Reset", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +176,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
"Last sync: ${
|
||||
try {
|
||||
Instant.parse(time).toString()
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
time
|
||||
}
|
||||
}",
|
||||
@@ -510,6 +368,151 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Data Management Section
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Text(
|
||||
text = "Data Management",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Export Data
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Export Data with Images") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Export all your climbing data and images to ZIP file (recommended)"
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Share, contentDescription = null)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val defaultFileName =
|
||||
"openclimb_export_${
|
||||
java.time.LocalDateTime.now()
|
||||
.toString()
|
||||
.replace(":", "-")
|
||||
.replace(".", "-")
|
||||
}.zip"
|
||||
exportZipLauncher.launch(defaultFileName)
|
||||
},
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Export ZIP")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Import Data") },
|
||||
supportingContent = {
|
||||
Text("Import climbing data from ZIP file (recommended format)")
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = { importLauncher.launch("application/zip") },
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Import")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Reset All Data") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Permanently delete all gyms, problems, sessions, attempts, and images"
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = { showResetDialog = true },
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Reset", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App Information Section
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
||||
@@ -754,7 +757,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
syncService.authToken = authToken.trim()
|
||||
viewModel.testSyncConnection()
|
||||
showSyncConfigDialog = false
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// Error will be shown via syncError state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,7 +396,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -416,7 +416,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -439,7 +439,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -459,7 +459,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -481,7 +481,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -492,7 +492,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -511,7 +511,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -522,7 +522,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,5 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
class SyncService: ObservableObject {
|
||||
@@ -455,7 +454,7 @@ class SyncService: ObservableObject {
|
||||
let zipData = try createMinimalZipFromBackup(updatedBackup)
|
||||
|
||||
// Use existing import method which properly handles data restoration
|
||||
try dataManager.importData(from: zipData)
|
||||
try dataManager.importData(from: zipData, showSuccessMessage: false)
|
||||
|
||||
// Update local data state to match imported data timestamp
|
||||
DataStateManager.shared.setLastModified(backup.exportedAt)
|
||||
@@ -735,180 +734,29 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
|
||||
func triggerAutoSync(dataManager: ClimbingDataManager) {
|
||||
guard isConnected && isConfigured && isAutoSyncEnabled else { return }
|
||||
// Early exit if sync cannot proceed - don't set isSyncing
|
||||
guard isConnected && isConfigured && isAutoSyncEnabled else {
|
||||
// Ensure isSyncing is false when sync is not possible
|
||||
if isSyncing {
|
||||
isSyncing = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent multiple simultaneous syncs
|
||||
guard !isSyncing else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await syncWithServer(dataManager: dataManager)
|
||||
} catch {
|
||||
print("Auto-sync failed: \(error)")
|
||||
// Don't show UI errors for auto-sync failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
await MainActor.run {
|
||||
self.isSyncing = false
|
||||
}
|
||||
} else {
|
||||
// New gym from server
|
||||
merged[serverGym.id] = serverGym
|
||||
}
|
||||
}
|
||||
|
||||
return Array(merged.values)
|
||||
}
|
||||
|
||||
private func mergeProblems(local: [BackupProblem], server: [BackupProblem]) -> [BackupProblem] {
|
||||
var merged: [String: BackupProblem] = [:]
|
||||
|
||||
// Add all local problems
|
||||
for problem in local {
|
||||
merged[problem.id] = problem
|
||||
}
|
||||
|
||||
// Add server problems, replacing if newer or merging image paths
|
||||
for serverProblem in server {
|
||||
if let localProblem = merged[serverProblem.id] {
|
||||
// Merge image paths from both sources
|
||||
let localImages = Set(localProblem.imagePaths ?? [])
|
||||
let serverImages = Set(serverProblem.imagePaths ?? [])
|
||||
let mergedImages = Array(localImages.union(serverImages))
|
||||
|
||||
// Use most recently updated problem data but with merged images
|
||||
let newerProblem =
|
||||
isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
|
||||
? serverProblem : localProblem
|
||||
merged[serverProblem.id] = BackupProblem(
|
||||
id: newerProblem.id,
|
||||
gymId: newerProblem.gymId,
|
||||
name: newerProblem.name,
|
||||
description: newerProblem.description,
|
||||
climbType: newerProblem.climbType,
|
||||
difficulty: newerProblem.difficulty,
|
||||
tags: newerProblem.tags,
|
||||
location: newerProblem.location,
|
||||
imagePaths: mergedImages.isEmpty ? nil : mergedImages,
|
||||
isActive: newerProblem.isActive,
|
||||
dateSet: newerProblem.dateSet,
|
||||
notes: newerProblem.notes,
|
||||
createdAt: newerProblem.createdAt,
|
||||
updatedAt: newerProblem.updatedAt
|
||||
)
|
||||
} else {
|
||||
// New problem from server
|
||||
merged[serverProblem.id] = serverProblem
|
||||
}
|
||||
}
|
||||
|
||||
return Array(merged.values)
|
||||
}
|
||||
|
||||
private func mergeSessions(local: [BackupClimbSession], server: [BackupClimbSession])
|
||||
-> [BackupClimbSession]
|
||||
{
|
||||
var merged: [String: BackupClimbSession] = [:]
|
||||
|
||||
// Add all local sessions
|
||||
for session in local {
|
||||
merged[session.id] = session
|
||||
}
|
||||
|
||||
// Add server sessions, replacing if newer
|
||||
for serverSession in server {
|
||||
if let localSession = merged[serverSession.id] {
|
||||
// Keep the most recently updated
|
||||
if isNewerThan(serverSession.updatedAt, localSession.updatedAt) {
|
||||
merged[serverSession.id] = serverSession
|
||||
}
|
||||
} else {
|
||||
// New session from server
|
||||
merged[serverSession.id] = serverSession
|
||||
}
|
||||
}
|
||||
|
||||
return Array(merged.values)
|
||||
}
|
||||
|
||||
private func mergeAttempts(local: [BackupAttempt], server: [BackupAttempt]) -> [BackupAttempt] {
|
||||
var merged: [String: BackupAttempt] = [:]
|
||||
|
||||
// Add all local attempts
|
||||
for attempt in local {
|
||||
merged[attempt.id] = attempt
|
||||
}
|
||||
|
||||
// Add server attempts, replacing if newer
|
||||
for serverAttempt in server {
|
||||
if let localAttempt = merged[serverAttempt.id] {
|
||||
// Keep the most recently created (attempts don't typically get updated)
|
||||
if isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) {
|
||||
merged[serverAttempt.id] = serverAttempt
|
||||
}
|
||||
} else {
|
||||
// New attempt from server
|
||||
merged[serverAttempt.id] = serverAttempt
|
||||
}
|
||||
}
|
||||
|
||||
return Array(merged.values)
|
||||
}
|
||||
|
||||
private func isNewerThan(_ dateString1: String, _ dateString2: String) -> Bool {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
guard let date1 = formatter.date(from: dateString1),
|
||||
let date2 = formatter.date(from: dateString2)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return date1 > date2
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
@@ -931,8 +779,6 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Removed SyncTrigger enum - now using simple auto sync on any data change
|
||||
|
||||
enum SyncError: LocalizedError {
|
||||
case notConfigured
|
||||
case notConnected
|
||||
|
||||
@@ -32,6 +32,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
// Sync service for automatic syncing
|
||||
let syncService = SyncService()
|
||||
|
||||
// Published property to propagate sync state changes
|
||||
@Published var isSyncing = false
|
||||
|
||||
private enum Keys {
|
||||
static let gyms = "openclimb_gyms"
|
||||
static let problems = "openclimb_problems"
|
||||
@@ -67,6 +70,10 @@ class ClimbingDataManager: ObservableObject {
|
||||
migrateImagePaths()
|
||||
setupLiveActivityNotifications()
|
||||
|
||||
// Keep our published isSyncing in sync with syncService.isSyncing
|
||||
syncService.$isSyncing
|
||||
.assign(to: &$isSyncing)
|
||||
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
await performImageMaintenance()
|
||||
@@ -206,6 +213,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Gym added successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
|
||||
func updateGym(_ gym: Gym) {
|
||||
@@ -215,6 +225,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Gym updated successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +250,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Gym deleted successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
|
||||
func gym(withId id: UUID) -> Gym? {
|
||||
@@ -261,6 +277,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Problem updated successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +295,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
problems.removeAll { $0.id == problem.id }
|
||||
saveProblems()
|
||||
DataStateManager.shared.updateDataState()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
|
||||
func problem(withId id: UUID) -> Problem? {
|
||||
@@ -291,7 +313,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
|
||||
func startSession(gymId: UUID, notes: String? = nil) {
|
||||
|
||||
// End any currently active session
|
||||
if let currentActive = activeSession {
|
||||
endSession(currentActive.id)
|
||||
}
|
||||
@@ -314,6 +336,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
for: newSession, gymName: gym.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
|
||||
func endSession(_ sessionId: UUID) {
|
||||
@@ -358,8 +383,11 @@ class ClimbingDataManager: ObservableObject {
|
||||
successMessage = "Session updated successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when session updates
|
||||
// Update Live Activity when session is updated
|
||||
updateLiveActivityForActiveSession()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +396,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
attempts.removeAll { $0.sessionId == session.id }
|
||||
saveAttempts()
|
||||
|
||||
// Remove from active session if it's the current one
|
||||
// If this is the active session, clear it
|
||||
if activeSession?.id == session.id {
|
||||
activeSession = nil
|
||||
saveActiveSession()
|
||||
@@ -380,6 +408,12 @@ class ClimbingDataManager: ObservableObject {
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Session deleted successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when session is deleted
|
||||
updateLiveActivityForActiveSession()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
|
||||
func session(withId id: UUID) -> ClimbSession? {
|
||||
@@ -421,6 +455,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
// Update Live Activity when attempt is updated
|
||||
updateLiveActivityForActiveSession()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,6 +470,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
// Update Live Activity when attempt is deleted
|
||||
updateLiveActivityForActiveSession()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
|
||||
func attempts(forSession sessionId: UUID) -> [Attempt] {
|
||||
@@ -476,7 +516,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
return gym(withId: mostUsedGymId)
|
||||
}
|
||||
|
||||
func resetAllData() {
|
||||
func resetAllData(showSuccessMessage: Bool = true) {
|
||||
gyms.removeAll()
|
||||
problems.removeAll()
|
||||
sessions.removeAll()
|
||||
@@ -490,8 +530,11 @@ class ClimbingDataManager: ObservableObject {
|
||||
userDefaults.removeObject(forKey: Keys.activeSession)
|
||||
|
||||
DataStateManager.shared.reset()
|
||||
successMessage = "All data has been reset"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
if showSuccessMessage {
|
||||
successMessage = "All data has been reset"
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let importResult = try ZipUtils.extractImportZip(data: data)
|
||||
|
||||
@@ -566,7 +609,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
try validateImportData(importData)
|
||||
|
||||
resetAllData()
|
||||
resetAllData(showSuccessMessage: showSuccessMessage)
|
||||
|
||||
let updatedProblems = updateProblemImagePaths(
|
||||
problems: importData.problems,
|
||||
@@ -586,9 +629,11 @@ class ClimbingDataManager: ObservableObject {
|
||||
// Update data state to current time since we just imported new data
|
||||
DataStateManager.shared.updateDataState()
|
||||
|
||||
successMessage =
|
||||
"Data imported successfully with \(importResult.imagePathMapping.count) images"
|
||||
clearMessageAfterDelay()
|
||||
if showSuccessMessage {
|
||||
successMessage =
|
||||
"Data imported successfully with \(importResult.imagePathMapping.count) images"
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
} catch {
|
||||
setError("Import failed: \(error.localizedDescription)")
|
||||
throw error
|
||||
|
||||
@@ -20,6 +20,27 @@ struct AnalyticsView: View {
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Analytics")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if dataManager.isSyncing {
|
||||
HStack(spacing: 2) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
|
||||
.scaleEffect(0.6)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
.animation(
|
||||
.easeInOut(duration: 0.2), value: dataManager.isSyncing
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,25 @@ struct GymsView: View {
|
||||
}
|
||||
.navigationTitle("Gyms")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
if dataManager.isSyncing {
|
||||
HStack(spacing: 2) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
|
||||
.scaleEffect(0.6)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
.animation(
|
||||
.easeInOut(duration: 0.2), value: dataManager.isSyncing
|
||||
)
|
||||
}
|
||||
|
||||
Button("Add") {
|
||||
showingAddGym = true
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//
|
||||
// LiveActivityDebugView.swift
|
||||
// OpenClimb
|
||||
//
|
||||
// Created by Assistant on 2025-09-15.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -62,7 +62,25 @@ struct ProblemsView: View {
|
||||
.navigationTitle("Problems")
|
||||
.searchable(text: $searchText, prompt: "Search problems...")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
if dataManager.isSyncing {
|
||||
HStack(spacing: 2) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
|
||||
.scaleEffect(0.6)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
.animation(
|
||||
.easeInOut(duration: 0.2), value: dataManager.isSyncing
|
||||
)
|
||||
}
|
||||
|
||||
if !dataManager.gyms.isEmpty {
|
||||
Button("Add") {
|
||||
showingAddProblem = true
|
||||
|
||||
@@ -17,7 +17,25 @@ struct SessionsView: View {
|
||||
.navigationTitle("Sessions")
|
||||
.navigationBarTitleDisplayMode(.automatic)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
if dataManager.isSyncing {
|
||||
HStack(spacing: 2) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
|
||||
.scaleEffect(0.6)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
.animation(
|
||||
.easeInOut(duration: 0.2), value: dataManager.isSyncing
|
||||
)
|
||||
}
|
||||
|
||||
if dataManager.gyms.isEmpty {
|
||||
EmptyView()
|
||||
} else if dataManager.activeSession == nil {
|
||||
|
||||
@@ -22,6 +22,27 @@ struct SettingsView: View {
|
||||
AppInfoSection()
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if dataManager.isSyncing {
|
||||
HStack(spacing: 2) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
|
||||
.scaleEffect(0.6)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
.animation(
|
||||
.easeInOut(duration: 0.2), value: dataManager.isSyncing
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(
|
||||
item: Binding<SheetType?>(
|
||||
get: { activeSheet },
|
||||
@@ -436,6 +457,7 @@ struct SyncSection: View {
|
||||
.foregroundColor(.red)
|
||||
.padding(.leading, 24)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSyncSettings) {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
//
|
||||
// AppIntent.swift
|
||||
// SessionStatusLive
|
||||
//
|
||||
// Created by Atridad Lahiji on 2025-09-15.
|
||||
//
|
||||
|
||||
import WidgetKit
|
||||
import AppIntents
|
||||
import WidgetKit
|
||||
|
||||
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource { "Configuration" }
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//
|
||||
// SessionStatusLive.swift
|
||||
// SessionStatusLive
|
||||
//
|
||||
// Created by Atridad Lahiji on 2025-09-15.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
//
|
||||
// SessionStatusLiveBundle.swift
|
||||
// SessionStatusLive
|
||||
//
|
||||
// Created by Atridad Lahiji on 2025-09-15.
|
||||
//
|
||||
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct SessionStatusLiveBundle: WidgetBundle {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//
|
||||
// SessionStatusLiveControl.swift
|
||||
// SessionStatusLive
|
||||
//
|
||||
// Created by Atridad Lahiji on 2025-09-15.
|
||||
//
|
||||
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
@@ -42,8 +38,9 @@ extension SessionStatusLiveControl {
|
||||
}
|
||||
|
||||
func currentValue(configuration: TimerConfiguration) async throws -> Value {
|
||||
let isRunning = true // Check if the timer is running
|
||||
return SessionStatusLiveControl.Value(isRunning: isRunning, name: configuration.timerName)
|
||||
let isRunning = true // Check if the timer is running
|
||||
return SessionStatusLiveControl.Value(
|
||||
isRunning: isRunning, name: configuration.timerName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//
|
||||
// SessionStatusLiveLiveActivity.swift
|
||||
// SessionStatusLive
|
||||
//
|
||||
// Created by Atridad Lahiji on 2025-09-15.
|
||||
//
|
||||
|
||||
import ActivityKit
|
||||
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: Secret token for authentication
|
||||
# Generate a secure random token and share it between your apps and server
|
||||
# Required
|
||||
AUTH_TOKEN=your-secure-secret-token-here
|
||||
IMAGE="git.atri.dad/atridad/openclimb-sync:latest"
|
||||
APP_PORT=1337
|
||||
ROOT_DIR="./data"
|
||||
|
||||
# Optional: Port to run the server on (default: 8080)
|
||||
PORT=8080
|
||||
|
||||
# Optional: Path to store the sync data (default: ./data/climb_data.json)
|
||||
DATA_FILE=./data/climb_data.json
|
||||
|
||||
# Optional: Directory to store images (default: ./data/images)
|
||||
IMAGES_DIR=./data/images
|
||||
# Optional
|
||||
DATA_FILE=/data/data.json
|
||||
IMAGES_DIR=/data/images
|
||||
|
||||
@@ -2,11 +2,12 @@ services:
|
||||
openclimb-sync:
|
||||
image: ${IMAGE}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- ${APP_PORT}:8080
|
||||
environment:
|
||||
- AUTH_TOKEN=${AUTH_TOKEN:-your-secret-token-here}
|
||||
- DATA_FILE=/data/climb_data.json
|
||||
- IMAGES_DIR=/data/images
|
||||
- AUTH_TOKEN=${AUTH_TOKEN}
|
||||
- DATA_FILE=${DATA_FILE}
|
||||
- IMAGES_DIR=${IMAGES_DIR}
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ${ROOT_DIR}:/data
|
||||
restart: unless-stopped
|
||||
networks: {}
|
||||
|
||||
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