Android 1.7.1 - Added a clear sync indicator
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user