Compare commits
6 Commits
ANDROID_2.
...
ANDROID_2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
c2f95f2793
|
|||
|
b7a3c98b2c
|
|||
|
fed9bab2ea
|
|||
|
862622b07b
|
|||
|
eba503eb5e
|
|||
|
8c4a78ad50
|
@@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.atridad.ascently"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 44
|
||||
versionName = "2.2.0"
|
||||
versionCode = 46
|
||||
versionName = "2.2.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -368,9 +368,22 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
repository.setAutoSyncCallback(null)
|
||||
|
||||
try {
|
||||
// Merge and apply deletions first to prevent resurrection
|
||||
val allDeletions = repository.getDeletedItems() + response.deletedItems
|
||||
val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" }
|
||||
|
||||
Log.d(TAG, "Applying ${uniqueDeletions.size} deletion records before merging data")
|
||||
applyDeletions(uniqueDeletions)
|
||||
|
||||
// Build deleted item lookup set
|
||||
val deletedItemSet = uniqueDeletions.map { "${it.type}:${it.id}" }.toSet()
|
||||
|
||||
// Download images for new/modified problems from server
|
||||
val imagePathMapping = mutableMapOf<String, String>()
|
||||
for (problem in response.problems) {
|
||||
if (deletedItemSet.contains("problem:${problem.id}")) {
|
||||
continue
|
||||
}
|
||||
problem.imagePaths?.forEach { imagePath ->
|
||||
val serverFilename = imagePath.substringAfterLast('/')
|
||||
try {
|
||||
@@ -384,9 +397,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
}
|
||||
|
||||
// Merge gyms - check if exists and compare timestamps
|
||||
// Merge gyms
|
||||
val existingGyms = repository.getAllGyms().first()
|
||||
for (backupGym in response.gyms) {
|
||||
if (deletedItemSet.contains("gym:${backupGym.id}")) {
|
||||
continue
|
||||
}
|
||||
val existing = existingGyms.find { it.id == backupGym.id }
|
||||
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
|
||||
val gym = backupGym.toGym()
|
||||
@@ -401,6 +417,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
// Merge problems
|
||||
val existingProblems = repository.getAllProblems().first()
|
||||
for (backupProblem in response.problems) {
|
||||
if (deletedItemSet.contains("problem:${backupProblem.id}")) {
|
||||
continue
|
||||
}
|
||||
val updatedImagePaths =
|
||||
backupProblem.imagePaths?.map { oldPath ->
|
||||
imagePathMapping[oldPath] ?: oldPath
|
||||
@@ -421,6 +440,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
// Merge sessions
|
||||
val existingSessions = repository.getAllSessions().first()
|
||||
for (backupSession in response.sessions) {
|
||||
if (deletedItemSet.contains("session:${backupSession.id}")) {
|
||||
continue
|
||||
}
|
||||
val session = backupSession.toClimbSession()
|
||||
val existing = existingSessions.find { it.id == backupSession.id }
|
||||
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
|
||||
@@ -435,6 +457,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
// Merge attempts
|
||||
val existingAttempts = repository.getAllAttempts().first()
|
||||
for (backupAttempt in response.attempts) {
|
||||
if (deletedItemSet.contains("attempt:${backupAttempt.id}")) {
|
||||
continue
|
||||
}
|
||||
val attempt = backupAttempt.toAttempt()
|
||||
val existing = existingAttempts.find { it.id == backupAttempt.id }
|
||||
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
|
||||
@@ -446,15 +471,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
}
|
||||
|
||||
// Apply deletions
|
||||
applyDeletions(response.deletedItems)
|
||||
// Apply deletions again for safety
|
||||
applyDeletions(uniqueDeletions)
|
||||
|
||||
// Update deletion records
|
||||
val allDeletions = repository.getDeletedItems() + response.deletedItems
|
||||
repository.clearDeletedItems()
|
||||
allDeletions.distinctBy { "${it.type}:${it.id}" }.forEach {
|
||||
repository.trackDeletion(it.id, it.type)
|
||||
}
|
||||
uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) }
|
||||
} finally {
|
||||
// Re-enable auto-sync
|
||||
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
|
||||
@@ -542,7 +564,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
Request.Builder()
|
||||
.url("$serverUrl/sync")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.post(requestBody)
|
||||
.put(requestBody)
|
||||
.build()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -12,35 +12,36 @@ import com.atridad.ascently.MainActivity
|
||||
import com.atridad.ascently.R
|
||||
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||
import com.atridad.ascently.data.repository.ClimbRepository
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class SessionTrackingService : Service() {
|
||||
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var notificationJob: Job? = null
|
||||
private var monitoringJob: Job? = null
|
||||
|
||||
|
||||
private lateinit var repository: ClimbRepository
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
|
||||
|
||||
companion object {
|
||||
const val NOTIFICATION_ID = 1001
|
||||
const val CHANNEL_ID = "session_tracking_channel"
|
||||
const val ACTION_START_SESSION = "start_session"
|
||||
const val ACTION_STOP_SESSION = "stop_session"
|
||||
const val EXTRA_SESSION_ID = "session_id"
|
||||
|
||||
|
||||
fun createStartIntent(context: Context, sessionId: String): Intent {
|
||||
return Intent(context, SessionTrackingService::class.java).apply {
|
||||
action = ACTION_START_SESSION
|
||||
putExtra(EXTRA_SESSION_ID, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun createStopIntent(context: Context, sessionId: String): Intent {
|
||||
return Intent(context, SessionTrackingService::class.java).apply {
|
||||
action = ACTION_STOP_SESSION
|
||||
@@ -48,17 +49,17 @@ class SessionTrackingService : Service() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
|
||||
val database = AscentlyDatabase.getDatabase(this)
|
||||
repository = ClimbRepository(database, this)
|
||||
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START_SESSION -> {
|
||||
@@ -71,12 +72,19 @@ class SessionTrackingService : Service() {
|
||||
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val targetSession = when {
|
||||
sessionId != null -> repository.getSessionById(sessionId)
|
||||
else -> repository.getActiveSession()
|
||||
}
|
||||
if (targetSession != null && targetSession.status == com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
|
||||
val completed = with(com.atridad.ascently.data.model.ClimbSession) { targetSession.complete() }
|
||||
val targetSession =
|
||||
when {
|
||||
sessionId != null -> repository.getSessionById(sessionId)
|
||||
else -> repository.getActiveSession()
|
||||
}
|
||||
if (targetSession != null &&
|
||||
targetSession.status ==
|
||||
com.atridad.ascently.data.model.SessionStatus.ACTIVE
|
||||
) {
|
||||
val completed =
|
||||
with(com.atridad.ascently.data.model.ClimbSession) {
|
||||
targetSession.complete()
|
||||
}
|
||||
repository.updateSession(completed)
|
||||
}
|
||||
} finally {
|
||||
@@ -90,61 +98,71 @@ class SessionTrackingService : Service() {
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
|
||||
private fun startSessionTracking(sessionId: String) {
|
||||
notificationJob?.cancel()
|
||||
monitoringJob?.cancel()
|
||||
|
||||
|
||||
try {
|
||||
createAndShowNotification(sessionId)
|
||||
// Update widget when session tracking starts
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(this)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
notificationJob = serviceScope.launch {
|
||||
try {
|
||||
if (!isNotificationActive()) {
|
||||
delay(1000L)
|
||||
createAndShowNotification(sessionId)
|
||||
}
|
||||
|
||||
while (isActive) {
|
||||
delay(5000L)
|
||||
updateNotification(sessionId)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
monitoringJob = serviceScope.launch {
|
||||
try {
|
||||
while (isActive) {
|
||||
delay(10000L)
|
||||
|
||||
if (!isNotificationActive()) {
|
||||
updateNotification(sessionId)
|
||||
}
|
||||
|
||||
val session = repository.getSessionById(sessionId)
|
||||
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
|
||||
stopSessionTracking()
|
||||
break
|
||||
|
||||
notificationJob =
|
||||
serviceScope.launch {
|
||||
try {
|
||||
if (!isNotificationActive()) {
|
||||
delay(1000L)
|
||||
createAndShowNotification(sessionId)
|
||||
}
|
||||
|
||||
while (isActive) {
|
||||
delay(5000L)
|
||||
updateNotification(sessionId)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
monitoringJob =
|
||||
serviceScope.launch {
|
||||
try {
|
||||
while (isActive) {
|
||||
delay(10000L)
|
||||
|
||||
if (!isNotificationActive()) {
|
||||
updateNotification(sessionId)
|
||||
}
|
||||
|
||||
val session = repository.getSessionById(sessionId)
|
||||
if (session == null ||
|
||||
session.status !=
|
||||
com.atridad.ascently.data.model.SessionStatus
|
||||
.ACTIVE
|
||||
) {
|
||||
stopSessionTracking()
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun stopSessionTracking() {
|
||||
notificationJob?.cancel()
|
||||
monitoringJob?.cancel()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
// Update widget when session tracking stops
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(this)
|
||||
}
|
||||
|
||||
|
||||
private fun isNotificationActive(): Boolean {
|
||||
return try {
|
||||
val activeNotifications = notificationManager.activeNotifications
|
||||
@@ -153,10 +171,12 @@ class SessionTrackingService : Service() {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun updateNotification(sessionId: String) {
|
||||
try {
|
||||
createAndShowNotification(sessionId)
|
||||
// Update widget when notification updates
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(this)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
@@ -169,116 +189,121 @@ class SessionTrackingService : Service() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun createAndShowNotification(sessionId: String) {
|
||||
try {
|
||||
val session = runBlocking {
|
||||
repository.getSessionById(sessionId)
|
||||
}
|
||||
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
|
||||
val session = runBlocking { repository.getSessionById(sessionId) }
|
||||
if (session == null ||
|
||||
session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE
|
||||
) {
|
||||
stopSessionTracking()
|
||||
return
|
||||
}
|
||||
|
||||
val gym = runBlocking {
|
||||
repository.getGymById(session.gymId)
|
||||
}
|
||||
|
||||
|
||||
val gym = runBlocking { repository.getGymById(session.gymId) }
|
||||
|
||||
val attempts = runBlocking {
|
||||
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
|
||||
}
|
||||
|
||||
val duration = session.startTime?.let { startTime ->
|
||||
try {
|
||||
val start = LocalDateTime.parse(startTime)
|
||||
val now = LocalDateTime.now()
|
||||
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
|
||||
when {
|
||||
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||
else -> "${totalSeconds}s"
|
||||
|
||||
val duration =
|
||||
session.startTime?.let { startTime ->
|
||||
try {
|
||||
val start = LocalDateTime.parse(startTime)
|
||||
val now = LocalDateTime.now()
|
||||
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
|
||||
when {
|
||||
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||
else -> "${totalSeconds}s"
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
"Active"
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
"Active"
|
||||
}
|
||||
} ?: "Active"
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Climbing Session Active")
|
||||
.setContentText("${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts")
|
||||
.setSmallIcon(R.drawable.ic_mountains)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentIntent(createOpenAppIntent())
|
||||
.addAction(
|
||||
R.drawable.ic_mountains,
|
||||
"Open Session",
|
||||
createOpenAppIntent()
|
||||
)
|
||||
.addAction(
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
"End Session",
|
||||
createStopPendingIntent(sessionId)
|
||||
)
|
||||
.build()
|
||||
|
||||
?: "Active"
|
||||
|
||||
val notification =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Climbing Session Active")
|
||||
.setContentText(
|
||||
"${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts"
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_mountains)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentIntent(createOpenAppIntent())
|
||||
.addAction(
|
||||
R.drawable.ic_mountains,
|
||||
"Open Session",
|
||||
createOpenAppIntent()
|
||||
)
|
||||
.addAction(
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
"End Session",
|
||||
createStopPendingIntent(sessionId)
|
||||
)
|
||||
.build()
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun createOpenAppIntent(): PendingIntent {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
action = "OPEN_SESSION"
|
||||
}
|
||||
val intent =
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
action = "OPEN_SESSION"
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun createStopPendingIntent(sessionId: String): PendingIntent {
|
||||
val intent = createStopIntent(this, sessionId)
|
||||
return PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
this,
|
||||
1,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Session Tracking",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "Shows active climbing session information"
|
||||
setShowBadge(false)
|
||||
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
|
||||
enableLights(false)
|
||||
enableVibration(false)
|
||||
setSound(null, null)
|
||||
}
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Session Tracking",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
.apply {
|
||||
description = "Shows active climbing session information"
|
||||
setShowBadge(false)
|
||||
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
|
||||
enableLights(false)
|
||||
enableVibration(false)
|
||||
setSound(null, null)
|
||||
}
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
notificationJob?.cancel()
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import com.atridad.ascently.data.model.*
|
||||
import com.atridad.ascently.ui.components.FullscreenImageViewer
|
||||
import com.atridad.ascently.ui.components.ImageDisplaySection
|
||||
import com.atridad.ascently.ui.components.ImagePicker
|
||||
import com.atridad.ascently.ui.theme.CustomIcons
|
||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
@@ -1489,6 +1490,7 @@ fun EnhancedAddAttemptDialog(
|
||||
// New problem creation state
|
||||
var newProblemName by remember { mutableStateOf("") }
|
||||
var newProblemGrade by remember { mutableStateOf("") }
|
||||
var newProblemImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) }
|
||||
var selectedDifficultySystem by remember {
|
||||
mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE)
|
||||
@@ -1690,7 +1692,14 @@ fun EnhancedAddAttemptDialog(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
IconButton(onClick = { showCreateProblem = false }) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
showCreateProblem = false
|
||||
newProblemName = ""
|
||||
newProblemGrade = ""
|
||||
newProblemImagePaths = emptyList()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
@@ -1905,6 +1914,21 @@ fun EnhancedAddAttemptDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Photos Section
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Photos (Optional)",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
ImagePicker(
|
||||
imageUris = newProblemImagePaths,
|
||||
onImagesChanged = { newProblemImagePaths = it },
|
||||
maxImages = 5
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2069,7 +2093,9 @@ fun EnhancedAddAttemptDialog(
|
||||
null
|
||||
},
|
||||
climbType = selectedClimbType,
|
||||
difficulty = difficulty
|
||||
difficulty = difficulty,
|
||||
imagePaths =
|
||||
newProblemImagePaths
|
||||
)
|
||||
|
||||
onProblemCreated(newProblem)
|
||||
@@ -2087,6 +2113,12 @@ fun EnhancedAddAttemptDialog(
|
||||
notes = notes.ifBlank { null }
|
||||
)
|
||||
onAttemptAdded(attempt)
|
||||
|
||||
// Reset form
|
||||
newProblemName = ""
|
||||
newProblemGrade = ""
|
||||
newProblemImagePaths = emptyList()
|
||||
showCreateProblem = false
|
||||
}
|
||||
} else {
|
||||
// Create attempt for selected problem
|
||||
|
||||
@@ -338,18 +338,6 @@ fun CalendarView(
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (activeSession != null && activeSessionGym != null) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
ActiveSessionBanner(
|
||||
activeSession = activeSession,
|
||||
gym = activeSessionGym,
|
||||
onSessionClick = { onNavigateToSessionDetail(activeSession.id) },
|
||||
onEndSession = onEndSession
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.atridad.ascently.MainActivity
|
||||
import com.atridad.ascently.R
|
||||
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||
import com.atridad.ascently.data.repository.ClimbRepository
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -48,53 +49,47 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
||||
val database = AscentlyDatabase.getDatabase(context)
|
||||
val repository = ClimbRepository(database, context)
|
||||
|
||||
// Fetch stats data
|
||||
// Get last 7 days date range (rolling period)
|
||||
val today = LocalDate.now()
|
||||
val sevenDaysAgo = today.minusDays(6) // Today + 6 days ago = 7 days total
|
||||
|
||||
// Fetch all sessions and attempts
|
||||
val sessions = repository.getAllSessions().first()
|
||||
val problems = repository.getAllProblems().first()
|
||||
val attempts = repository.getAllAttempts().first()
|
||||
val gyms = repository.getAllGyms().first()
|
||||
|
||||
// Calculate stats
|
||||
val completedSessions = sessions.filter { it.endTime != null }
|
||||
|
||||
// Count problems that have been completed (have at least one successful attempt)
|
||||
val completedProblems =
|
||||
problems
|
||||
.filter { problem ->
|
||||
attempts.any { attempt ->
|
||||
attempt.problemId == problem.id &&
|
||||
(attempt.result ==
|
||||
com.atridad.ascently.data.model
|
||||
.AttemptResult.SUCCESS ||
|
||||
attempt.result ==
|
||||
com.atridad.ascently.data.model
|
||||
.AttemptResult.FLASH)
|
||||
}
|
||||
}
|
||||
.size
|
||||
|
||||
val favoriteGym =
|
||||
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
|
||||
(gymId, _) ->
|
||||
gyms.find { it.id == gymId }?.name
|
||||
// Filter for last 7 days across all gyms
|
||||
val weekSessions =
|
||||
sessions.filter { session ->
|
||||
try {
|
||||
val sessionDate = LocalDate.parse(session.date.substring(0, 10))
|
||||
!sessionDate.isBefore(sevenDaysAgo) && !sessionDate.isAfter(today)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
?: "No sessions yet"
|
||||
|
||||
val weekSessionIds = weekSessions.map { it.id }.toSet()
|
||||
|
||||
// Count total attempts this week
|
||||
val totalAttempts =
|
||||
attempts.count { attempt -> weekSessionIds.contains(attempt.sessionId) }
|
||||
|
||||
// Count sessions this week
|
||||
val totalSessions = weekSessions.size
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
|
||||
|
||||
views.setTextViewText(
|
||||
R.id.widget_total_sessions,
|
||||
completedSessions.size.toString()
|
||||
)
|
||||
views.setTextViewText(
|
||||
R.id.widget_problems_completed,
|
||||
completedProblems.toString()
|
||||
)
|
||||
views.setTextViewText(R.id.widget_total_problems, problems.size.toString())
|
||||
views.setTextViewText(R.id.widget_favorite_gym, favoriteGym)
|
||||
// Set weekly stats
|
||||
views.setTextViewText(R.id.widget_attempts_value, totalAttempts.toString())
|
||||
views.setTextViewText(R.id.widget_sessions_value, totalSessions.toString())
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
val intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
flags =
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
@@ -110,10 +105,8 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
||||
} catch (_: Exception) {
|
||||
launch(Dispatchers.Main) {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
|
||||
views.setTextViewText(R.id.widget_total_sessions, "0")
|
||||
views.setTextViewText(R.id.widget_problems_completed, "0")
|
||||
views.setTextViewText(R.id.widget_total_problems, "0")
|
||||
views.setTextViewText(R.id.widget_favorite_gym, "No data")
|
||||
views.setTextViewText(R.id.widget_attempts_value, "0")
|
||||
views.setTextViewText(R.id.widget_sessions_value, "0")
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
val pendingIntent =
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2ZM10,17L5,12L6.41,10.59L10,14.17L17.59,6.58L19,8L10,17Z"/>
|
||||
</vector>
|
||||
9
android/app/src/main/res/drawable/ic_circle_filled.xml
Normal file
9
android/app/src/main/res/drawable/ic_circle_filled.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M9,11.24V7.5C9,6.12 10.12,5 11.5,5S14,6.12 14,7.5v3.74c1.21,-0.81 2,-2.18 2,-3.74C16,5.01 13.99,3 11.5,3S7,5.01 7,7.5C7,9.06 7.79,10.43 9,11.24zM18.84,15.87l-4.54,-2.26c-0.17,-0.07 -0.35,-0.11 -0.54,-0.11H13v-6C13,6.67 12.33,6 11.5,6S10,6.67 10,7.5v10.74l-3.43,-0.72c-0.08,-0.01 -0.15,-0.03 -0.24,-0.03c-0.31,0 -0.59,0.13 -0.79,0.33l-0.79,0.8l4.94,4.94C9.96,23.83 10.34,24 10.75,24h6.79c0.75,0 1.33,-0.55 1.44,-1.28l0.75,-5.27c0.01,-0.07 0.02,-0.14 0.02,-0.2C19.75,16.63 19.37,16.09 18.84,15.87z"/>
|
||||
</vector>
|
||||
@@ -5,190 +5,84 @@
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_background"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
android:padding="12dp"
|
||||
android:gravity="center">
|
||||
|
||||
<!-- Header -->
|
||||
<!-- Header with icon and "Weekly" text -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:src="@drawable/ic_mountains"
|
||||
android:tint="@color/widget_primary"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Ascently"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_text_primary" />
|
||||
android:layout_marginEnd="8dp"
|
||||
android:contentDescription="@string/ascently_icon" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Climbing Stats"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_text_secondary" />
|
||||
android:text="@string/weekly"
|
||||
android:textSize="18sp"
|
||||
android:textColor="@color/widget_text_primary" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<!-- Attempts Row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<!-- Top Row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:src="@drawable/ic_circle_filled"
|
||||
android:tint="@color/widget_primary"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:contentDescription="Attempts icon" />
|
||||
|
||||
<!-- Sessions Card -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/widget_stat_card_background"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:padding="12dp">
|
||||
<TextView
|
||||
android:id="@+id/widget_attempts_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="40sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_text_primary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_total_sessions"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sessions"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:layout_marginTop="2dp" />
|
||||
<!-- Sessions Row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
</LinearLayout>
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:src="@drawable/ic_play_arrow_24"
|
||||
android:tint="@color/widget_primary"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:contentDescription="@string/sessions_icon" />
|
||||
|
||||
<!-- Problems Card -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/widget_stat_card_background"
|
||||
android:layout_marginStart="4dp"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_problems_completed"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_primary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Completed"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Bottom Row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<!-- Success Rate Card -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/widget_stat_card_background"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_total_problems"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_secondary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Problems"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Favorite Gym Card -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/widget_stat_card_background"
|
||||
android:layout_marginStart="4dp"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_favorite_gym"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="No gyms"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:gravity="center"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Favorite"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
<TextView
|
||||
android:id="@+id/widget_sessions_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/_0"
|
||||
android:textSize="40sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_text_primary" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -12,5 +12,9 @@
|
||||
<string name="shortcut_end_session_disabled">No active session to end</string>
|
||||
|
||||
<!-- Widget -->
|
||||
<string name="widget_description">View your climbing stats at a glance</string>
|
||||
<string name="widget_description">View your weekly climbing stats</string>
|
||||
<string name="ascently_icon">Ascently icon</string>
|
||||
<string name="weekly">Weekly</string>
|
||||
<string name="sessions_icon">Sessions icon</string>
|
||||
<string name="_0">0</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
android:description="@string/widget_description"
|
||||
android:initialKeyguardLayout="@layout/widget_climb_stats"
|
||||
android:initialLayout="@layout/widget_climb_stats"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="180dp"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:maxResizeWidth="110dp"
|
||||
android:maxResizeHeight="110dp"
|
||||
android:previewImage="@drawable/ic_mountains"
|
||||
android:previewLayout="@layout/widget_climb_stats"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:targetCellWidth="4"
|
||||
android:resizeMode="none"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:maxResizeWidth="320dp"
|
||||
android:maxResizeHeight="240dp" />
|
||||
android:widgetCategory="home_screen" />
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.5.0",
|
||||
"@astrojs/starlight": "^0.36.1",
|
||||
"astro": "^5.14.5",
|
||||
"astro": "^5.14.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.5"
|
||||
}
|
||||
}
|
||||
|
||||
488
docs/pnpm-lock.yaml
generated
488
docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
155
docs/src/components/DownloadButtons.astro
Normal file
155
docs/src/components/DownloadButtons.astro
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
import { Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
import { Card, CardGrid } from "@astrojs/starlight/components";
|
||||
import { LinkButton } from "@astrojs/starlight/components";
|
||||
import { Badge } from "@astrojs/starlight/components";
|
||||
import QRCode from "./QRCode.astro";
|
||||
import { downloadLinks, requirements } from "../config";
|
||||
|
||||
interface Props {
|
||||
showQR?: boolean;
|
||||
}
|
||||
|
||||
const { showQR = false } = Astro.props;
|
||||
|
||||
const hasLink = (link: string | undefined) => link && link.trim() !== "";
|
||||
---
|
||||
|
||||
<Tabs syncKey="platform">
|
||||
<TabItem label="Android" icon="star">
|
||||
<CardGrid>
|
||||
{
|
||||
hasLink(downloadLinks.android.playStore) && (
|
||||
<Card title="Google Play Store" icon="star">
|
||||
<p style="text-align: center;">
|
||||
<LinkButton
|
||||
href={downloadLinks.android.playStore}
|
||||
variant="primary"
|
||||
icon="external"
|
||||
>
|
||||
Get on Play Store
|
||||
</LinkButton>
|
||||
</p>
|
||||
{showQR && (
|
||||
<p style="text-align: center;">
|
||||
<QRCode
|
||||
data={downloadLinks.android.playStore}
|
||||
size={200}
|
||||
alt="QR code for Play Store"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
hasLink(downloadLinks.android.obtainium) && (
|
||||
<Card title="Obtainium" icon="rocket">
|
||||
<p style="text-align: center;">
|
||||
<LinkButton
|
||||
href={downloadLinks.android.obtainium}
|
||||
variant="primary"
|
||||
icon="external"
|
||||
>
|
||||
Get on Obtainium
|
||||
</LinkButton>
|
||||
</p>
|
||||
{showQR && (
|
||||
<p style="text-align: center;">
|
||||
<QRCode
|
||||
data={downloadLinks.android.obtainium}
|
||||
size={200}
|
||||
alt="QR code for Obtainium"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
hasLink(downloadLinks.android.releases) && (
|
||||
<Card title="Direct Download" icon="download">
|
||||
<p style="text-align: center;">
|
||||
<LinkButton
|
||||
href={downloadLinks.android.releases}
|
||||
variant="secondary"
|
||||
icon="external"
|
||||
>
|
||||
Download APK
|
||||
</LinkButton>
|
||||
</p>
|
||||
{showQR && (
|
||||
<p style="text-align: center;">
|
||||
<QRCode
|
||||
data={downloadLinks.android.releases}
|
||||
size={200}
|
||||
alt="QR code for APK download"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
</CardGrid>
|
||||
|
||||
<p><strong>Requirements:</strong> {requirements.android}</p>
|
||||
</TabItem>
|
||||
|
||||
<TabItem label="iOS" icon="apple">
|
||||
<CardGrid>
|
||||
{
|
||||
hasLink(downloadLinks.ios.appStore) && (
|
||||
<Card title="App Store" icon="rocket">
|
||||
<p style="text-align: center;">
|
||||
<LinkButton
|
||||
href={downloadLinks.ios.appStore}
|
||||
variant="primary"
|
||||
icon="external"
|
||||
>
|
||||
Download on App Store
|
||||
</LinkButton>
|
||||
</p>
|
||||
{showQR && (
|
||||
<p style="text-align: center;">
|
||||
<QRCode
|
||||
data={downloadLinks.ios.appStore}
|
||||
size={200}
|
||||
alt="QR code for App Store"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
hasLink(downloadLinks.ios.testFlight) && (
|
||||
<Card title="TestFlight Beta" icon="warning">
|
||||
<p style="text-align: center;">
|
||||
<LinkButton
|
||||
href={downloadLinks.ios.testFlight}
|
||||
variant="secondary"
|
||||
icon="external"
|
||||
>
|
||||
Join TestFlight
|
||||
</LinkButton>
|
||||
</p>
|
||||
{showQR && (
|
||||
<p style="text-align: center;">
|
||||
<QRCode
|
||||
data={downloadLinks.ios.testFlight}
|
||||
size={200}
|
||||
alt="QR code for TestFlight"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
</CardGrid>
|
||||
|
||||
<p><strong>Requirements:</strong> {requirements.ios}</p>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
111
docs/src/components/QRCode.astro
Normal file
111
docs/src/components/QRCode.astro
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
import * as QR from "qrcode";
|
||||
|
||||
interface Props {
|
||||
data: string;
|
||||
size?: number;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
const { data, size = 200, alt = "QR Code" } = Astro.props;
|
||||
|
||||
// Generate QR code for dark mode
|
||||
let darkModeQR = "";
|
||||
try {
|
||||
darkModeQR = await QR.toDataURL(data, {
|
||||
width: size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: "#FFBF00",
|
||||
light: "#17181C",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to generate dark mode QR code:", err);
|
||||
}
|
||||
|
||||
// Generate QR code for light mode
|
||||
let lightModeQR = "";
|
||||
try {
|
||||
lightModeQR = await QR.toDataURL(data, {
|
||||
width: size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: "#F24B3C",
|
||||
light: "#FFFFFF",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to generate light mode QR code:", err);
|
||||
}
|
||||
|
||||
const uniqueId = `qr-${Math.random().toString(36).substr(2, 9)}`;
|
||||
---
|
||||
|
||||
{
|
||||
(darkModeQR || lightModeQR) && (
|
||||
<img
|
||||
id={uniqueId}
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
data-light-src={lightModeQR}
|
||||
data-dark-src={darkModeQR}
|
||||
style="margin: auto;"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<script is:inline define:vars={{ uniqueId, lightModeQR, darkModeQR }}>
|
||||
(function () {
|
||||
const img = document.getElementById(uniqueId);
|
||||
if (!img) return;
|
||||
|
||||
const theme = document.documentElement.getAttribute("data-theme");
|
||||
if (theme === "dark" && darkModeQR) {
|
||||
img.setAttribute("src", darkModeQR);
|
||||
} else if (lightModeQR) {
|
||||
img.setAttribute("src", lightModeQR);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function updateQRCodes() {
|
||||
const theme = document.documentElement.getAttribute("data-theme");
|
||||
const qrImages = document.querySelectorAll(
|
||||
"img[data-light-src][data-dark-src]",
|
||||
);
|
||||
|
||||
qrImages.forEach((img) => {
|
||||
const lightSrc = img.getAttribute("data-light-src");
|
||||
const darkSrc = img.getAttribute("data-dark-src");
|
||||
|
||||
if (theme === "dark" && darkSrc) {
|
||||
img.setAttribute("src", darkSrc);
|
||||
} else if (lightSrc) {
|
||||
img.setAttribute("src", lightSrc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set initial theme on page load
|
||||
updateQRCodes();
|
||||
|
||||
// Watch for theme changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
mutation.attributeName === "data-theme"
|
||||
) {
|
||||
updateQRCodes();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme"],
|
||||
});
|
||||
</script>
|
||||
17
docs/src/config.ts
Normal file
17
docs/src/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const requirements = {
|
||||
android: "Android 12+",
|
||||
ios: "iOS 17+",
|
||||
} as const;
|
||||
|
||||
export const downloadLinks = {
|
||||
android: {
|
||||
releases: "https://git.atri.dad/atridad/Ascently/releases",
|
||||
obtainium:
|
||||
"https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://git.atri.dad/atridad/Ascently/releases",
|
||||
playStore: "",
|
||||
},
|
||||
ios: {
|
||||
appStore: "https://apps.apple.com/ca/app/ascently/id6753959144",
|
||||
testFlight: "https://testflight.apple.com/join/E2DYRGH8",
|
||||
},
|
||||
} as const;
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
title: Download
|
||||
description: Get Ascently on your Android or iOS device
|
||||
---
|
||||
|
||||
## Android
|
||||
|
||||
### Option 1: Direct APK Download
|
||||
Download the latest APK from the [Releases page](https://git.atri.dad/atridad/Ascently/releases).
|
||||
|
||||
### Option 2: Obtainium
|
||||
Use Obtainium for automatic updates:
|
||||
|
||||
[<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.ascently%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FAscently%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22Ascently%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Ascently%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
|
||||
|
||||
## iOS
|
||||
|
||||
### App Store
|
||||
Download from the app store [here](https://apps.apple.com/ca/app/ascently/id6753959144)
|
||||
|
||||
### TestFlight Beta
|
||||
Join the TestFlight beta [here](https://testflight.apple.com/join/E2DYRGH8)
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Android 12+** or **iOS 17+**
|
||||
10
docs/src/content/docs/download.mdx
Normal file
10
docs/src/content/docs/download.mdx
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Download
|
||||
description: Get Ascently on your Android or iOS device
|
||||
---
|
||||
|
||||
import DownloadButtons from '../../components/DownloadButtons.astro';
|
||||
|
||||
Get Ascently on your device and start tracking your climbs today!
|
||||
|
||||
<DownloadButtons showQR={true} />
|
||||
@@ -465,7 +465,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -513,7 +513,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -602,7 +602,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -632,7 +632,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
|
||||
Binary file not shown.
@@ -266,9 +266,25 @@ class SyncService: ObservableObject {
|
||||
{
|
||||
let formatter = ISO8601DateFormatter()
|
||||
|
||||
// Merge and apply deletions first to prevent resurrection
|
||||
let allDeletions = dataManager.getDeletedItems() + response.deletedItems
|
||||
let uniqueDeletions = Array(Set(allDeletions))
|
||||
|
||||
print(
|
||||
"iOS DELTA SYNC: Applying \(uniqueDeletions.count) deletion records before merging data"
|
||||
)
|
||||
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
|
||||
|
||||
// Build deleted item lookup map
|
||||
let deletedItemSet = Set(uniqueDeletions.map { $0.type + ":" + $0.id })
|
||||
|
||||
// Download images for new/modified problems from server
|
||||
var imagePathMapping: [String: String] = [:]
|
||||
for problem in response.problems {
|
||||
if deletedItemSet.contains("problem:" + problem.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
|
||||
|
||||
for (index, imagePath) in imagePaths.enumerated() {
|
||||
@@ -293,6 +309,10 @@ class SyncService: ObservableObject {
|
||||
|
||||
// Merge gyms
|
||||
for backupGym in response.gyms {
|
||||
if deletedItemSet.contains("gym:" + backupGym.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id })
|
||||
{
|
||||
let existing = dataManager.gyms[index]
|
||||
@@ -306,6 +326,10 @@ class SyncService: ObservableObject {
|
||||
|
||||
// Merge problems
|
||||
for backupProblem in response.problems {
|
||||
if deletedItemSet.contains("problem:" + backupProblem.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
var problemToMerge = backupProblem
|
||||
if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths {
|
||||
let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 }
|
||||
@@ -341,6 +365,10 @@ class SyncService: ObservableObject {
|
||||
|
||||
// Merge sessions
|
||||
for backupSession in response.sessions {
|
||||
if deletedItemSet.contains("session:" + backupSession.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
if let index = dataManager.sessions.firstIndex(where: {
|
||||
$0.id.uuidString == backupSession.id
|
||||
}) {
|
||||
@@ -355,6 +383,10 @@ class SyncService: ObservableObject {
|
||||
|
||||
// Merge attempts
|
||||
for backupAttempt in response.attempts {
|
||||
if deletedItemSet.contains("attempt:" + backupAttempt.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
if let index = dataManager.attempts.firstIndex(where: {
|
||||
$0.id.uuidString == backupAttempt.id
|
||||
}) {
|
||||
@@ -367,9 +399,7 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply deletions
|
||||
let allDeletions = dataManager.getDeletedItems() + response.deletedItems
|
||||
let uniqueDeletions = Array(Set(allDeletions))
|
||||
// Apply deletions again for safety
|
||||
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
|
||||
|
||||
// Save all changes
|
||||
|
||||
40
sync/main.go
40
sync/main.go
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const VERSION = "2.1.0"
|
||||
const VERSION = "2.2.0"
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
@@ -283,8 +283,16 @@ func (s *SyncServer) mergeDeletedItems(existing []DeletedItem, updates []Deleted
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up tombstones older than 30 days to prevent unbounded growth
|
||||
cutoffTime := time.Now().UTC().Add(-30 * 24 * time.Hour)
|
||||
result := make([]DeletedItem, 0, len(deletedMap))
|
||||
for _, item := range deletedMap {
|
||||
deletedTime, err := time.Parse(time.RFC3339, item.DeletedAt)
|
||||
if err == nil && deletedTime.Before(cutoffTime) {
|
||||
log.Printf("Cleaning up old deletion record: type=%s, id=%s, deletedAt=%s",
|
||||
item.Type, item.ID, item.DeletedAt)
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
@@ -533,15 +541,16 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Merge and apply deletions first to prevent resurrection
|
||||
serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems)
|
||||
s.applyDeletions(serverBackup, serverBackup.DeletedItems)
|
||||
log.Printf("Applied deletions: total=%d deletion records", len(serverBackup.DeletedItems))
|
||||
|
||||
// Merge client changes into server data
|
||||
serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms)
|
||||
serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems)
|
||||
serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions)
|
||||
serverBackup.Attempts = s.mergeAttempts(serverBackup.Attempts, deltaRequest.Attempts)
|
||||
serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems)
|
||||
|
||||
// Apply deletions to remove deleted items
|
||||
s.applyDeletions(serverBackup, serverBackup.DeletedItems)
|
||||
|
||||
// Save merged data
|
||||
if err := s.saveData(serverBackup); err != nil {
|
||||
@@ -553,8 +562,15 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse client's last sync time
|
||||
clientLastSync, err := time.Parse(time.RFC3339, deltaRequest.LastSyncTime)
|
||||
if err != nil {
|
||||
// If parsing fails, send everything
|
||||
clientLastSync = time.Time{}
|
||||
log.Printf("Warning: Could not parse lastSyncTime '%s', sending all data", deltaRequest.LastSyncTime)
|
||||
}
|
||||
|
||||
// Build deleted item lookup map
|
||||
deletedItemMap := make(map[string]bool)
|
||||
for _, item := range serverBackup.DeletedItems {
|
||||
key := item.Type + ":" + item.ID
|
||||
deletedItemMap[key] = true
|
||||
}
|
||||
|
||||
// Prepare response with items modified since client's last sync
|
||||
@@ -569,6 +585,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Filter gyms modified after client's last sync
|
||||
for _, gym := range serverBackup.Gyms {
|
||||
if deletedItemMap["gym:"+gym.ID] {
|
||||
continue
|
||||
}
|
||||
gymTime, err := time.Parse(time.RFC3339, gym.UpdatedAt)
|
||||
if err == nil && gymTime.After(clientLastSync) {
|
||||
response.Gyms = append(response.Gyms, gym)
|
||||
@@ -577,6 +596,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Filter problems modified after client's last sync
|
||||
for _, problem := range serverBackup.Problems {
|
||||
if deletedItemMap["problem:"+problem.ID] {
|
||||
continue
|
||||
}
|
||||
problemTime, err := time.Parse(time.RFC3339, problem.UpdatedAt)
|
||||
if err == nil && problemTime.After(clientLastSync) {
|
||||
response.Problems = append(response.Problems, problem)
|
||||
@@ -585,6 +607,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Filter sessions modified after client's last sync
|
||||
for _, session := range serverBackup.Sessions {
|
||||
if deletedItemMap["session:"+session.ID] {
|
||||
continue
|
||||
}
|
||||
sessionTime, err := time.Parse(time.RFC3339, session.UpdatedAt)
|
||||
if err == nil && sessionTime.After(clientLastSync) {
|
||||
response.Sessions = append(response.Sessions, session)
|
||||
@@ -593,6 +618,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Filter attempts created after client's last sync
|
||||
for _, attempt := range serverBackup.Attempts {
|
||||
if deletedItemMap["attempt:"+attempt.ID] {
|
||||
continue
|
||||
}
|
||||
attemptTime, err := time.Parse(time.RFC3339, attempt.CreatedAt)
|
||||
if err == nil && attemptTime.After(clientLastSync) {
|
||||
response.Attempts = append(response.Attempts, attempt)
|
||||
|
||||
501
sync/sync_test.go
Normal file
501
sync/sync_test.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDeltaSyncDeletedItemResurrection verifies deleted items don't resurrect
|
||||
func TestDeltaSyncDeletedItemResurrection(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
server := &SyncServer{
|
||||
dataFile: filepath.Join(tempDir, "test.json"),
|
||||
imagesDir: filepath.Join(tempDir, "images"),
|
||||
authToken: "test-token",
|
||||
}
|
||||
|
||||
// Initial state: Server has one gym, one problem, one session with 8 attempts
|
||||
now := time.Now().UTC()
|
||||
gymID := "gym-1"
|
||||
problemID := "problem-1"
|
||||
sessionID := "session-1"
|
||||
|
||||
initialBackup := &ClimbDataBackup{
|
||||
Version: "2.0",
|
||||
FormatVersion: "2.0",
|
||||
Gyms: []BackupGym{
|
||||
{
|
||||
ID: gymID,
|
||||
Name: "Test Gym",
|
||||
SupportedClimbTypes: []string{"BOULDER"},
|
||||
DifficultySystems: []string{"V"},
|
||||
CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
|
||||
UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
Problems: []BackupProblem{
|
||||
{
|
||||
ID: problemID,
|
||||
GymID: gymID,
|
||||
ClimbType: "BOULDER",
|
||||
Difficulty: DifficultyGrade{
|
||||
System: "V",
|
||||
Grade: "V5",
|
||||
NumericValue: 5,
|
||||
},
|
||||
IsActive: true,
|
||||
CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
|
||||
UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
Sessions: []BackupClimbSession{
|
||||
{
|
||||
ID: sessionID,
|
||||
GymID: gymID,
|
||||
Date: now.Format("2006-01-02"),
|
||||
Status: "completed",
|
||||
CreatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339),
|
||||
UpdatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
Attempts: []BackupAttempt{},
|
||||
DeletedItems: []DeletedItem{},
|
||||
}
|
||||
|
||||
// Add 8 attempts
|
||||
for i := 0; i < 8; i++ {
|
||||
attempt := BackupAttempt{
|
||||
ID: "attempt-" + string(rune('1'+i)),
|
||||
SessionID: sessionID,
|
||||
ProblemID: problemID,
|
||||
Result: "COMPLETED",
|
||||
Timestamp: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339),
|
||||
CreatedAt: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339),
|
||||
}
|
||||
initialBackup.Attempts = append(initialBackup.Attempts, attempt)
|
||||
}
|
||||
|
||||
if err := server.saveData(initialBackup); err != nil {
|
||||
t.Fatalf("Failed to save initial data: %v", err)
|
||||
}
|
||||
|
||||
// Client 1 syncs - gets all data
|
||||
client1LastSync := now.Add(-2 * time.Hour).Format(time.RFC3339)
|
||||
deltaRequest1 := DeltaSyncRequest{
|
||||
LastSyncTime: client1LastSync,
|
||||
Gyms: []BackupGym{},
|
||||
Problems: []BackupProblem{},
|
||||
Sessions: []BackupClimbSession{},
|
||||
Attempts: []BackupAttempt{},
|
||||
DeletedItems: []DeletedItem{},
|
||||
}
|
||||
|
||||
// Simulate delta sync for client 1
|
||||
serverBackup, _ := server.loadData()
|
||||
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest1.DeletedItems)
|
||||
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
|
||||
|
||||
if len(serverBackup.Sessions) != 1 {
|
||||
t.Errorf("Expected 1 session after client1 sync, got %d", len(serverBackup.Sessions))
|
||||
}
|
||||
if len(serverBackup.Attempts) != 8 {
|
||||
t.Errorf("Expected 8 attempts after client1 sync, got %d", len(serverBackup.Attempts))
|
||||
}
|
||||
|
||||
// Client 1 deletes the session locally
|
||||
deleteTime := now.Format(time.RFC3339)
|
||||
deletions := []DeletedItem{
|
||||
{ID: sessionID, Type: "session", DeletedAt: deleteTime},
|
||||
}
|
||||
// Also track attempt deletions
|
||||
for _, attempt := range initialBackup.Attempts {
|
||||
deletions = append(deletions, DeletedItem{
|
||||
ID: attempt.ID,
|
||||
Type: "attempt",
|
||||
DeletedAt: deleteTime,
|
||||
})
|
||||
}
|
||||
|
||||
// Client 1 syncs deletion
|
||||
deltaRequest2 := DeltaSyncRequest{
|
||||
LastSyncTime: now.Add(-5 * time.Minute).Format(time.RFC3339),
|
||||
Gyms: []BackupGym{},
|
||||
Problems: []BackupProblem{},
|
||||
Sessions: []BackupClimbSession{},
|
||||
Attempts: []BackupAttempt{},
|
||||
DeletedItems: deletions,
|
||||
}
|
||||
|
||||
// Server processes deletion
|
||||
serverBackup, _ = server.loadData()
|
||||
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest2.DeletedItems)
|
||||
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
|
||||
server.saveData(serverBackup)
|
||||
|
||||
// Verify deletions were applied on server
|
||||
serverBackup, _ = server.loadData()
|
||||
if len(serverBackup.Sessions) != 0 {
|
||||
t.Errorf("Expected 0 sessions after deletion, got %d", len(serverBackup.Sessions))
|
||||
}
|
||||
if len(serverBackup.Attempts) != 0 {
|
||||
t.Errorf("Expected 0 attempts after deletion, got %d", len(serverBackup.Attempts))
|
||||
}
|
||||
if len(serverBackup.DeletedItems) != 9 {
|
||||
t.Errorf("Expected 9 deletion records, got %d", len(serverBackup.DeletedItems))
|
||||
}
|
||||
|
||||
// Client does local reset and pulls from server
|
||||
deltaRequest3 := DeltaSyncRequest{
|
||||
LastSyncTime: time.Time{}.Format(time.RFC3339),
|
||||
Gyms: []BackupGym{},
|
||||
Problems: []BackupProblem{},
|
||||
Sessions: []BackupClimbSession{},
|
||||
Attempts: []BackupAttempt{},
|
||||
DeletedItems: []DeletedItem{},
|
||||
}
|
||||
|
||||
serverBackup, _ = server.loadData()
|
||||
clientLastSync, _ := time.Parse(time.RFC3339, deltaRequest3.LastSyncTime)
|
||||
|
||||
// Build response
|
||||
response := DeltaSyncResponse{
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||||
Gyms: []BackupGym{},
|
||||
Problems: []BackupProblem{},
|
||||
Sessions: []BackupClimbSession{},
|
||||
Attempts: []BackupAttempt{},
|
||||
DeletedItems: []DeletedItem{},
|
||||
}
|
||||
|
||||
// Build deleted item map
|
||||
deletedItemMap := make(map[string]bool)
|
||||
for _, item := range serverBackup.DeletedItems {
|
||||
key := item.Type + ":" + item.ID
|
||||
deletedItemMap[key] = true
|
||||
}
|
||||
|
||||
// Filter sessions (excluding deleted)
|
||||
for _, session := range serverBackup.Sessions {
|
||||
if deletedItemMap["session:"+session.ID] {
|
||||
continue
|
||||
}
|
||||
sessionTime, _ := time.Parse(time.RFC3339, session.UpdatedAt)
|
||||
if sessionTime.After(clientLastSync) {
|
||||
response.Sessions = append(response.Sessions, session)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter attempts (excluding deleted)
|
||||
for _, attempt := range serverBackup.Attempts {
|
||||
if deletedItemMap["attempt:"+attempt.ID] {
|
||||
continue
|
||||
}
|
||||
attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt)
|
||||
if attemptTime.After(clientLastSync) {
|
||||
response.Attempts = append(response.Attempts, attempt)
|
||||
}
|
||||
}
|
||||
|
||||
// Send deletion records
|
||||
for _, deletion := range serverBackup.DeletedItems {
|
||||
deletionTime, _ := time.Parse(time.RFC3339, deletion.DeletedAt)
|
||||
if deletionTime.After(clientLastSync) {
|
||||
response.DeletedItems = append(response.DeletedItems, deletion)
|
||||
}
|
||||
}
|
||||
|
||||
if len(response.Sessions) != 0 {
|
||||
t.Errorf("Deleted session was resurrected! Got %d sessions in response", len(response.Sessions))
|
||||
}
|
||||
if len(response.Attempts) != 0 {
|
||||
t.Errorf("Deleted attempts were resurrected! Got %d attempts in response", len(response.Attempts))
|
||||
}
|
||||
if len(response.DeletedItems) < 9 {
|
||||
t.Errorf("Expected at least 9 deletion records in response, got %d", len(response.DeletedItems))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeltaSyncAttemptCount verifies all attempts are preserved
|
||||
func TestDeltaSyncAttemptCount(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
server := &SyncServer{
|
||||
dataFile: filepath.Join(tempDir, "test.json"),
|
||||
imagesDir: filepath.Join(tempDir, "images"),
|
||||
authToken: "test-token",
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
gymID := "gym-1"
|
||||
problemID := "problem-1"
|
||||
sessionID := "session-1"
|
||||
|
||||
// Create session with 8 attempts
|
||||
initialBackup := &ClimbDataBackup{
|
||||
Version: "2.0",
|
||||
FormatVersion: "2.0",
|
||||
Gyms: []BackupGym{{ID: gymID, Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Problems: []BackupProblem{{ID: problemID, GymID: gymID, ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Sessions: []BackupClimbSession{{ID: sessionID, GymID: gymID, Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Attempts: []BackupAttempt{},
|
||||
DeletedItems: []DeletedItem{},
|
||||
}
|
||||
|
||||
// Add 8 attempts at different times
|
||||
baseTime := now.Add(-30 * time.Minute)
|
||||
for i := 0; i < 8; i++ {
|
||||
attempt := BackupAttempt{
|
||||
ID: "attempt-" + string(rune('1'+i)),
|
||||
SessionID: sessionID,
|
||||
ProblemID: problemID,
|
||||
Result: "COMPLETED",
|
||||
Timestamp: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
|
||||
CreatedAt: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
|
||||
}
|
||||
initialBackup.Attempts = append(initialBackup.Attempts, attempt)
|
||||
}
|
||||
|
||||
if err := server.saveData(initialBackup); err != nil {
|
||||
t.Fatalf("Failed to save initial data: %v", err)
|
||||
}
|
||||
|
||||
// Client syncs with lastSyncTime BEFORE all attempts were created
|
||||
clientLastSync := baseTime.Add(-1 * time.Hour)
|
||||
|
||||
serverBackup, _ := server.loadData()
|
||||
|
||||
// Count attempts that should be returned
|
||||
attemptCount := 0
|
||||
for _, attempt := range serverBackup.Attempts {
|
||||
attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt)
|
||||
if attemptTime.After(clientLastSync) {
|
||||
attemptCount++
|
||||
}
|
||||
}
|
||||
|
||||
if attemptCount != 8 {
|
||||
t.Errorf("Expected all 8 attempts to be returned, got %d", attemptCount)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestTombstoneCleanup verifies old deletion records are cleaned up
|
||||
func TestTombstoneCleanup(t *testing.T) {
|
||||
server := &SyncServer{}
|
||||
|
||||
now := time.Now().UTC()
|
||||
oldDeletion := DeletedItem{
|
||||
ID: "old-item",
|
||||
Type: "session",
|
||||
DeletedAt: now.Add(-31 * 24 * time.Hour).Format(time.RFC3339), // 31 days old
|
||||
}
|
||||
recentDeletion := DeletedItem{
|
||||
ID: "recent-item",
|
||||
Type: "session",
|
||||
DeletedAt: now.Add(-1 * 24 * time.Hour).Format(time.RFC3339), // 1 day old
|
||||
}
|
||||
|
||||
existing := []DeletedItem{oldDeletion}
|
||||
updates := []DeletedItem{recentDeletion}
|
||||
|
||||
merged := server.mergeDeletedItems(existing, updates)
|
||||
|
||||
// Old deletion should be cleaned up, only recent one remains
|
||||
if len(merged) != 1 {
|
||||
t.Errorf("Expected 1 deletion record after cleanup, got %d", len(merged))
|
||||
}
|
||||
if len(merged) > 0 && merged[0].ID != "recent-item" {
|
||||
t.Errorf("Expected recent deletion to remain, got %s", merged[0].ID)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestMergeDeletedItemsDeduplication verifies duplicate deletions are handled
|
||||
func TestMergeDeletedItemsDeduplication(t *testing.T) {
|
||||
server := &SyncServer{}
|
||||
|
||||
now := time.Now().UTC()
|
||||
deletion1 := DeletedItem{
|
||||
ID: "item-1",
|
||||
Type: "session",
|
||||
DeletedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
|
||||
}
|
||||
deletion2 := DeletedItem{
|
||||
ID: "item-1",
|
||||
Type: "session",
|
||||
DeletedAt: now.Format(time.RFC3339), // Newer timestamp
|
||||
}
|
||||
|
||||
existing := []DeletedItem{deletion1}
|
||||
updates := []DeletedItem{deletion2}
|
||||
|
||||
merged := server.mergeDeletedItems(existing, updates)
|
||||
|
||||
if len(merged) != 1 {
|
||||
t.Errorf("Expected 1 deletion record, got %d", len(merged))
|
||||
}
|
||||
if len(merged) > 0 && merged[0].DeletedAt != deletion2.DeletedAt {
|
||||
t.Errorf("Expected newer deletion timestamp to be kept")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestApplyDeletions verifies deletions are applied correctly
|
||||
func TestApplyDeletions(t *testing.T) {
|
||||
server := &SyncServer{}
|
||||
|
||||
now := time.Now().UTC()
|
||||
backup := &ClimbDataBackup{
|
||||
Version: "2.0",
|
||||
FormatVersion: "2.0",
|
||||
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Attempts: []BackupAttempt{{ID: "attempt-1", SessionID: "session-1", ProblemID: "problem-1", Result: "COMPLETED", Timestamp: now.Format(time.RFC3339), CreatedAt: now.Format(time.RFC3339)}},
|
||||
DeletedItems: []DeletedItem{},
|
||||
}
|
||||
|
||||
deletions := []DeletedItem{
|
||||
{ID: "session-1", Type: "session", DeletedAt: now.Format(time.RFC3339)},
|
||||
{ID: "attempt-1", Type: "attempt", DeletedAt: now.Format(time.RFC3339)},
|
||||
}
|
||||
|
||||
server.applyDeletions(backup, deletions)
|
||||
|
||||
if len(backup.Sessions) != 0 {
|
||||
t.Errorf("Expected 0 sessions after deletion, got %d", len(backup.Sessions))
|
||||
}
|
||||
if len(backup.Attempts) != 0 {
|
||||
t.Errorf("Expected 0 attempts after deletion, got %d", len(backup.Attempts))
|
||||
}
|
||||
if len(backup.Gyms) != 1 {
|
||||
t.Errorf("Expected gym to remain, got %d gyms", len(backup.Gyms))
|
||||
}
|
||||
if len(backup.Problems) != 1 {
|
||||
t.Errorf("Expected problem to remain, got %d problems", len(backup.Problems))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestCascadingDeletions verifies related items are handled properly
|
||||
func TestCascadingDeletions(t *testing.T) {
|
||||
server := &SyncServer{}
|
||||
|
||||
now := time.Now().UTC()
|
||||
sessionID := "session-1"
|
||||
backup := &ClimbDataBackup{
|
||||
Version: "2.0",
|
||||
FormatVersion: "2.0",
|
||||
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Sessions: []BackupClimbSession{{ID: sessionID, GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Attempts: []BackupAttempt{},
|
||||
DeletedItems: []DeletedItem{},
|
||||
}
|
||||
|
||||
// Add multiple attempts for the session
|
||||
for i := 0; i < 5; i++ {
|
||||
backup.Attempts = append(backup.Attempts, BackupAttempt{
|
||||
ID: "attempt-" + string(rune('1'+i)),
|
||||
SessionID: sessionID,
|
||||
ProblemID: "problem-1",
|
||||
Result: "COMPLETED",
|
||||
Timestamp: now.Format(time.RFC3339),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// Delete session - attempts should also be tracked as deleted
|
||||
deletions := []DeletedItem{
|
||||
{ID: sessionID, Type: "session", DeletedAt: now.Format(time.RFC3339)},
|
||||
}
|
||||
for _, attempt := range backup.Attempts {
|
||||
deletions = append(deletions, DeletedItem{
|
||||
ID: attempt.ID,
|
||||
Type: "attempt",
|
||||
DeletedAt: now.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
server.applyDeletions(backup, deletions)
|
||||
|
||||
if len(backup.Sessions) != 0 {
|
||||
t.Errorf("Expected session to be deleted, got %d sessions", len(backup.Sessions))
|
||||
}
|
||||
if len(backup.Attempts) != 0 {
|
||||
t.Errorf("Expected all attempts to be deleted, got %d attempts", len(backup.Attempts))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestFullSyncAfterReset verifies the reported user scenario
|
||||
func TestFullSyncAfterReset(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
server := &SyncServer{
|
||||
dataFile: filepath.Join(tempDir, "test.json"),
|
||||
imagesDir: filepath.Join(tempDir, "images"),
|
||||
authToken: "test-token",
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Initial sync with data
|
||||
initialData := &ClimbDataBackup{
|
||||
Version: "2.0",
|
||||
FormatVersion: "2.0",
|
||||
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
|
||||
Attempts: []BackupAttempt{},
|
||||
DeletedItems: []DeletedItem{},
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
initialData.Attempts = append(initialData.Attempts, BackupAttempt{
|
||||
ID: "attempt-" + string(rune('1'+i)),
|
||||
SessionID: "session-1",
|
||||
ProblemID: "problem-1",
|
||||
Result: "COMPLETED",
|
||||
Timestamp: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
|
||||
CreatedAt: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
server.saveData(initialData)
|
||||
|
||||
// Client deletes everything and syncs
|
||||
deletions := []DeletedItem{
|
||||
{ID: "gym-1", Type: "gym", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
|
||||
{ID: "problem-1", Type: "problem", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
|
||||
{ID: "session-1", Type: "session", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
deletions = append(deletions, DeletedItem{
|
||||
ID: "attempt-" + string(rune('1'+i)),
|
||||
Type: "attempt",
|
||||
DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
serverBackup, _ := server.loadData()
|
||||
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deletions)
|
||||
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
|
||||
server.saveData(serverBackup)
|
||||
|
||||
// Client does local reset and pulls from server
|
||||
serverBackup, _ = server.loadData()
|
||||
|
||||
if len(serverBackup.Gyms) != 0 {
|
||||
t.Errorf("Expected 0 gyms, got %d", len(serverBackup.Gyms))
|
||||
}
|
||||
if len(serverBackup.Problems) != 0 {
|
||||
t.Errorf("Expected 0 problems, got %d", len(serverBackup.Problems))
|
||||
}
|
||||
if len(serverBackup.Sessions) != 0 {
|
||||
t.Errorf("Expected 0 sessions, got %d", len(serverBackup.Sessions))
|
||||
}
|
||||
if len(serverBackup.Attempts) != 0 {
|
||||
t.Errorf("Expected 0 attempts, got %d", len(serverBackup.Attempts))
|
||||
}
|
||||
if len(serverBackup.DeletedItems) == 0 {
|
||||
t.Errorf("Expected deletion records, got 0")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user