Android 1.7.1 - Added a clear sync indicator
This commit is contained in:
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.openclimb"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 28
|
versionCode = 29
|
||||||
versionName = "1.7.0"
|
versionName = "1.7.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import com.atridad.openclimb.utils.ImageUtils
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -27,12 +26,12 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
class SyncService(private val context: Context, private val repository: ClimbRepository) {
|
class SyncService(private val context: Context, private val repository: ClimbRepository) {
|
||||||
|
|
||||||
@@ -91,13 +90,13 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
var serverURL: String
|
var serverURL: String
|
||||||
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
|
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
|
||||||
set(value) {
|
set(value) {
|
||||||
sharedPreferences.edit().putString(Keys.SERVER_URL, value).apply()
|
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var authToken: String
|
var authToken: String
|
||||||
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
|
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
|
||||||
set(value) {
|
set(value) {
|
||||||
sharedPreferences.edit().putString(Keys.AUTH_TOKEN, value).apply()
|
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val isConfigured: Boolean
|
val isConfigured: Boolean
|
||||||
@@ -116,7 +115,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
|
|
||||||
// Register auto-sync callback with repository
|
// Register auto-sync callback with repository
|
||||||
repository.setAutoSyncCallback {
|
repository.setAutoSyncCallback {
|
||||||
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
|
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||||
triggerAutoSync()
|
triggerAutoSync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,21 +490,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
return imagePathMapping
|
return imagePathMapping
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncImagesToServer() {
|
|
||||||
val allProblems = repository.getAllProblems().first()
|
|
||||||
val backup =
|
|
||||||
ClimbDataBackup(
|
|
||||||
exportedAt = DateFormatUtils.nowISO8601(),
|
|
||||||
version = "2.0",
|
|
||||||
formatVersion = "2.0",
|
|
||||||
gyms = emptyList(),
|
|
||||||
problems = allProblems.map { BackupProblem.fromProblem(it) },
|
|
||||||
sessions = emptyList(),
|
|
||||||
attempts = emptyList()
|
|
||||||
)
|
|
||||||
syncImagesForBackup(backup)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
|
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
|
||||||
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems")
|
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems")
|
||||||
|
|
||||||
@@ -626,7 +610,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
val updatedProblem =
|
val updatedProblem =
|
||||||
if (imagePathMapping.isNotEmpty()) {
|
if (imagePathMapping.isNotEmpty()) {
|
||||||
val newImagePaths =
|
val newImagePaths =
|
||||||
backupProblem.imagePaths?.mapNotNull { oldPath ->
|
backupProblem.imagePaths?.map { oldPath ->
|
||||||
// Extract filename and check mapping
|
// Extract filename and check mapping
|
||||||
val filename = oldPath.substringAfterLast('/')
|
val filename = oldPath.substringAfterLast('/')
|
||||||
// Use mapped full path or fallback to consistent naming
|
// Use mapped full path or fallback to consistent naming
|
||||||
@@ -696,11 +680,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Converts milliseconds to ISO8601 timestamp */
|
|
||||||
private fun millisToISO8601(millis: Long): String {
|
|
||||||
return DateFormatUtils.millisToISO8601(millis)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixes existing image paths in the database to include the proper directory structure. This
|
* Fixes existing image paths in the database to include the proper directory structure. This
|
||||||
* corrects paths like "problem_abc_0.jpg" to "problem_images/problem_abc_0.jpg"
|
* corrects paths like "problem_abc_0.jpg" to "problem_images/problem_abc_0.jpg"
|
||||||
@@ -833,141 +812,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEPRECATED: Complex merge logic replaced with simple timestamp-based sync
|
|
||||||
// These methods are no longer used but kept for reference
|
|
||||||
@Deprecated("Use simple timestamp-based sync instead")
|
|
||||||
private fun performIntelligentMerge(
|
|
||||||
local: ClimbDataBackup,
|
|
||||||
server: ClimbDataBackup
|
|
||||||
): ClimbDataBackup {
|
|
||||||
Log.d(TAG, "Merging data - preserving all entities to prevent data loss")
|
|
||||||
|
|
||||||
val mergedGyms = mergeGyms(local.gyms, server.gyms)
|
|
||||||
val mergedProblems = mergeProblems(local.problems, server.problems)
|
|
||||||
val mergedSessions = mergeSessions(local.sessions, server.sessions)
|
|
||||||
val mergedAttempts = mergeAttempts(local.attempts, server.attempts)
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Merge results: gyms=${mergedGyms.size}, problems=${mergedProblems.size}, " +
|
|
||||||
"sessions=${mergedSessions.size}, attempts=${mergedAttempts.size}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return ClimbDataBackup(
|
|
||||||
exportedAt = DateFormatUtils.nowISO8601(),
|
|
||||||
version = "2.0",
|
|
||||||
formatVersion = "2.0",
|
|
||||||
gyms = mergedGyms,
|
|
||||||
problems = mergedProblems,
|
|
||||||
sessions = mergedSessions,
|
|
||||||
attempts = mergedAttempts
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mergeGyms(local: List<BackupGym>, server: List<BackupGym>): List<BackupGym> {
|
|
||||||
val merged = mutableMapOf<String, BackupGym>()
|
|
||||||
|
|
||||||
// Add all local gyms
|
|
||||||
local.forEach { gym -> merged[gym.id] = gym }
|
|
||||||
|
|
||||||
// Add server gyms, preferring newer updates
|
|
||||||
server.forEach { serverGym ->
|
|
||||||
val localGym = merged[serverGym.id]
|
|
||||||
if (localGym == null || isNewerThan(serverGym.updatedAt, localGym.updatedAt)) {
|
|
||||||
merged[serverGym.id] = serverGym
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.values.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mergeProblems(
|
|
||||||
local: List<BackupProblem>,
|
|
||||||
server: List<BackupProblem>
|
|
||||||
): List<BackupProblem> {
|
|
||||||
val merged = mutableMapOf<String, BackupProblem>()
|
|
||||||
|
|
||||||
// Add all local problems
|
|
||||||
local.forEach { problem -> merged[problem.id] = problem }
|
|
||||||
|
|
||||||
// Add server problems, preferring newer updates
|
|
||||||
server.forEach { serverProblem ->
|
|
||||||
val localProblem = merged[serverProblem.id]
|
|
||||||
if (localProblem == null || isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
|
|
||||||
) {
|
|
||||||
// Merge image paths to preserve all images
|
|
||||||
val allImagePaths = mutableSetOf<String>()
|
|
||||||
localProblem?.imagePaths?.let { allImagePaths.addAll(it) }
|
|
||||||
serverProblem.imagePaths?.let { allImagePaths.addAll(it) }
|
|
||||||
|
|
||||||
merged[serverProblem.id] =
|
|
||||||
serverProblem.withUpdatedImagePaths(allImagePaths.toList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.values.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mergeSessions(
|
|
||||||
local: List<BackupClimbSession>,
|
|
||||||
server: List<BackupClimbSession>
|
|
||||||
): List<BackupClimbSession> {
|
|
||||||
val merged = mutableMapOf<String, BackupClimbSession>()
|
|
||||||
|
|
||||||
// Add all local sessions
|
|
||||||
local.forEach { session -> merged[session.id] = session }
|
|
||||||
|
|
||||||
// Add server sessions, preferring newer updates
|
|
||||||
server.forEach { serverSession ->
|
|
||||||
val localSession = merged[serverSession.id]
|
|
||||||
if (localSession == null || isNewerThan(serverSession.updatedAt, localSession.updatedAt)
|
|
||||||
) {
|
|
||||||
merged[serverSession.id] = serverSession
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.values.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mergeAttempts(
|
|
||||||
local: List<BackupAttempt>,
|
|
||||||
server: List<BackupAttempt>
|
|
||||||
): List<BackupAttempt> {
|
|
||||||
val merged = mutableMapOf<String, BackupAttempt>()
|
|
||||||
|
|
||||||
// Add all local attempts
|
|
||||||
local.forEach { attempt -> merged[attempt.id] = attempt }
|
|
||||||
|
|
||||||
// Add server attempts, preferring newer updates
|
|
||||||
server.forEach { serverAttempt ->
|
|
||||||
val localAttempt = merged[serverAttempt.id]
|
|
||||||
if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt)
|
|
||||||
) {
|
|
||||||
merged[serverAttempt.id] = serverAttempt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.values.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isNewerThan(dateString1: String, dateString2: String): Boolean {
|
|
||||||
return try {
|
|
||||||
// Try parsing as instant first
|
|
||||||
val date1 = Instant.parse(dateString1)
|
|
||||||
val date2 = Instant.parse(dateString2)
|
|
||||||
date1.isAfter(date2)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Fallback to string comparison
|
|
||||||
dateString1 > dateString2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect() {
|
|
||||||
_isConnected.value = false
|
|
||||||
sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply()
|
|
||||||
_syncError.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearConfiguration() {
|
fun clearConfiguration() {
|
||||||
serverURL = ""
|
serverURL = ""
|
||||||
authToken = ""
|
authToken = ""
|
||||||
@@ -980,14 +824,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed SyncTrigger enum - now using simple auto sync on any data change
|
|
||||||
|
|
||||||
sealed class SyncException(message: String) : Exception(message) {
|
sealed class SyncException(message: String) : Exception(message) {
|
||||||
object NotConfigured :
|
object NotConfigured :
|
||||||
SyncException("Sync is not configured. Please set server URL and auth token.")
|
SyncException("Sync is not configured. Please set server URL and auth token.")
|
||||||
object NotConnected : SyncException("Not connected to server. Please test connection first.")
|
object NotConnected : SyncException("Not connected to server. Please test connection first.")
|
||||||
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
|
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
|
||||||
object InvalidURL : SyncException("Invalid server URL.")
|
|
||||||
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
|
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
|
||||||
data class InvalidResponse(val details: String) :
|
data class InvalidResponse(val details: String) :
|
||||||
SyncException("Invalid server response: $details")
|
SyncException("Invalid server response: $details")
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SyncIndicator(isSyncing: StateFlow<Boolean>, modifier: Modifier = Modifier) {
|
||||||
|
val syncing by isSyncing.collectAsState()
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = syncing,
|
||||||
|
enter = scaleIn() + fadeIn(),
|
||||||
|
exit = scaleOut() + fadeOut(),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(28.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
|
||||||
|
.padding(6.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.atridad.openclimb.data.model.ClimbType
|
|||||||
import com.atridad.openclimb.data.model.DifficultySystem
|
import com.atridad.openclimb.data.model.DifficultySystem
|
||||||
import com.atridad.openclimb.ui.components.BarChart
|
import com.atridad.openclimb.ui.components.BarChart
|
||||||
import com.atridad.openclimb.ui.components.BarChartDataPoint
|
import com.atridad.openclimb.ui.components.BarChartDataPoint
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -45,8 +46,10 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
|||||||
Text(
|
Text(
|
||||||
text = "Analytics",
|
text = "Analytics",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +200,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.menuAnchor(
|
Modifier.menuAnchor(
|
||||||
type = MenuAnchorType.PrimaryNotEditable,
|
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||||
enabled = true
|
enabled = true
|
||||||
)
|
)
|
||||||
.width(120.dp),
|
.width(120.dp),
|
||||||
@@ -253,7 +256,7 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||||
)
|
)
|
||||||
attemptDate.isAfter(sevenDaysAgo)
|
attemptDate.isAfter(sevenDaysAgo)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// If date parsing fails, include the data point
|
// If date parsing fails, include the data point
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -425,9 +428,7 @@ fun calculateGradeDistribution(
|
|||||||
} else {
|
} else {
|
||||||
gradeDistribution[key] =
|
gradeDistribution[key] =
|
||||||
GradeDistributionDataPoint(
|
GradeDistributionDataPoint(
|
||||||
date =
|
date = attempt.timestamp,
|
||||||
attempt.timestamp
|
|
||||||
.toString(), // Use attempt timestamp for filtering
|
|
||||||
grade = problem.difficulty.grade,
|
grade = problem.difficulty.grade,
|
||||||
gradeNumeric =
|
gradeNumeric =
|
||||||
gradeToNumeric(
|
gradeToNumeric(
|
||||||
|
|||||||
@@ -12,21 +12,15 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.openclimb.R
|
||||||
import com.atridad.openclimb.data.model.Gym
|
import com.atridad.openclimb.data.model.Gym
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun GymsScreen(
|
fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Unit) {
|
||||||
viewModel: ClimbViewModel,
|
|
||||||
onNavigateToGymDetail: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -41,8 +35,10 @@ fun GymsScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Climbing Gyms",
|
text = "Climbing Gyms",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -57,10 +53,7 @@ fun GymsScreen(
|
|||||||
} else {
|
} else {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(gyms) { gym ->
|
items(gyms) { gym ->
|
||||||
GymCard(
|
GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) })
|
||||||
gym = gym,
|
|
||||||
onClick = { onNavigateToGymDetail(gym.id) }
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,19 +63,9 @@ fun GymsScreen(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun GymCard(
|
fun GymCard(gym: Gym, onClick: () -> Unit) {
|
||||||
gym: Gym,
|
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||||
onClick: () -> Unit
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = gym.name,
|
text = gym.name,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
@@ -104,9 +87,7 @@ fun GymCard(
|
|||||||
gym.supportedClimbTypes.forEach { climbType ->
|
gym.supportedClimbTypes.forEach { climbType ->
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
label = {
|
label = { Text(climbType.getDisplayName()) },
|
||||||
Text(climbType.getDisplayName())
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(end = 4.dp)
|
modifier = Modifier.padding(end = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -115,7 +96,8 @@ fun GymCard(
|
|||||||
if (gym.difficultySystems.isNotEmpty()) {
|
if (gym.difficultySystems.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
text =
|
||||||
|
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.atridad.openclimb.data.model.Gym
|
|||||||
import com.atridad.openclimb.data.model.Problem
|
import com.atridad.openclimb.data.model.Problem
|
||||||
import com.atridad.openclimb.ui.components.FullscreenImageViewer
|
import com.atridad.openclimb.ui.components.FullscreenImageViewer
|
||||||
import com.atridad.openclimb.ui.components.ImageDisplay
|
import com.atridad.openclimb.ui.components.ImageDisplay
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -58,10 +59,12 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Problems & Routes",
|
text = "Climbing Problems",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@@ -20,16 +20,14 @@ import com.atridad.openclimb.R
|
|||||||
import com.atridad.openclimb.data.model.ClimbSession
|
import com.atridad.openclimb.data.model.ClimbSession
|
||||||
import com.atridad.openclimb.data.model.SessionStatus
|
import com.atridad.openclimb.data.model.SessionStatus
|
||||||
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionsScreen(
|
fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String) -> Unit) {
|
||||||
viewModel: ClimbViewModel,
|
|
||||||
onNavigateToSessionDetail: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sessions by viewModel.sessions.collectAsState()
|
val sessions by viewModel.sessions.collectAsState()
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
@@ -38,15 +36,9 @@ fun SessionsScreen(
|
|||||||
|
|
||||||
// Filter out active sessions from regular session list
|
// Filter out active sessions from regular session list
|
||||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||||
val activeSessionGym = activeSession?.let { session ->
|
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
||||||
gyms.find { it.id == session.gymId }
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -61,8 +53,10 @@ fun SessionsScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Climbing Sessions",
|
text = "Climbing Sessions",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -71,14 +65,8 @@ fun SessionsScreen(
|
|||||||
ActiveSessionBanner(
|
ActiveSessionBanner(
|
||||||
activeSession = activeSession,
|
activeSession = activeSession,
|
||||||
gym = activeSessionGym,
|
gym = activeSessionGym,
|
||||||
onSessionClick = {
|
onSessionClick = { activeSession?.let { onNavigateToSessionDetail(it.id) } },
|
||||||
activeSession?.let { onNavigateToSessionDetail(it.id) }
|
onEndSession = { activeSession?.let { viewModel.endSession(context, it.id) } }
|
||||||
},
|
|
||||||
onEndSession = {
|
|
||||||
activeSession?.let {
|
|
||||||
viewModel.endSession(context, it.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (activeSession != null) {
|
if (activeSession != null) {
|
||||||
@@ -88,7 +76,10 @@ fun SessionsScreen(
|
|||||||
if (completedSessions.isEmpty() && activeSession == null) {
|
if (completedSessions.isEmpty() && activeSession == null) {
|
||||||
EmptyStateMessage(
|
EmptyStateMessage(
|
||||||
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
|
||||||
message = if (gyms.isEmpty()) "Add a gym first to start tracking your climbing sessions!" else "Start your first climbing session!",
|
message =
|
||||||
|
if (gyms.isEmpty())
|
||||||
|
"Add a gym first to start tracking your climbing sessions!"
|
||||||
|
else "Start your first climbing session!",
|
||||||
onActionClick = {},
|
onActionClick = {},
|
||||||
actionText = ""
|
actionText = ""
|
||||||
)
|
)
|
||||||
@@ -114,18 +105,15 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
colors =
|
||||||
.padding(16.dp),
|
CardDefaults.cardColors(
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -150,18 +138,15 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
colors =
|
||||||
.padding(16.dp),
|
CardDefaults.cardColors(
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -182,20 +167,9 @@ fun SessionsScreen(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionCard(
|
fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
|
||||||
session: ClimbSession,
|
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||||
gymName: String,
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
@@ -267,9 +241,7 @@ fun EmptyStateMessage(
|
|||||||
if (actionText.isNotEmpty()) {
|
if (actionText.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Button(onClick = onActionClick) {
|
Button(onClick = onActionClick) { Text(actionText) }
|
||||||
Text(actionText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.res.painterResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.openclimb.R
|
||||||
|
import com.atridad.openclimb.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -122,153 +123,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
Text(
|
Text(
|
||||||
text = "Settings",
|
text = "Settings",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
|
||||||
|
|
||||||
// Data Management Section
|
|
||||||
item {
|
|
||||||
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Data Management",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// Export Data
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor =
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
|
||||||
alpha = 0.3f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Export Data with Images") },
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
"Export all your climbing data and images to ZIP file (recommended)"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(Icons.Default.Share, contentDescription = null)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
val defaultFileName =
|
|
||||||
"openclimb_export_${
|
|
||||||
java.time.LocalDateTime.now()
|
|
||||||
.toString()
|
|
||||||
.replace(":", "-")
|
|
||||||
.replace(".", "-")
|
|
||||||
}.zip"
|
|
||||||
exportZipLauncher.launch(defaultFileName)
|
|
||||||
},
|
|
||||||
enabled = !uiState.isLoading
|
|
||||||
) {
|
|
||||||
if (uiState.isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Export ZIP")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor =
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
|
||||||
alpha = 0.3f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Import Data") },
|
|
||||||
supportingContent = {
|
|
||||||
Text("Import climbing data from ZIP file (recommended format)")
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(Icons.Default.Add, contentDescription = null)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { importLauncher.launch("application/zip") },
|
|
||||||
enabled = !uiState.isLoading
|
|
||||||
) {
|
|
||||||
if (uiState.isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Import")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor =
|
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(
|
|
||||||
alpha = 0.3f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Reset All Data") },
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
"Permanently delete all gyms, problems, sessions, attempts, and images"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showResetDialog = true },
|
|
||||||
enabled = !uiState.isLoading
|
|
||||||
) {
|
|
||||||
if (uiState.isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Reset", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +176,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
"Last sync: ${
|
"Last sync: ${
|
||||||
try {
|
try {
|
||||||
Instant.parse(time).toString()
|
Instant.parse(time).toString()
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
time
|
time
|
||||||
}
|
}
|
||||||
}",
|
}",
|
||||||
@@ -510,6 +368,151 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Data Management Section
|
||||||
|
item {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Data Management",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Export Data
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||||
|
alpha = 0.3f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Export Data with Images") },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
"Export all your climbing data and images to ZIP file (recommended)"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(Icons.Default.Share, contentDescription = null)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val defaultFileName =
|
||||||
|
"openclimb_export_${
|
||||||
|
java.time.LocalDateTime.now()
|
||||||
|
.toString()
|
||||||
|
.replace(":", "-")
|
||||||
|
.replace(".", "-")
|
||||||
|
}.zip"
|
||||||
|
exportZipLauncher.launch(defaultFileName)
|
||||||
|
},
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Export ZIP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||||
|
alpha = 0.3f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Import Data") },
|
||||||
|
supportingContent = {
|
||||||
|
Text("Import climbing data from ZIP file (recommended format)")
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { importLauncher.launch("application/zip") },
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Import")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
MaterialTheme.colorScheme.errorContainer.copy(
|
||||||
|
alpha = 0.3f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Reset All Data") },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
"Permanently delete all gyms, problems, sessions, attempts, and images"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { showResetDialog = true },
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Reset", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// App Information Section
|
// App Information Section
|
||||||
item {
|
item {
|
||||||
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
||||||
@@ -754,7 +757,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
syncService.authToken = authToken.trim()
|
syncService.authToken = authToken.trim()
|
||||||
viewModel.testSyncConnection()
|
viewModel.testSyncConnection()
|
||||||
showSyncConfigDialog = false
|
showSyncConfigDialog = false
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// Error will be shown via syncError state
|
// Error will be shown via syncError state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -1,14 +1,9 @@
|
|||||||
# OpenClimb Sync Server Configuration
|
# Required
|
||||||
|
|
||||||
# Required: Secret token for authentication
|
|
||||||
# Generate a secure random token and share it between your apps and server
|
|
||||||
AUTH_TOKEN=your-secure-secret-token-here
|
AUTH_TOKEN=your-secure-secret-token-here
|
||||||
|
IMAGE="git.atri.dad/atridad/openclimb-sync:latest"
|
||||||
|
APP_PORT=1337
|
||||||
|
ROOT_DIR="./data"
|
||||||
|
|
||||||
# Optional: Port to run the server on (default: 8080)
|
# Optional
|
||||||
PORT=8080
|
DATA_FILE=/data/data.json
|
||||||
|
IMAGES_DIR=/data/images
|
||||||
# Optional: Path to store the sync data (default: ./data/climb_data.json)
|
|
||||||
DATA_FILE=./data/climb_data.json
|
|
||||||
|
|
||||||
# Optional: Directory to store images (default: ./data/images)
|
|
||||||
IMAGES_DIR=./data/images
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ services:
|
|||||||
openclimb-sync:
|
openclimb-sync:
|
||||||
image: ${IMAGE}
|
image: ${IMAGE}
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- ${APP_PORT}:8080
|
||||||
environment:
|
environment:
|
||||||
- AUTH_TOKEN=${AUTH_TOKEN:-your-secret-token-here}
|
- AUTH_TOKEN=${AUTH_TOKEN}
|
||||||
- DATA_FILE=/data/climb_data.json
|
- DATA_FILE=${DATA_FILE}
|
||||||
- IMAGES_DIR=/data/images
|
- IMAGES_DIR=${IMAGES_DIR}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ${ROOT_DIR}:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user