Compare commits

...

4 Commits

23 changed files with 560 additions and 744 deletions

View File

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

View File

@@ -17,7 +17,6 @@ import com.atridad.openclimb.utils.ImageUtils
import java.io.IOException
import java.time.Instant
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -27,12 +26,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import androidx.core.content.edit
class SyncService(private val context: Context, private val repository: ClimbRepository) {
@@ -91,13 +90,13 @@ class SyncService(private val context: Context, private val repository: ClimbRep
var serverURL: String
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
set(value) {
sharedPreferences.edit().putString(Keys.SERVER_URL, value).apply()
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
}
var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
set(value) {
sharedPreferences.edit().putString(Keys.AUTH_TOKEN, value).apply()
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
}
val isConfigured: Boolean
@@ -116,7 +115,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
// Register auto-sync callback with repository
repository.setAutoSyncCallback {
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
triggerAutoSync()
}
}
@@ -491,21 +490,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
return imagePathMapping
}
private suspend fun syncImagesToServer() {
val allProblems = repository.getAllProblems().first()
val backup =
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
version = "2.0",
formatVersion = "2.0",
gyms = emptyList(),
problems = allProblems.map { BackupProblem.fromProblem(it) },
sessions = emptyList(),
attempts = emptyList()
)
syncImagesForBackup(backup)
}
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems")
@@ -626,7 +610,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val updatedProblem =
if (imagePathMapping.isNotEmpty()) {
val newImagePaths =
backupProblem.imagePaths?.mapNotNull { oldPath ->
backupProblem.imagePaths?.map { oldPath ->
// Extract filename and check mapping
val filename = oldPath.substringAfterLast('/')
// Use mapped full path or fallback to consistent naming
@@ -696,11 +680,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
/** Converts milliseconds to ISO8601 timestamp */
private fun millisToISO8601(millis: Long): String {
return DateFormatUtils.millisToISO8601(millis)
}
/**
* Fixes existing image paths in the database to include the proper directory structure. This
* corrects paths like "problem_abc_0.jpg" to "problem_images/problem_abc_0.jpg"
@@ -833,141 +812,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
// DEPRECATED: Complex merge logic replaced with simple timestamp-based sync
// These methods are no longer used but kept for reference
@Deprecated("Use simple timestamp-based sync instead")
private fun performIntelligentMerge(
local: ClimbDataBackup,
server: ClimbDataBackup
): ClimbDataBackup {
Log.d(TAG, "Merging data - preserving all entities to prevent data loss")
val mergedGyms = mergeGyms(local.gyms, server.gyms)
val mergedProblems = mergeProblems(local.problems, server.problems)
val mergedSessions = mergeSessions(local.sessions, server.sessions)
val mergedAttempts = mergeAttempts(local.attempts, server.attempts)
Log.d(
TAG,
"Merge results: gyms=${mergedGyms.size}, problems=${mergedProblems.size}, " +
"sessions=${mergedSessions.size}, attempts=${mergedAttempts.size}"
)
return ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
version = "2.0",
formatVersion = "2.0",
gyms = mergedGyms,
problems = mergedProblems,
sessions = mergedSessions,
attempts = mergedAttempts
)
}
private fun mergeGyms(local: List<BackupGym>, server: List<BackupGym>): List<BackupGym> {
val merged = mutableMapOf<String, BackupGym>()
// Add all local gyms
local.forEach { gym -> merged[gym.id] = gym }
// Add server gyms, preferring newer updates
server.forEach { serverGym ->
val localGym = merged[serverGym.id]
if (localGym == null || isNewerThan(serverGym.updatedAt, localGym.updatedAt)) {
merged[serverGym.id] = serverGym
}
}
return merged.values.toList()
}
private fun mergeProblems(
local: List<BackupProblem>,
server: List<BackupProblem>
): List<BackupProblem> {
val merged = mutableMapOf<String, BackupProblem>()
// Add all local problems
local.forEach { problem -> merged[problem.id] = problem }
// Add server problems, preferring newer updates
server.forEach { serverProblem ->
val localProblem = merged[serverProblem.id]
if (localProblem == null || isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
) {
// Merge image paths to preserve all images
val allImagePaths = mutableSetOf<String>()
localProblem?.imagePaths?.let { allImagePaths.addAll(it) }
serverProblem.imagePaths?.let { allImagePaths.addAll(it) }
merged[serverProblem.id] =
serverProblem.withUpdatedImagePaths(allImagePaths.toList())
}
}
return merged.values.toList()
}
private fun mergeSessions(
local: List<BackupClimbSession>,
server: List<BackupClimbSession>
): List<BackupClimbSession> {
val merged = mutableMapOf<String, BackupClimbSession>()
// Add all local sessions
local.forEach { session -> merged[session.id] = session }
// Add server sessions, preferring newer updates
server.forEach { serverSession ->
val localSession = merged[serverSession.id]
if (localSession == null || isNewerThan(serverSession.updatedAt, localSession.updatedAt)
) {
merged[serverSession.id] = serverSession
}
}
return merged.values.toList()
}
private fun mergeAttempts(
local: List<BackupAttempt>,
server: List<BackupAttempt>
): List<BackupAttempt> {
val merged = mutableMapOf<String, BackupAttempt>()
// Add all local attempts
local.forEach { attempt -> merged[attempt.id] = attempt }
// Add server attempts, preferring newer updates
server.forEach { serverAttempt ->
val localAttempt = merged[serverAttempt.id]
if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt)
) {
merged[serverAttempt.id] = serverAttempt
}
}
return merged.values.toList()
}
private fun isNewerThan(dateString1: String, dateString2: String): Boolean {
return try {
// Try parsing as instant first
val date1 = Instant.parse(dateString1)
val date2 = Instant.parse(dateString2)
date1.isAfter(date2)
} catch (e: Exception) {
// Fallback to string comparison
dateString1 > dateString2
}
}
fun disconnect() {
_isConnected.value = false
sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply()
_syncError.value = null
}
fun clearConfiguration() {
serverURL = ""
authToken = ""
@@ -980,14 +824,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
// Removed SyncTrigger enum - now using simple auto sync on any data change
sealed class SyncException(message: String) : Exception(message) {
object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.")
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
object InvalidURL : SyncException("Invalid server URL.")
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details")

View File

@@ -0,0 +1,49 @@
package com.atridad.openclimb.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.StateFlow
@Composable
fun SyncIndicator(isSyncing: StateFlow<Boolean>, modifier: Modifier = Modifier) {
val syncing by isSyncing.collectAsState()
AnimatedVisibility(
visible = syncing,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
modifier = modifier
) {
Box(
modifier =
Modifier.size(28.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
.padding(6.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
}

View File

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

View File

@@ -12,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,31 +63,21 @@ 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
)
}
@@ -103,11 +86,9 @@ fun GymCard(
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)
)
}
}
@@ -115,9 +96,10 @@ fun GymCard(
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
)
}
@@ -125,10 +107,10 @@ fun GymCard(
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
)
}
}

View File

@@ -17,6 +17,7 @@ import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.data.model.Problem
import com.atridad.openclimb.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplay
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -58,10 +59,12 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Problems & Routes",
text = "Climbing Problems",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
}
Spacer(modifier = Modifier.height(16.dp))

View File

@@ -20,16 +20,14 @@ import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus
import com.atridad.openclimb.ui.components.ActiveSessionBanner
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionsScreen(
viewModel: ClimbViewModel,
onNavigateToSessionDetail: (String) -> Unit
) {
fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String) -> Unit) {
val context = LocalContext.current
val sessions by viewModel.sessions.collectAsState()
val gyms by viewModel.gyms.collectAsState()
@@ -38,47 +36,37 @@ fun SessionsScreen(
// Filter out active sessions from regular session list
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
val activeSessionGym = activeSession?.let { session ->
gyms.find { it.id == session.gymId }
}
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
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) {
@@ -87,18 +75,21 @@ fun SessionsScreen(
if (completedSessions.isEmpty() && activeSession == null) {
EmptyStateMessage(
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
message = if (gyms.isEmpty()) "Add a gym first to start tracking your climbing sessions!" else "Start your first climbing session!",
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))
}
@@ -114,30 +105,27 @@ fun SessionsScreen(
}
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
)
}
}
@@ -150,30 +138,27 @@ fun SessionsScreen(
}
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,33 +167,22 @@ 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
)
}
@@ -216,8 +190,8 @@ fun SessionCard(
session.duration?.let { duration ->
Text(
text = "Duration: $duration minutes",
style = MaterialTheme.typography.bodyMedium
text = "Duration: $duration minutes",
style = MaterialTheme.typography.bodyMedium
)
}
@@ -225,10 +199,10 @@ fun SessionCard(
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) }
}
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.io.File
import java.time.Instant
@@ -122,153 +123,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
Text(
text = "Settings",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
}
}
// Data Management Section
item {
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Data Management",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
// Export Data
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Export Data with Images") },
supportingContent = {
Text(
"Export all your climbing data and images to ZIP file (recommended)"
)
},
leadingContent = {
Icon(Icons.Default.Share, contentDescription = null)
},
trailingContent = {
TextButton(
onClick = {
val defaultFileName =
"openclimb_export_${
java.time.LocalDateTime.now()
.toString()
.replace(":", "-")
.replace(".", "-")
}.zip"
exportZipLauncher.launch(defaultFileName)
},
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Export ZIP")
}
}
}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Import Data") },
supportingContent = {
Text("Import climbing data from ZIP file (recommended format)")
},
leadingContent = {
Icon(Icons.Default.Add, contentDescription = null)
},
trailingContent = {
TextButton(
onClick = { importLauncher.launch("application/zip") },
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Import")
}
}
}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Reset All Data") },
supportingContent = {
Text(
"Permanently delete all gyms, problems, sessions, attempts, and images"
)
},
leadingContent = {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
trailingContent = {
TextButton(
onClick = { showResetDialog = true },
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Reset", color = MaterialTheme.colorScheme.error)
}
}
}
)
}
}
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
}
}
@@ -318,7 +176,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
"Last sync: ${
try {
Instant.parse(time).toString()
} catch (e: Exception) {
} catch (_: Exception) {
time
}
}",
@@ -510,6 +368,151 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
}
}
// Data Management Section
item {
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Data Management",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
// Export Data
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Export Data with Images") },
supportingContent = {
Text(
"Export all your climbing data and images to ZIP file (recommended)"
)
},
leadingContent = {
Icon(Icons.Default.Share, contentDescription = null)
},
trailingContent = {
TextButton(
onClick = {
val defaultFileName =
"openclimb_export_${
java.time.LocalDateTime.now()
.toString()
.replace(":", "-")
.replace(".", "-")
}.zip"
exportZipLauncher.launch(defaultFileName)
},
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Export ZIP")
}
}
}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Import Data") },
supportingContent = {
Text("Import climbing data from ZIP file (recommended format)")
},
leadingContent = {
Icon(Icons.Default.Add, contentDescription = null)
},
trailingContent = {
TextButton(
onClick = { importLauncher.launch("application/zip") },
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Import")
}
}
}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Reset All Data") },
supportingContent = {
Text(
"Permanently delete all gyms, problems, sessions, attempts, and images"
)
},
leadingContent = {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
trailingContent = {
TextButton(
onClick = { showResetDialog = true },
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Reset", color = MaterialTheme.colorScheme.error)
}
}
}
)
}
}
}
}
// App Information Section
item {
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
@@ -754,7 +757,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
syncService.authToken = authToken.trim()
viewModel.testSyncConnection()
showSyncConfigDialog = false
} catch (e: Exception) {
} catch (_: Exception) {
// Error will be shown via syncError state
}
}

View File

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

View File

@@ -1,6 +1,5 @@
import Combine
import Foundation
import UIKit
@MainActor
class SyncService: ObservableObject {
@@ -455,7 +454,7 @@ class SyncService: ObservableObject {
let zipData = try createMinimalZipFromBackup(updatedBackup)
// Use existing import method which properly handles data restoration
try dataManager.importData(from: zipData)
try dataManager.importData(from: zipData, showSuccessMessage: false)
// Update local data state to match imported data timestamp
DataStateManager.shared.setLastModified(backup.exportedAt)
@@ -735,180 +734,29 @@ class SyncService: ObservableObject {
}
func triggerAutoSync(dataManager: ClimbingDataManager) {
guard isConnected && isConfigured && isAutoSyncEnabled else { return }
// Early exit if sync cannot proceed - don't set isSyncing
guard isConnected && isConfigured && isAutoSyncEnabled else {
// Ensure isSyncing is false when sync is not possible
if isSyncing {
isSyncing = false
}
return
}
// Prevent multiple simultaneous syncs
guard !isSyncing else {
return
}
Task {
do {
try await syncWithServer(dataManager: dataManager)
} catch {
print("Auto-sync failed: \(error)")
// Don't show UI errors for auto-sync failures
}
}
}
// 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

View File

@@ -32,6 +32,9 @@ class ClimbingDataManager: ObservableObject {
// Sync service for automatic syncing
let syncService = SyncService()
// Published property to propagate sync state changes
@Published var isSyncing = false
private enum Keys {
static let gyms = "openclimb_gyms"
static let problems = "openclimb_problems"
@@ -67,6 +70,10 @@ class ClimbingDataManager: ObservableObject {
migrateImagePaths()
setupLiveActivityNotifications()
// Keep our published isSyncing in sync with syncService.isSyncing
syncService.$isSyncing
.assign(to: &$isSyncing)
Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
await performImageMaintenance()
@@ -206,6 +213,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState()
successMessage = "Gym added successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func updateGym(_ gym: Gym) {
@@ -215,6 +225,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState()
successMessage = "Gym updated successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
@@ -237,6 +250,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState()
successMessage = "Gym deleted successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func gym(withId id: UUID) -> Gym? {
@@ -261,6 +277,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState()
successMessage = "Problem updated successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
@@ -276,6 +295,9 @@ class ClimbingDataManager: ObservableObject {
problems.removeAll { $0.id == problem.id }
saveProblems()
DataStateManager.shared.updateDataState()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func problem(withId id: UUID) -> Problem? {
@@ -291,7 +313,7 @@ class ClimbingDataManager: ObservableObject {
}
func startSession(gymId: UUID, notes: String? = nil) {
// End any currently active session
if let currentActive = activeSession {
endSession(currentActive.id)
}
@@ -314,6 +336,9 @@ class ClimbingDataManager: ObservableObject {
for: newSession, gymName: gym.name)
}
}
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func endSession(_ sessionId: UUID) {
@@ -358,8 +383,11 @@ class ClimbingDataManager: ObservableObject {
successMessage = "Session updated successfully"
clearMessageAfterDelay()
// Update Live Activity when session updates
// Update Live Activity when session is updated
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
@@ -368,7 +396,7 @@ class ClimbingDataManager: ObservableObject {
attempts.removeAll { $0.sessionId == session.id }
saveAttempts()
// Remove from active session if it's the current one
// If this is the active session, clear it
if activeSession?.id == session.id {
activeSession = nil
saveActiveSession()
@@ -380,6 +408,12 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState()
successMessage = "Session deleted successfully"
clearMessageAfterDelay()
// Update Live Activity when session is deleted
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func session(withId id: UUID) -> ClimbSession? {
@@ -421,6 +455,9 @@ class ClimbingDataManager: ObservableObject {
// Update Live Activity when attempt is updated
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
}
@@ -433,6 +470,9 @@ class ClimbingDataManager: ObservableObject {
// Update Live Activity when attempt is deleted
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func attempts(forSession sessionId: UUID) -> [Attempt] {
@@ -476,7 +516,7 @@ class ClimbingDataManager: ObservableObject {
return gym(withId: mostUsedGymId)
}
func resetAllData() {
func resetAllData(showSuccessMessage: Bool = true) {
gyms.removeAll()
problems.removeAll()
sessions.removeAll()
@@ -490,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,5 @@
//
// SessionStatusLiveControl.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import AppIntents
import SwiftUI
@@ -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)
}
}
}

View File

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