Compare commits
10 Commits
ANDROID_2.
...
ANDROID_2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
c2f95f2793
|
|||
|
b7a3c98b2c
|
|||
|
fed9bab2ea
|
|||
|
862622b07b
|
|||
|
eba503eb5e
|
|||
|
8c4a78ad50
|
|||
|
3b16475dc6
|
|||
|
105d39689d
|
|||
|
d4023133b7
|
|||
|
602b5f8938
|
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.atridad.ascently"
|
applicationId = "com.atridad.ascently"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 42
|
versionCode = 46
|
||||||
versionName = "2.1.0"
|
versionName = "2.2.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,9 +368,22 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
repository.setAutoSyncCallback(null)
|
repository.setAutoSyncCallback(null)
|
||||||
|
|
||||||
try {
|
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
|
// Download images for new/modified problems from server
|
||||||
val imagePathMapping = mutableMapOf<String, String>()
|
val imagePathMapping = mutableMapOf<String, String>()
|
||||||
for (problem in response.problems) {
|
for (problem in response.problems) {
|
||||||
|
if (deletedItemSet.contains("problem:${problem.id}")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
problem.imagePaths?.forEach { imagePath ->
|
problem.imagePaths?.forEach { imagePath ->
|
||||||
val serverFilename = imagePath.substringAfterLast('/')
|
val serverFilename = imagePath.substringAfterLast('/')
|
||||||
try {
|
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()
|
val existingGyms = repository.getAllGyms().first()
|
||||||
for (backupGym in response.gyms) {
|
for (backupGym in response.gyms) {
|
||||||
|
if (deletedItemSet.contains("gym:${backupGym.id}")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
val existing = existingGyms.find { it.id == backupGym.id }
|
val existing = existingGyms.find { it.id == backupGym.id }
|
||||||
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
|
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
|
||||||
val gym = backupGym.toGym()
|
val gym = backupGym.toGym()
|
||||||
@@ -401,6 +417,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
// Merge problems
|
// Merge problems
|
||||||
val existingProblems = repository.getAllProblems().first()
|
val existingProblems = repository.getAllProblems().first()
|
||||||
for (backupProblem in response.problems) {
|
for (backupProblem in response.problems) {
|
||||||
|
if (deletedItemSet.contains("problem:${backupProblem.id}")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
val updatedImagePaths =
|
val updatedImagePaths =
|
||||||
backupProblem.imagePaths?.map { oldPath ->
|
backupProblem.imagePaths?.map { oldPath ->
|
||||||
imagePathMapping[oldPath] ?: oldPath
|
imagePathMapping[oldPath] ?: oldPath
|
||||||
@@ -421,6 +440,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
// Merge sessions
|
// Merge sessions
|
||||||
val existingSessions = repository.getAllSessions().first()
|
val existingSessions = repository.getAllSessions().first()
|
||||||
for (backupSession in response.sessions) {
|
for (backupSession in response.sessions) {
|
||||||
|
if (deletedItemSet.contains("session:${backupSession.id}")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
val session = backupSession.toClimbSession()
|
val session = backupSession.toClimbSession()
|
||||||
val existing = existingSessions.find { it.id == backupSession.id }
|
val existing = existingSessions.find { it.id == backupSession.id }
|
||||||
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
|
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
|
||||||
@@ -435,6 +457,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
// Merge attempts
|
// Merge attempts
|
||||||
val existingAttempts = repository.getAllAttempts().first()
|
val existingAttempts = repository.getAllAttempts().first()
|
||||||
for (backupAttempt in response.attempts) {
|
for (backupAttempt in response.attempts) {
|
||||||
|
if (deletedItemSet.contains("attempt:${backupAttempt.id}")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
val attempt = backupAttempt.toAttempt()
|
val attempt = backupAttempt.toAttempt()
|
||||||
val existing = existingAttempts.find { it.id == backupAttempt.id }
|
val existing = existingAttempts.find { it.id == backupAttempt.id }
|
||||||
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
|
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
|
||||||
@@ -446,15 +471,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply deletions
|
// Apply deletions again for safety
|
||||||
applyDeletions(response.deletedItems)
|
applyDeletions(uniqueDeletions)
|
||||||
|
|
||||||
// Update deletion records
|
// Update deletion records
|
||||||
val allDeletions = repository.getDeletedItems() + response.deletedItems
|
|
||||||
repository.clearDeletedItems()
|
repository.clearDeletedItems()
|
||||||
allDeletions.distinctBy { "${it.type}:${it.id}" }.forEach {
|
uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) }
|
||||||
repository.trackDeletion(it.id, it.type)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
// Re-enable auto-sync
|
// Re-enable auto-sync
|
||||||
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
|
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
|
||||||
@@ -542,7 +564,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
Request.Builder()
|
Request.Builder()
|
||||||
.url("$serverUrl/sync")
|
.url("$serverUrl/sync")
|
||||||
.header("Authorization", "Bearer $authToken")
|
.header("Authorization", "Bearer $authToken")
|
||||||
.post(requestBody)
|
.put(requestBody)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import com.atridad.ascently.MainActivity
|
|||||||
import com.atridad.ascently.R
|
import com.atridad.ascently.R
|
||||||
import com.atridad.ascently.data.database.AscentlyDatabase
|
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||||
import com.atridad.ascently.data.repository.ClimbRepository
|
import com.atridad.ascently.data.repository.ClimbRepository
|
||||||
import kotlinx.coroutines.*
|
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
class SessionTrackingService : Service() {
|
class SessionTrackingService : Service() {
|
||||||
@@ -71,12 +72,19 @@ class SessionTrackingService : Service() {
|
|||||||
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
|
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
try {
|
try {
|
||||||
val targetSession = when {
|
val targetSession =
|
||||||
|
when {
|
||||||
sessionId != null -> repository.getSessionById(sessionId)
|
sessionId != null -> repository.getSessionById(sessionId)
|
||||||
else -> repository.getActiveSession()
|
else -> repository.getActiveSession()
|
||||||
}
|
}
|
||||||
if (targetSession != null && targetSession.status == com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
|
if (targetSession != null &&
|
||||||
val completed = with(com.atridad.ascently.data.model.ClimbSession) { targetSession.complete() }
|
targetSession.status ==
|
||||||
|
com.atridad.ascently.data.model.SessionStatus.ACTIVE
|
||||||
|
) {
|
||||||
|
val completed =
|
||||||
|
with(com.atridad.ascently.data.model.ClimbSession) {
|
||||||
|
targetSession.complete()
|
||||||
|
}
|
||||||
repository.updateSession(completed)
|
repository.updateSession(completed)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -97,11 +105,14 @@ class SessionTrackingService : Service() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
createAndShowNotification(sessionId)
|
createAndShowNotification(sessionId)
|
||||||
|
// Update widget when session tracking starts
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(this)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
|
||||||
notificationJob = serviceScope.launch {
|
notificationJob =
|
||||||
|
serviceScope.launch {
|
||||||
try {
|
try {
|
||||||
if (!isNotificationActive()) {
|
if (!isNotificationActive()) {
|
||||||
delay(1000L)
|
delay(1000L)
|
||||||
@@ -117,7 +128,8 @@ class SessionTrackingService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
monitoringJob = serviceScope.launch {
|
monitoringJob =
|
||||||
|
serviceScope.launch {
|
||||||
try {
|
try {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(10000L)
|
delay(10000L)
|
||||||
@@ -127,7 +139,11 @@ class SessionTrackingService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val session = repository.getSessionById(sessionId)
|
val session = repository.getSessionById(sessionId)
|
||||||
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
|
if (session == null ||
|
||||||
|
session.status !=
|
||||||
|
com.atridad.ascently.data.model.SessionStatus
|
||||||
|
.ACTIVE
|
||||||
|
) {
|
||||||
stopSessionTracking()
|
stopSessionTracking()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -143,6 +159,8 @@ class SessionTrackingService : Service() {
|
|||||||
monitoringJob?.cancel()
|
monitoringJob?.cancel()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
|
// Update widget when session tracking stops
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isNotificationActive(): Boolean {
|
private fun isNotificationActive(): Boolean {
|
||||||
@@ -157,6 +175,8 @@ class SessionTrackingService : Service() {
|
|||||||
private suspend fun updateNotification(sessionId: String) {
|
private suspend fun updateNotification(sessionId: String) {
|
||||||
try {
|
try {
|
||||||
createAndShowNotification(sessionId)
|
createAndShowNotification(sessionId)
|
||||||
|
// Update widget when notification updates
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(this)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
|
||||||
@@ -172,23 +192,22 @@ class SessionTrackingService : Service() {
|
|||||||
|
|
||||||
private fun createAndShowNotification(sessionId: String) {
|
private fun createAndShowNotification(sessionId: String) {
|
||||||
try {
|
try {
|
||||||
val session = runBlocking {
|
val session = runBlocking { repository.getSessionById(sessionId) }
|
||||||
repository.getSessionById(sessionId)
|
if (session == null ||
|
||||||
}
|
session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE
|
||||||
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
|
) {
|
||||||
stopSessionTracking()
|
stopSessionTracking()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val gym = runBlocking {
|
val gym = runBlocking { repository.getGymById(session.gymId) }
|
||||||
repository.getGymById(session.gymId)
|
|
||||||
}
|
|
||||||
|
|
||||||
val attempts = runBlocking {
|
val attempts = runBlocking {
|
||||||
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
|
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val duration = session.startTime?.let { startTime ->
|
val duration =
|
||||||
|
session.startTime?.let { startTime ->
|
||||||
try {
|
try {
|
||||||
val start = LocalDateTime.parse(startTime)
|
val start = LocalDateTime.parse(startTime)
|
||||||
val now = LocalDateTime.now()
|
val now = LocalDateTime.now()
|
||||||
@@ -205,11 +224,15 @@ class SessionTrackingService : Service() {
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
"Active"
|
"Active"
|
||||||
}
|
}
|
||||||
} ?: "Active"
|
}
|
||||||
|
?: "Active"
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
val notification =
|
||||||
|
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("Climbing Session Active")
|
.setContentTitle("Climbing Session Active")
|
||||||
.setContentText("${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts")
|
.setContentText(
|
||||||
|
"${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts"
|
||||||
|
)
|
||||||
.setSmallIcon(R.drawable.ic_mountains)
|
.setSmallIcon(R.drawable.ic_mountains)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
@@ -232,7 +255,6 @@ class SessionTrackingService : Service() {
|
|||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
throw e
|
throw e
|
||||||
@@ -240,7 +262,8 @@ class SessionTrackingService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createOpenAppIntent(): PendingIntent {
|
private fun createOpenAppIntent(): PendingIntent {
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
val intent =
|
||||||
|
Intent(this, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
action = "OPEN_SESSION"
|
action = "OPEN_SESSION"
|
||||||
}
|
}
|
||||||
@@ -263,11 +286,13 @@ class SessionTrackingService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
val channel = NotificationChannel(
|
val channel =
|
||||||
|
NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Session Tracking",
|
"Session Tracking",
|
||||||
NotificationManager.IMPORTANCE_DEFAULT
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
).apply {
|
)
|
||||||
|
.apply {
|
||||||
description = "Shows active climbing session information"
|
description = "Shows active climbing session information"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
|
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.window.Dialog
|
|||||||
import com.atridad.ascently.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import com.atridad.ascently.ui.components.FullscreenImageViewer
|
import com.atridad.ascently.ui.components.FullscreenImageViewer
|
||||||
import com.atridad.ascently.ui.components.ImageDisplaySection
|
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.theme.CustomIcons
|
||||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
import com.atridad.ascently.utils.DateFormatUtils
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
@@ -1489,6 +1490,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
// New problem creation state
|
// New problem creation state
|
||||||
var newProblemName by remember { mutableStateOf("") }
|
var newProblemName by remember { mutableStateOf("") }
|
||||||
var newProblemGrade by remember { mutableStateOf("") }
|
var newProblemGrade by remember { mutableStateOf("") }
|
||||||
|
var newProblemImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) }
|
var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) }
|
||||||
var selectedDifficultySystem by remember {
|
var selectedDifficultySystem by remember {
|
||||||
mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE)
|
mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE)
|
||||||
@@ -1690,7 +1692,14 @@ fun EnhancedAddAttemptDialog(
|
|||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
IconButton(onClick = { showCreateProblem = false }) {
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
showCreateProblem = false
|
||||||
|
newProblemName = ""
|
||||||
|
newProblemGrade = ""
|
||||||
|
newProblemImagePaths = emptyList()
|
||||||
|
}
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = "Back",
|
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
|
null
|
||||||
},
|
},
|
||||||
climbType = selectedClimbType,
|
climbType = selectedClimbType,
|
||||||
difficulty = difficulty
|
difficulty = difficulty,
|
||||||
|
imagePaths =
|
||||||
|
newProblemImagePaths
|
||||||
)
|
)
|
||||||
|
|
||||||
onProblemCreated(newProblem)
|
onProblemCreated(newProblem)
|
||||||
@@ -2087,6 +2113,12 @@ fun EnhancedAddAttemptDialog(
|
|||||||
notes = notes.ifBlank { null }
|
notes = notes.ifBlank { null }
|
||||||
)
|
)
|
||||||
onAttemptAdded(attempt)
|
onAttemptAdded(attempt)
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
newProblemName = ""
|
||||||
|
newProblemGrade = ""
|
||||||
|
newProblemImagePaths = emptyList()
|
||||||
|
showCreateProblem = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create attempt for selected problem
|
// Create attempt for selected problem
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
package com.atridad.ascently.ui.screens
|
package com.atridad.ascently.ui.screens
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -23,6 +33,16 @@ import com.atridad.ascently.ui.components.ActiveSessionBanner
|
|||||||
import com.atridad.ascently.ui.components.SyncIndicator
|
import com.atridad.ascently.ui.components.SyncIndicator
|
||||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
import com.atridad.ascently.utils.DateFormatUtils
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
enum class ViewMode {
|
||||||
|
LIST,
|
||||||
|
CALENDAR
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -33,7 +53,15 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
val activeSession by viewModel.activeSession.collectAsState()
|
val activeSession by viewModel.activeSession.collectAsState()
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
// Filter out active sessions from regular session list
|
val sharedPreferences =
|
||||||
|
context.getSharedPreferences("SessionsPreferences", Context.MODE_PRIVATE)
|
||||||
|
val savedViewMode = sharedPreferences.getString("view_mode", "LIST")
|
||||||
|
var viewMode by remember {
|
||||||
|
mutableStateOf(if (savedViewMode == "CALENDAR") ViewMode.CALENDAR else ViewMode.LIST)
|
||||||
|
}
|
||||||
|
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
|
||||||
|
var selectedDate by remember { mutableStateOf<LocalDate?>(null) }
|
||||||
|
|
||||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||||
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
||||||
|
|
||||||
@@ -55,12 +83,30 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
viewMode =
|
||||||
|
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
|
||||||
|
selectedDate = null
|
||||||
|
sharedPreferences.edit().putString("view_mode", viewMode.name).apply()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (viewMode == ViewMode.LIST) Icons.Default.CalendarMonth
|
||||||
|
else Icons.AutoMirrored.Filled.List,
|
||||||
|
contentDescription =
|
||||||
|
if (viewMode == ViewMode.LIST) "Calendar View" else "List View",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Active session banner
|
|
||||||
ActiveSessionBanner(
|
ActiveSessionBanner(
|
||||||
activeSession = activeSession,
|
activeSession = activeSession,
|
||||||
gym = activeSessionGym,
|
gym = activeSessionGym,
|
||||||
@@ -83,20 +129,40 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
actionText = ""
|
actionText = ""
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
when (viewMode) {
|
||||||
|
ViewMode.LIST -> {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(completedSessions) { session ->
|
items(completedSessions) { session ->
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
gymName = gyms.find { it.id == session.gymId }?.name
|
||||||
|
?: "Unknown Gym",
|
||||||
onClick = { onNavigateToSessionDetail(session.id) }
|
onClick = { onNavigateToSessionDetail(session.id) }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ViewMode.CALENDAR -> {
|
||||||
|
CalendarView(
|
||||||
|
sessions = completedSessions,
|
||||||
|
gyms = gyms,
|
||||||
|
activeSession = activeSession,
|
||||||
|
activeSessionGym = activeSessionGym,
|
||||||
|
selectedMonth = selectedMonth,
|
||||||
|
onMonthChange = { selectedMonth = it },
|
||||||
|
selectedDate = selectedDate,
|
||||||
|
onDateSelected = { selectedDate = it },
|
||||||
|
onNavigateToSessionDetail = onNavigateToSessionDetail,
|
||||||
|
onEndSession = {
|
||||||
|
activeSession?.let { viewModel.endSession(context, it.id) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show UI state messages and errors
|
|
||||||
uiState.message?.let { message ->
|
uiState.message?.let { message ->
|
||||||
LaunchedEffect(message) {
|
LaunchedEffect(message) {
|
||||||
kotlinx.coroutines.delay(5000)
|
kotlinx.coroutines.delay(5000)
|
||||||
@@ -245,6 +311,226 @@ fun EmptyStateMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CalendarView(
|
||||||
|
sessions: List<ClimbSession>,
|
||||||
|
gyms: List<com.atridad.ascently.data.model.Gym>,
|
||||||
|
activeSession: ClimbSession?,
|
||||||
|
activeSessionGym: com.atridad.ascently.data.model.Gym?,
|
||||||
|
selectedMonth: YearMonth,
|
||||||
|
onMonthChange: (YearMonth) -> Unit,
|
||||||
|
selectedDate: LocalDate?,
|
||||||
|
onDateSelected: (LocalDate?) -> Unit,
|
||||||
|
onNavigateToSessionDetail: (String) -> Unit,
|
||||||
|
onEndSession: () -> Unit
|
||||||
|
) {
|
||||||
|
val sessionsByDate =
|
||||||
|
remember(sessions) {
|
||||||
|
sessions.groupBy {
|
||||||
|
try {
|
||||||
|
java.time.Instant.parse(it.date)
|
||||||
|
.atZone(java.time.ZoneId.systemDefault())
|
||||||
|
.toLocalDate()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) {
|
||||||
|
Text("‹", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) {
|
||||||
|
Text("›", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
onMonthChange(YearMonth.from(today))
|
||||||
|
onDateSelected(today)
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Today",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat").forEach { day ->
|
||||||
|
Text(
|
||||||
|
text = day,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
val firstDayOfMonth = selectedMonth.atDay(1)
|
||||||
|
val daysInMonth = selectedMonth.lengthOfMonth()
|
||||||
|
val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7
|
||||||
|
val totalCells =
|
||||||
|
((firstDayOfWeek + daysInMonth) / 7.0).let {
|
||||||
|
if (it == it.toInt().toDouble()) it.toInt() * 7 else (it.toInt() + 1) * 7
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyVerticalGrid(columns = GridCells.Fixed(7), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
items(totalCells) { index ->
|
||||||
|
val dayNumber = index - firstDayOfWeek + 1
|
||||||
|
|
||||||
|
if (dayNumber in 1..daysInMonth) {
|
||||||
|
val date = selectedMonth.atDay(dayNumber)
|
||||||
|
val sessionsOnDate = sessionsByDate[date] ?: emptyList()
|
||||||
|
val isSelected = date == selectedDate
|
||||||
|
val isToday = date == LocalDate.now()
|
||||||
|
|
||||||
|
CalendarDay(
|
||||||
|
day = dayNumber,
|
||||||
|
hasSession = sessionsOnDate.isNotEmpty(),
|
||||||
|
isSelected = isSelected,
|
||||||
|
isToday = isToday,
|
||||||
|
onClick = {
|
||||||
|
if (sessionsOnDate.isNotEmpty()) {
|
||||||
|
onDateSelected(if (isSelected) null else date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.aspectRatio(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDate != null) {
|
||||||
|
val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList()
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
items(sessionsOnSelectedDate) { session ->
|
||||||
|
SessionCard(
|
||||||
|
session = session,
|
||||||
|
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
||||||
|
onClick = { onNavigateToSessionDetail(session.id) }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CalendarDay(
|
||||||
|
day: Int,
|
||||||
|
hasSession: Boolean,
|
||||||
|
isSelected: Boolean,
|
||||||
|
isToday: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.aspectRatio(1f)
|
||||||
|
.padding(2.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
when {
|
||||||
|
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
isToday -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clickable(enabled = hasSession, onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = day.toString(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color =
|
||||||
|
when {
|
||||||
|
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
isToday -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
!hasSession -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
fontWeight = if (hasSession || isToday) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasSession) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(6.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.primary.copy(
|
||||||
|
alpha = 0.7f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun formatDate(dateString: String): String {
|
private fun formatDate(dateString: String): String {
|
||||||
return DateFormatUtils.formatDateForDisplay(dateString)
|
return DateFormatUtils.formatDateForDisplay(dateString)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.atridad.ascently.MainActivity
|
|||||||
import com.atridad.ascently.R
|
import com.atridad.ascently.R
|
||||||
import com.atridad.ascently.data.database.AscentlyDatabase
|
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||||
import com.atridad.ascently.data.repository.ClimbRepository
|
import com.atridad.ascently.data.repository.ClimbRepository
|
||||||
|
import java.time.LocalDate
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -48,53 +49,47 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
|||||||
val database = AscentlyDatabase.getDatabase(context)
|
val database = AscentlyDatabase.getDatabase(context)
|
||||||
val repository = ClimbRepository(database, 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 sessions = repository.getAllSessions().first()
|
||||||
val problems = repository.getAllProblems().first()
|
|
||||||
val attempts = repository.getAllAttempts().first()
|
val attempts = repository.getAllAttempts().first()
|
||||||
val gyms = repository.getAllGyms().first()
|
|
||||||
|
|
||||||
// Calculate stats
|
// Filter for last 7 days across all gyms
|
||||||
val completedSessions = sessions.filter { it.endTime != null }
|
val weekSessions =
|
||||||
|
sessions.filter { session ->
|
||||||
// Count problems that have been completed (have at least one successful attempt)
|
try {
|
||||||
val completedProblems =
|
val sessionDate = LocalDate.parse(session.date.substring(0, 10))
|
||||||
problems
|
!sessionDate.isBefore(sevenDaysAgo) && !sessionDate.isAfter(today)
|
||||||
.filter { problem ->
|
} catch (_: Exception) {
|
||||||
attempts.any { attempt ->
|
false
|
||||||
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 =
|
val weekSessionIds = weekSessions.map { it.id }.toSet()
|
||||||
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
|
|
||||||
(gymId, _) ->
|
// Count total attempts this week
|
||||||
gyms.find { it.id == gymId }?.name
|
val totalAttempts =
|
||||||
}
|
attempts.count { attempt -> weekSessionIds.contains(attempt.sessionId) }
|
||||||
?: "No sessions yet"
|
|
||||||
|
// Count sessions this week
|
||||||
|
val totalSessions = weekSessions.size
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
|
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
|
||||||
|
|
||||||
views.setTextViewText(
|
// Set weekly stats
|
||||||
R.id.widget_total_sessions,
|
views.setTextViewText(R.id.widget_attempts_value, totalAttempts.toString())
|
||||||
completedSessions.size.toString()
|
views.setTextViewText(R.id.widget_sessions_value, totalSessions.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)
|
|
||||||
|
|
||||||
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 =
|
val pendingIntent =
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
@@ -110,10 +105,8 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
|
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
|
||||||
views.setTextViewText(R.id.widget_total_sessions, "0")
|
views.setTextViewText(R.id.widget_attempts_value, "0")
|
||||||
views.setTextViewText(R.id.widget_problems_completed, "0")
|
views.setTextViewText(R.id.widget_sessions_value, "0")
|
||||||
views.setTextViewText(R.id.widget_total_problems, "0")
|
|
||||||
views.setTextViewText(R.id.widget_favorite_gym, "No data")
|
|
||||||
|
|
||||||
val intent = Intent(context, MainActivity::class.java)
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
val pendingIntent =
|
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
@@ -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>
|
||||||
@@ -4,27 +4,6 @@
|
|||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
|
<path android:fillColor="#FFC107" android:pathData="M24.000,78.545 L41.851,38.380 L59.702,78.545 Z" />
|
||||||
<group
|
<path android:fillColor="#F44336" android:pathData="M39.372,78.545 L61.686,29.455 L84.000,78.545 Z" />
|
||||||
android:scaleX="0.7"
|
|
||||||
android:scaleY="0.7"
|
|
||||||
android:translateX="16.2"
|
|
||||||
android:translateY="20">
|
|
||||||
|
|
||||||
<!-- Left mountain (yellow/amber) -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFC107"
|
|
||||||
android:strokeColor="#1C1C1C"
|
|
||||||
android:strokeWidth="3"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:pathData="M15,70 L35,25 L55,70 Z" />
|
|
||||||
|
|
||||||
<!-- Right mountain (red) -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#F44336"
|
|
||||||
android:strokeColor="#1C1C1C"
|
|
||||||
android:strokeWidth="3"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:pathData="M40,70 L65,15 L90,70 Z" />
|
|
||||||
</group>
|
|
||||||
</vector>
|
</vector>
|
||||||
@@ -4,29 +4,6 @@
|
|||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FFC107" android:pathData="M2.000,20.182 L7.950,6.793 L13.901,20.182 Z" />
|
||||||
<!-- Left mountain (yellow/amber) -->
|
<path android:fillColor="#F44336" android:pathData="M7.124,20.182 L14.562,3.818 L22.000,20.182 Z" />
|
||||||
<path
|
|
||||||
android:fillColor="#FFC107"
|
|
||||||
android:pathData="M3,18 L8,9 L13,18 Z" />
|
|
||||||
|
|
||||||
<!-- Right mountain (red) -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#F44336"
|
|
||||||
android:pathData="M11,18 L16,7 L21,18 Z" />
|
|
||||||
|
|
||||||
<!-- Black outlines -->
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/transparent"
|
|
||||||
android:strokeColor="#1C1C1C"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:pathData="M3,18 L8,9 L13,18" />
|
|
||||||
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/transparent"
|
|
||||||
android:strokeColor="#1C1C1C"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:pathData="M11,18 L16,7 L21,18" />
|
|
||||||
</vector>
|
</vector>
|
||||||
19
android/app/src/main/res/drawable/ic_splash.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFC107"
|
||||||
|
android:pathData="M24,74 L42,34 L60,74 Z" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#F44336"
|
||||||
|
android:pathData="M41,74 L59,24 L84,74 Z" />
|
||||||
|
</vector>
|
||||||
@@ -5,190 +5,84 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@drawable/widget_background"
|
android:background="@drawable/widget_background"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="12dp">
|
android:padding="12dp"
|
||||||
|
android:gravity="center">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header with icon and "Weekly" text -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
android:layout_marginBottom="12dp">
|
android:layout_marginBottom="12dp">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="24dp"
|
android:layout_width="28dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="28dp"
|
||||||
android:src="@drawable/ic_mountains"
|
android:src="@drawable/ic_mountains"
|
||||||
android:tint="@color/widget_primary"
|
android:tint="@color/widget_primary"
|
||||||
android:layout_marginEnd="8dp" />
|
android:layout_marginEnd="8dp"
|
||||||
|
android:contentDescription="@string/ascently_icon" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:text="@string/weekly"
|
||||||
android:text="Ascently"
|
android:textSize="18sp"
|
||||||
android:textSize="16sp"
|
android:textColor="@color/widget_text_primary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Attempts Row -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="12dp">
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
<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:textStyle="bold"
|
||||||
android:textColor="@color/widget_text_primary" />
|
android:textColor="@color/widget_text_primary" />
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Climbing Stats"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="@color/widget_text_secondary" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Stats Grid -->
|
<!-- Sessions Row -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center">
|
|
||||||
|
|
||||||
<!-- Top Row -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:layout_marginBottom="8dp">
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
<!-- Sessions Card -->
|
<ImageView
|
||||||
<LinearLayout
|
android:layout_width="32dp"
|
||||||
android:layout_width="0dp"
|
android:layout_height="32dp"
|
||||||
android:layout_height="match_parent"
|
android:src="@drawable/ic_play_arrow_24"
|
||||||
android:layout_weight="1"
|
android:tint="@color/widget_primary"
|
||||||
android:orientation="vertical"
|
android:layout_marginEnd="12dp"
|
||||||
android:gravity="center"
|
android:contentDescription="@string/sessions_icon" />
|
||||||
android:background="@drawable/widget_stat_card_background"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
android:padding="12dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_total_sessions"
|
android:id="@+id/widget_sessions_value"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="0"
|
android:text="@string/_0"
|
||||||
android:textSize="22sp"
|
android:textSize="40sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@color/widget_primary" />
|
android:textColor="@color/widget_text_primary" />
|
||||||
|
|
||||||
<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" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 550 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 730 B |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 388 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 514 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 628 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 854 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 970 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -12,5 +12,9 @@
|
|||||||
<string name="shortcut_end_session_disabled">No active session to end</string>
|
<string name="shortcut_end_session_disabled">No active session to end</string>
|
||||||
|
|
||||||
<!-- Widget -->
|
<!-- 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>
|
</resources>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
<style name="Theme.Ascently.Splash" parent="Theme.Ascently">
|
<style name="Theme.Ascently.Splash" parent="Theme.Ascently">
|
||||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
|
||||||
<item name="android:windowSplashScreenAnimationDuration">200</item>
|
<item name="android:windowSplashScreenAnimationDuration">200</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -3,15 +3,14 @@
|
|||||||
android:description="@string/widget_description"
|
android:description="@string/widget_description"
|
||||||
android:initialKeyguardLayout="@layout/widget_climb_stats"
|
android:initialKeyguardLayout="@layout/widget_climb_stats"
|
||||||
android:initialLayout="@layout/widget_climb_stats"
|
android:initialLayout="@layout/widget_climb_stats"
|
||||||
android:minWidth="250dp"
|
android:minWidth="110dp"
|
||||||
android:minHeight="180dp"
|
android:minHeight="110dp"
|
||||||
|
android:maxResizeWidth="110dp"
|
||||||
|
android:maxResizeHeight="110dp"
|
||||||
android:previewImage="@drawable/ic_mountains"
|
android:previewImage="@drawable/ic_mountains"
|
||||||
android:previewLayout="@layout/widget_climb_stats"
|
android:previewLayout="@layout/widget_climb_stats"
|
||||||
android:resizeMode="horizontal|vertical"
|
android:resizeMode="none"
|
||||||
android:targetCellWidth="4"
|
android:targetCellWidth="2"
|
||||||
android:targetCellHeight="2"
|
android:targetCellHeight="2"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
android:widgetCategory="home_screen"
|
android:widgetCategory="home_screen" />
|
||||||
android:widgetFeatures="reconfigurable"
|
|
||||||
android:maxResizeWidth="320dp"
|
|
||||||
android:maxResizeHeight="240dp" />
|
|
||||||
|
|||||||
3
branding/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
394
branding/generate.py
Executable file
@@ -0,0 +1,394 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, TypedDict
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
|
||||||
|
class Polygon(TypedDict):
|
||||||
|
coords: list[tuple[float, float]]
|
||||||
|
fill: str
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
|
PROJECT_ROOT = SCRIPT_DIR.parent
|
||||||
|
SOURCE_DIR = SCRIPT_DIR / "source"
|
||||||
|
LOGOS_DIR = SCRIPT_DIR / "logos"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_svg_polygons(svg_path: Path) -> list[Polygon]:
|
||||||
|
tree = ET.parse(svg_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
ns = {"svg": "http://www.w3.org/2000/svg"}
|
||||||
|
polygons = root.findall(".//svg:polygon", ns)
|
||||||
|
if not polygons:
|
||||||
|
polygons = root.findall(".//polygon")
|
||||||
|
|
||||||
|
result: list[Polygon] = []
|
||||||
|
for poly in polygons:
|
||||||
|
points_str = poly.get("points", "").strip()
|
||||||
|
fill = poly.get("fill", "#000000")
|
||||||
|
|
||||||
|
coords: list[tuple[float, float]] = []
|
||||||
|
for pair in points_str.split():
|
||||||
|
x, y = pair.split(",")
|
||||||
|
coords.append((float(x), float(y)))
|
||||||
|
|
||||||
|
result.append({"coords": coords, "fill": fill})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_bbox(polygons: list[Polygon]) -> dict[str, float]:
|
||||||
|
all_coords: list[tuple[float, float]] = []
|
||||||
|
for poly in polygons:
|
||||||
|
all_coords.extend(poly["coords"])
|
||||||
|
|
||||||
|
xs = [c[0] for c in all_coords]
|
||||||
|
ys = [c[1] for c in all_coords]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"min_x": min(xs),
|
||||||
|
"max_x": max(xs),
|
||||||
|
"min_y": min(ys),
|
||||||
|
"max_y": max(ys),
|
||||||
|
"width": max(xs) - min(xs),
|
||||||
|
"height": max(ys) - min(ys),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scale_and_center(
|
||||||
|
polygons: list[Polygon], viewbox_size: float, target_width: float
|
||||||
|
) -> list[Polygon]:
|
||||||
|
bbox = get_bbox(polygons)
|
||||||
|
|
||||||
|
scale = target_width / bbox["width"]
|
||||||
|
center = viewbox_size / 2
|
||||||
|
|
||||||
|
scaled_polys: list[Polygon] = []
|
||||||
|
for poly in polygons:
|
||||||
|
scaled_coords = [(x * scale, y * scale) for x, y in poly["coords"]]
|
||||||
|
scaled_polys.append({"coords": scaled_coords, "fill": poly["fill"]})
|
||||||
|
|
||||||
|
scaled_bbox = get_bbox(scaled_polys)
|
||||||
|
current_center_x = (scaled_bbox["min_x"] + scaled_bbox["max_x"]) / 2
|
||||||
|
current_center_y = (scaled_bbox["min_y"] + scaled_bbox["max_y"]) / 2
|
||||||
|
|
||||||
|
offset_x = center - current_center_x
|
||||||
|
offset_y = center - current_center_y
|
||||||
|
|
||||||
|
final_polys: list[Polygon] = []
|
||||||
|
for poly in scaled_polys:
|
||||||
|
final_coords = [(x + offset_x, y + offset_y) for x, y in poly["coords"]]
|
||||||
|
final_polys.append({"coords": final_coords, "fill": poly["fill"]})
|
||||||
|
|
||||||
|
return final_polys
|
||||||
|
|
||||||
|
|
||||||
|
def format_svg_points(coords: list[tuple[float, float]]) -> str:
|
||||||
|
return " ".join(f"{x:.3f},{y:.3f}" for x, y in coords)
|
||||||
|
|
||||||
|
|
||||||
|
def format_android_path(coords: list[tuple[float, float]]) -> str:
|
||||||
|
points = " ".join(f"{x:.3f},{y:.3f}" for x, y in coords)
|
||||||
|
pairs = points.split()
|
||||||
|
return f"M{pairs[0]} L{pairs[1]} L{pairs[2]} Z"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_svg(polygons: list[Polygon], width: int, height: int) -> str:
|
||||||
|
lines = [
|
||||||
|
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">'
|
||||||
|
]
|
||||||
|
for poly in polygons:
|
||||||
|
points = format_svg_points(poly["coords"])
|
||||||
|
lines.append(f' <polygon points="{points}" fill="{poly["fill"]}"/>')
|
||||||
|
lines.append("</svg>")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_android_vector(
|
||||||
|
polygons: list[Polygon], width: int, height: int, viewbox: int
|
||||||
|
) -> str:
|
||||||
|
lines = [
|
||||||
|
'<?xml version="1.0" encoding="utf-8"?>',
|
||||||
|
'<vector xmlns:android="http://schemas.android.com/apk/res/android"',
|
||||||
|
f' android:width="{width}dp"',
|
||||||
|
f' android:height="{height}dp"',
|
||||||
|
f' android:viewportWidth="{viewbox}"',
|
||||||
|
f' android:viewportHeight="{viewbox}">',
|
||||||
|
]
|
||||||
|
for poly in polygons:
|
||||||
|
path = format_android_path(poly["coords"])
|
||||||
|
lines.append(
|
||||||
|
f' <path android:fillColor="{poly["fill"]}" android:pathData="{path}" />'
|
||||||
|
)
|
||||||
|
lines.append("</vector>")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def rasterize_svg(
|
||||||
|
svg_path: Path,
|
||||||
|
output_path: Path,
|
||||||
|
size: int,
|
||||||
|
bg_color: tuple[int, int, int, int] | None = None,
|
||||||
|
circular: bool = False,
|
||||||
|
) -> None:
|
||||||
|
from xml.dom import minidom
|
||||||
|
|
||||||
|
doc = minidom.parse(str(svg_path))
|
||||||
|
|
||||||
|
img = Image.new(
|
||||||
|
"RGBA", (size, size), (255, 255, 255, 0) if bg_color is None else bg_color
|
||||||
|
)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
svg_elem = doc.getElementsByTagName("svg")[0]
|
||||||
|
viewbox = svg_elem.getAttribute("viewBox").split()
|
||||||
|
if viewbox:
|
||||||
|
vb_width = float(viewbox[2])
|
||||||
|
vb_height = float(viewbox[3])
|
||||||
|
scale_x = size / vb_width
|
||||||
|
scale_y = size / vb_height
|
||||||
|
else:
|
||||||
|
scale_x = scale_y = 1
|
||||||
|
|
||||||
|
def parse_transform(
|
||||||
|
transform_str: str,
|
||||||
|
) -> Callable[[float, float], tuple[float, float]]:
|
||||||
|
import re
|
||||||
|
|
||||||
|
if not transform_str:
|
||||||
|
return lambda x, y: (x, y)
|
||||||
|
|
||||||
|
transforms: list[tuple[str, list[float]]] = []
|
||||||
|
for match in re.finditer(r"(\w+)\(([^)]+)\)", transform_str):
|
||||||
|
func, args_str = match.groups()
|
||||||
|
args = [float(x) for x in args_str.replace(",", " ").split()]
|
||||||
|
transforms.append((func, args))
|
||||||
|
|
||||||
|
def apply_transforms(x: float, y: float) -> tuple[float, float]:
|
||||||
|
for func, args in transforms:
|
||||||
|
if func == "translate":
|
||||||
|
x += args[0]
|
||||||
|
y += args[1] if len(args) > 1 else args[0]
|
||||||
|
elif func == "scale":
|
||||||
|
x *= args[0]
|
||||||
|
y *= args[1] if len(args) > 1 else args[0]
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
return apply_transforms
|
||||||
|
|
||||||
|
for g in doc.getElementsByTagName("g"):
|
||||||
|
transform = parse_transform(g.getAttribute("transform"))
|
||||||
|
|
||||||
|
for poly in g.getElementsByTagName("polygon"):
|
||||||
|
points_str = poly.getAttribute("points").strip()
|
||||||
|
fill = poly.getAttribute("fill")
|
||||||
|
if not fill:
|
||||||
|
fill = "#000000"
|
||||||
|
|
||||||
|
coords: list[tuple[float, float]] = []
|
||||||
|
for pair in points_str.split():
|
||||||
|
x, y = pair.split(",")
|
||||||
|
x, y = float(x), float(y)
|
||||||
|
x, y = transform(x, y)
|
||||||
|
coords.append((x * scale_x, y * scale_y))
|
||||||
|
|
||||||
|
draw.polygon(coords, fill=fill)
|
||||||
|
|
||||||
|
for poly in doc.getElementsByTagName("polygon"):
|
||||||
|
if poly.parentNode and getattr(poly.parentNode, "tagName", None) == "g":
|
||||||
|
continue
|
||||||
|
|
||||||
|
points_str = poly.getAttribute("points").strip()
|
||||||
|
fill = poly.getAttribute("fill")
|
||||||
|
if not fill:
|
||||||
|
fill = "#000000"
|
||||||
|
|
||||||
|
coords = []
|
||||||
|
for pair in points_str.split():
|
||||||
|
x, y = pair.split(",")
|
||||||
|
coords.append((float(x) * scale_x, float(y) * scale_y))
|
||||||
|
|
||||||
|
draw.polygon(coords, fill=fill)
|
||||||
|
|
||||||
|
if circular:
|
||||||
|
mask = Image.new("L", (size, size), 0)
|
||||||
|
mask_draw = ImageDraw.Draw(mask)
|
||||||
|
mask_draw.ellipse((0, 0, size, size), fill=255)
|
||||||
|
img.putalpha(mask)
|
||||||
|
|
||||||
|
img.save(output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print("Generating branding assets...")
|
||||||
|
|
||||||
|
logo_svg = SOURCE_DIR / "logo.svg"
|
||||||
|
icon_light = SOURCE_DIR / "icon-light.svg"
|
||||||
|
icon_dark = SOURCE_DIR / "icon-dark.svg"
|
||||||
|
icon_tinted = SOURCE_DIR / "icon-tinted.svg"
|
||||||
|
|
||||||
|
polygons = parse_svg_polygons(logo_svg)
|
||||||
|
|
||||||
|
print(" iOS...")
|
||||||
|
ios_assets = PROJECT_ROOT / "ios/Ascently/Assets.xcassets/AppIcon.appiconset"
|
||||||
|
|
||||||
|
for src, dst in [
|
||||||
|
(icon_light, ios_assets / "app_icon_light_template.svg"),
|
||||||
|
(icon_dark, ios_assets / "app_icon_dark_template.svg"),
|
||||||
|
(icon_tinted, ios_assets / "app_icon_tinted_template.svg"),
|
||||||
|
]:
|
||||||
|
with open(src) as f:
|
||||||
|
content = f.read()
|
||||||
|
with open(dst, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
img_light = Image.new("RGB", (1024, 1024), (255, 255, 255))
|
||||||
|
draw_light = ImageDraw.Draw(img_light)
|
||||||
|
scaled = scale_and_center(polygons, 1024, int(1024 * 0.7))
|
||||||
|
for poly in scaled:
|
||||||
|
coords = [(x, y) for x, y in poly["coords"]]
|
||||||
|
draw_light.polygon(coords, fill=poly["fill"])
|
||||||
|
img_light.save(ios_assets / "app_icon_1024.png")
|
||||||
|
|
||||||
|
img_dark = Image.new("RGB", (1024, 1024), (26, 26, 26))
|
||||||
|
draw_dark = ImageDraw.Draw(img_dark)
|
||||||
|
for poly in scaled:
|
||||||
|
coords = [(x, y) for x, y in poly["coords"]]
|
||||||
|
draw_dark.polygon(coords, fill=poly["fill"])
|
||||||
|
img_dark.save(ios_assets / "app_icon_1024_dark.png")
|
||||||
|
|
||||||
|
img_tinted = Image.new("RGB", (1024, 1024), (0, 0, 0))
|
||||||
|
draw_tinted = ImageDraw.Draw(img_tinted)
|
||||||
|
for i, poly in enumerate(scaled):
|
||||||
|
coords = [(x, y) for x, y in poly["coords"]]
|
||||||
|
draw_tinted.polygon(coords, fill=(0, 0, 0))
|
||||||
|
img_tinted.save(ios_assets / "app_icon_1024_tinted.png")
|
||||||
|
|
||||||
|
print(" Android...")
|
||||||
|
|
||||||
|
polys_108 = scale_and_center(polygons, 108, 60)
|
||||||
|
android_xml = generate_android_vector(polys_108, 108, 108, 108)
|
||||||
|
(
|
||||||
|
PROJECT_ROOT / "android/app/src/main/res/drawable/ic_launcher_foreground.xml"
|
||||||
|
).write_text(android_xml)
|
||||||
|
|
||||||
|
polys_24 = scale_and_center(polygons, 24, 20)
|
||||||
|
mountains_xml = generate_android_vector(polys_24, 24, 24, 24)
|
||||||
|
(PROJECT_ROOT / "android/app/src/main/res/drawable/ic_mountains.xml").write_text(
|
||||||
|
mountains_xml
|
||||||
|
)
|
||||||
|
|
||||||
|
for density, size in [
|
||||||
|
("mdpi", 48),
|
||||||
|
("hdpi", 72),
|
||||||
|
("xhdpi", 96),
|
||||||
|
("xxhdpi", 144),
|
||||||
|
("xxxhdpi", 192),
|
||||||
|
]:
|
||||||
|
mipmap_dir = PROJECT_ROOT / f"android/app/src/main/res/mipmap-{density}"
|
||||||
|
|
||||||
|
img = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
scaled = scale_and_center(polygons, size, int(size * 0.6))
|
||||||
|
for poly in scaled:
|
||||||
|
coords = [(x, y) for x, y in poly["coords"]]
|
||||||
|
draw.polygon(coords, fill=poly["fill"])
|
||||||
|
|
||||||
|
img.save(mipmap_dir / "ic_launcher.webp")
|
||||||
|
|
||||||
|
img_round = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||||
|
draw_round = ImageDraw.Draw(img_round)
|
||||||
|
|
||||||
|
for poly in scaled:
|
||||||
|
coords = [(x, y) for x, y in poly["coords"]]
|
||||||
|
draw_round.polygon(coords, fill=poly["fill"])
|
||||||
|
|
||||||
|
mask = Image.new("L", (size, size), 0)
|
||||||
|
mask_draw = ImageDraw.Draw(mask)
|
||||||
|
mask_draw.ellipse((0, 0, size, size), fill=255)
|
||||||
|
img_round.putalpha(mask)
|
||||||
|
|
||||||
|
img_round.save(mipmap_dir / "ic_launcher_round.webp")
|
||||||
|
|
||||||
|
print(" Docs...")
|
||||||
|
|
||||||
|
polys_32 = scale_and_center(polygons, 32, 26)
|
||||||
|
logo_svg_32 = generate_svg(polys_32, 32, 32)
|
||||||
|
(PROJECT_ROOT / "docs/src/assets/logo.svg").write_text(logo_svg_32)
|
||||||
|
(PROJECT_ROOT / "docs/src/assets/logo-dark.svg").write_text(logo_svg_32)
|
||||||
|
|
||||||
|
polys_256 = scale_and_center(polygons, 256, 208)
|
||||||
|
logo_svg_256 = generate_svg(polys_256, 256, 256)
|
||||||
|
(PROJECT_ROOT / "docs/src/assets/logo-highres.svg").write_text(logo_svg_256)
|
||||||
|
|
||||||
|
logo_32_path = PROJECT_ROOT / "docs/src/assets/logo.svg"
|
||||||
|
rasterize_svg(logo_32_path, PROJECT_ROOT / "docs/public/favicon.png", 32)
|
||||||
|
|
||||||
|
sizes = [16, 32, 48]
|
||||||
|
imgs = []
|
||||||
|
for size in sizes:
|
||||||
|
img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
scaled = scale_and_center(polygons, size, int(size * 0.8))
|
||||||
|
for poly in scaled:
|
||||||
|
coords = [(x, y) for x, y in poly["coords"]]
|
||||||
|
draw.polygon(coords, fill=poly["fill"])
|
||||||
|
|
||||||
|
imgs.append(img)
|
||||||
|
|
||||||
|
imgs[0].save(
|
||||||
|
PROJECT_ROOT / "docs/public/favicon.ico",
|
||||||
|
format="ICO",
|
||||||
|
sizes=[(s, s) for s in sizes],
|
||||||
|
append_images=imgs[1:],
|
||||||
|
)
|
||||||
|
|
||||||
|
print(" Logos...")
|
||||||
|
|
||||||
|
LOGOS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
sizes = [64, 128, 256, 512, 1024, 2048]
|
||||||
|
for size in sizes:
|
||||||
|
img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
scaled = scale_and_center(polygons, size, int(size * 0.8))
|
||||||
|
for poly in scaled:
|
||||||
|
coords = [(x, y) for x, y in poly["coords"]]
|
||||||
|
draw.polygon(coords, fill=poly["fill"])
|
||||||
|
|
||||||
|
img.save(LOGOS_DIR / f"logo-{size}.png")
|
||||||
|
|
||||||
|
for size in sizes:
|
||||||
|
img = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
scaled = scale_and_center(polygons, size, int(size * 0.8))
|
||||||
|
for poly in scaled:
|
||||||
|
coords = [(x, y) for x, y in poly["coords"]]
|
||||||
|
draw.polygon(coords, fill=poly["fill"])
|
||||||
|
|
||||||
|
img.save(LOGOS_DIR / f"logo-{size}-white.png")
|
||||||
|
|
||||||
|
for size in sizes:
|
||||||
|
img = Image.new("RGBA", (size, size), (26, 26, 26, 255))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
scaled = scale_and_center(polygons, size, int(size * 0.8))
|
||||||
|
for poly in scaled:
|
||||||
|
coords = [(x, y) for x, y in poly["coords"]]
|
||||||
|
draw.polygon(coords, fill=poly["fill"])
|
||||||
|
|
||||||
|
img.save(LOGOS_DIR / f"logo-{size}-dark.png")
|
||||||
|
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
12
branding/generate.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
echo "Error: Python 3 required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 "$SCRIPT_DIR/generate.py"
|
||||||
BIN
branding/logos/logo-1024-dark.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
branding/logos/logo-1024-white.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
branding/logos/logo-1024.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
branding/logos/logo-128-dark.png
Normal file
|
After Width: | Height: | Size: 804 B |
BIN
branding/logos/logo-128-white.png
Normal file
|
After Width: | Height: | Size: 798 B |
BIN
branding/logos/logo-128.png
Normal file
|
After Width: | Height: | Size: 795 B |
BIN
branding/logos/logo-2048-dark.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
branding/logos/logo-2048-white.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
branding/logos/logo-2048.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
branding/logos/logo-256-dark.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
branding/logos/logo-256-white.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
branding/logos/logo-256.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
branding/logos/logo-512-dark.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
branding/logos/logo-512-white.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
branding/logos/logo-512.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
branding/logos/logo-64-dark.png
Normal file
|
After Width: | Height: | Size: 411 B |
BIN
branding/logos/logo-64-white.png
Normal file
|
After Width: | Height: | Size: 413 B |
BIN
branding/logos/logo-64.png
Normal file
|
After Width: | Height: | Size: 413 B |
8
branding/source/icon-dark.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
|
||||||
|
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||||
|
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
|
||||||
|
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 411 B |
8
branding/source/icon-light.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
|
||||||
|
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||||
|
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
|
||||||
|
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 411 B |
8
branding/source/icon-tinted.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
|
||||||
|
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||||
|
<polygon points="8,75 35,14.25 62,75" fill="#000000" opacity="0.8"/>
|
||||||
|
<polygon points="31.25,75 65,0.75 98.75,75" fill="#000000" opacity="0.9"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 443 B |
5
branding/source/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="108" height="108" viewBox="0 0 108 108" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
|
||||||
|
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 254 B |
@@ -27,7 +27,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.5.0",
|
"@astrojs/node": "^9.5.0",
|
||||||
"@astrojs/starlight": "^0.36.1",
|
"@astrojs/starlight": "^0.36.1",
|
||||||
"astro": "^5.14.5",
|
"astro": "^5.14.6",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.4"
|
"sharp": "^0.34.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
488
docs/pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 166 B |
|
Before Width: | Height: | Size: 731 B After Width: | Height: | Size: 229 B |
|
Before Width: | Height: | Size: 96 KiB |
@@ -1,15 +1,4 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- Left mountain (amber/yellow) -->
|
<polygon points="3.000,26.636 10.736,9.231 18.471,26.636" fill="#FFC107"/>
|
||||||
<polygon points="6,24 12,8 18,24"
|
<polygon points="9.661,26.636 19.331,5.364 29.000,26.636" fill="#F44336"/>
|
||||||
fill="#FFC107"
|
|
||||||
stroke="#FFFFFF"
|
|
||||||
stroke-width="1"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
|
|
||||||
<!-- Right mountain (red) -->
|
|
||||||
<polygon points="14,24 22,4 30,24"
|
|
||||||
fill="#F44336"
|
|
||||||
stroke="#FFFFFF"
|
|
||||||
stroke-width="1"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 475 B After Width: | Height: | Size: 244 B |
@@ -1,15 +1,4 @@
|
|||||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- Left mountain (amber/yellow) -->
|
<polygon points="24.000,213.091 85.884,73.851 147.769,213.091" fill="#FFC107"/>
|
||||||
<polygon points="48,192 96,64 144,192"
|
<polygon points="77.289,213.091 154.645,42.909 232.000,213.091" fill="#F44336"/>
|
||||||
fill="#FFC107"
|
|
||||||
stroke="#1C1C1C"
|
|
||||||
stroke-width="4"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
|
|
||||||
<!-- Right mountain (red) -->
|
|
||||||
<polygon points="112,192 176,32 240,192"
|
|
||||||
fill="#F44336"
|
|
||||||
stroke="#1C1C1C"
|
|
||||||
stroke-width="4"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 490 B After Width: | Height: | Size: 259 B |
@@ -1,15 +1,4 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- Left mountain (amber/yellow) -->
|
<polygon points="3.000,26.636 10.736,9.231 18.471,26.636" fill="#FFC107"/>
|
||||||
<polygon points="6,24 12,8 18,24"
|
<polygon points="9.661,26.636 19.331,5.364 29.000,26.636" fill="#F44336"/>
|
||||||
fill="#FFC107"
|
|
||||||
stroke="#1C1C1C"
|
|
||||||
stroke-width="1"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
|
|
||||||
<!-- Right mountain (red) -->
|
|
||||||
<polygon points="14,24 22,4 30,24"
|
|
||||||
fill="#F44336"
|
|
||||||
stroke="#1C1C1C"
|
|
||||||
stroke-width="1"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 475 B After Width: | Height: | Size: 244 B |
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
@@ -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
@@ -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
@@ -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_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -487,7 +487,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||||
MARKETING_VERSION = 2.1.0;
|
MARKETING_VERSION = 2.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -513,7 +513,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -535,7 +535,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||||
MARKETING_VERSION = 2.1.0;
|
MARKETING_VERSION = 2.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -602,7 +602,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -613,7 +613,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.1.0;
|
MARKETING_VERSION = 2.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -632,7 +632,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -643,7 +643,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.1.0;
|
MARKETING_VERSION = 2.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -1,22 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- Dark background with rounded corners for iOS -->
|
|
||||||
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
|
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
|
||||||
|
|
||||||
<!-- Transform to match Android layout exactly -->
|
|
||||||
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||||
<!-- Left mountain (yellow/amber) - matches Android coordinates with white border -->
|
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
|
||||||
<polygon points="15,70 35,25 55,70"
|
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
|
||||||
fill="#FFC107"
|
|
||||||
stroke="#FFFFFF"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
|
|
||||||
<!-- Right mountain (red) - matches Android coordinates with white border -->
|
|
||||||
<polygon points="40,70 65,15 90,70"
|
|
||||||
fill="#F44336"
|
|
||||||
stroke="#FFFFFF"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 913 B After Width: | Height: | Size: 411 B |
@@ -1,22 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- White background with rounded corners for iOS -->
|
|
||||||
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
|
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
|
||||||
|
|
||||||
<!-- Transform to match Android layout exactly -->
|
|
||||||
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||||
<!-- Left mountain (yellow/amber) - matches Android coordinates -->
|
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
|
||||||
<polygon points="15,70 35,25 55,70"
|
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
|
||||||
fill="#FFC107"
|
|
||||||
stroke="#1C1C1C"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
|
|
||||||
<!-- Right mountain (red) - matches Android coordinates -->
|
|
||||||
<polygon points="40,70 65,15 90,70"
|
|
||||||
fill="#F44336"
|
|
||||||
stroke="#1C1C1C"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 878 B After Width: | Height: | Size: 411 B |
@@ -1,24 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- Transparent background with rounded corners for iOS tinted mode -->
|
|
||||||
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
|
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
|
||||||
|
|
||||||
<!-- Transform to match Android layout exactly -->
|
|
||||||
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||||
<!-- Left mountain - matches Android coordinates, black fill for tinting -->
|
<polygon points="8,75 35,14.25 62,75" fill="#000000" opacity="0.8"/>
|
||||||
<polygon points="15,70 35,25 55,70"
|
<polygon points="31.25,75 65,0.75 98.75,75" fill="#000000" opacity="0.9"/>
|
||||||
fill="#000000"
|
|
||||||
stroke="#000000"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
opacity="0.8"/>
|
|
||||||
|
|
||||||
<!-- Right mountain - matches Android coordinates, black fill for tinting -->
|
|
||||||
<polygon points="40,70 65,15 90,70"
|
|
||||||
fill="#000000"
|
|
||||||
stroke="#000000"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
opacity="0.9"/>
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 981 B After Width: | Height: | Size: 443 B |
@@ -158,7 +158,7 @@ class SyncService: ObservableObject {
|
|||||||
let modifiedProblems = dataManager.problems.filter { problem in
|
let modifiedProblems = dataManager.problems.filter { problem in
|
||||||
problem.updatedAt > lastSync
|
problem.updatedAt > lastSync
|
||||||
}.map { problem -> BackupProblem in
|
}.map { problem -> BackupProblem in
|
||||||
var backupProblem = BackupProblem(from: problem)
|
let backupProblem = BackupProblem(from: problem)
|
||||||
if !problem.imagePaths.isEmpty {
|
if !problem.imagePaths.isEmpty {
|
||||||
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
|
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
|
||||||
ImageNamingUtils.generateImageFilename(
|
ImageNamingUtils.generateImageFilename(
|
||||||
@@ -266,9 +266,25 @@ class SyncService: ObservableObject {
|
|||||||
{
|
{
|
||||||
let formatter = ISO8601DateFormatter()
|
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
|
// Download images for new/modified problems from server
|
||||||
var imagePathMapping: [String: String] = [:]
|
var imagePathMapping: [String: String] = [:]
|
||||||
for problem in response.problems {
|
for problem in response.problems {
|
||||||
|
if deletedItemSet.contains("problem:" + problem.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
|
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
|
||||||
|
|
||||||
for (index, imagePath) in imagePaths.enumerated() {
|
for (index, imagePath) in imagePaths.enumerated() {
|
||||||
@@ -293,6 +309,10 @@ class SyncService: ObservableObject {
|
|||||||
|
|
||||||
// Merge gyms
|
// Merge gyms
|
||||||
for backupGym in response.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 })
|
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id })
|
||||||
{
|
{
|
||||||
let existing = dataManager.gyms[index]
|
let existing = dataManager.gyms[index]
|
||||||
@@ -306,6 +326,10 @@ class SyncService: ObservableObject {
|
|||||||
|
|
||||||
// Merge problems
|
// Merge problems
|
||||||
for backupProblem in response.problems {
|
for backupProblem in response.problems {
|
||||||
|
if deletedItemSet.contains("problem:" + backupProblem.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
var problemToMerge = backupProblem
|
var problemToMerge = backupProblem
|
||||||
if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths {
|
if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths {
|
||||||
let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 }
|
let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 }
|
||||||
@@ -341,6 +365,10 @@ class SyncService: ObservableObject {
|
|||||||
|
|
||||||
// Merge sessions
|
// Merge sessions
|
||||||
for backupSession in response.sessions {
|
for backupSession in response.sessions {
|
||||||
|
if deletedItemSet.contains("session:" + backupSession.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if let index = dataManager.sessions.firstIndex(where: {
|
if let index = dataManager.sessions.firstIndex(where: {
|
||||||
$0.id.uuidString == backupSession.id
|
$0.id.uuidString == backupSession.id
|
||||||
}) {
|
}) {
|
||||||
@@ -355,6 +383,10 @@ class SyncService: ObservableObject {
|
|||||||
|
|
||||||
// Merge attempts
|
// Merge attempts
|
||||||
for backupAttempt in response.attempts {
|
for backupAttempt in response.attempts {
|
||||||
|
if deletedItemSet.contains("attempt:" + backupAttempt.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if let index = dataManager.attempts.firstIndex(where: {
|
if let index = dataManager.attempts.firstIndex(where: {
|
||||||
$0.id.uuidString == backupAttempt.id
|
$0.id.uuidString == backupAttempt.id
|
||||||
}) {
|
}) {
|
||||||
@@ -367,9 +399,7 @@ class SyncService: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply deletions
|
// Apply deletions again for safety
|
||||||
let allDeletions = dataManager.getDeletedItems() + response.deletedItems
|
|
||||||
let uniqueDeletions = Array(Set(allDeletions))
|
|
||||||
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
|
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
|
||||||
|
|
||||||
// Save all changes
|
// Save all changes
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ struct OrientationAwareImage: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
loadImageWithCorrectOrientation()
|
loadImageWithCorrectOrientation()
|
||||||
}
|
}
|
||||||
.onChange(of: imagePath) { _ in
|
.onChange(of: imagePath) { _, _ in
|
||||||
loadImageWithCorrectOrientation()
|
loadImageWithCorrectOrientation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
338
ios/Ascently/Views/CalendarView.swift
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CalendarView: View {
|
||||||
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
|
let sessions: [ClimbSession]
|
||||||
|
@Binding var selectedMonth: Date
|
||||||
|
@Binding var selectedDate: Date?
|
||||||
|
let onNavigateToSession: (UUID) -> Void
|
||||||
|
|
||||||
|
var calendar: Calendar {
|
||||||
|
Calendar.current
|
||||||
|
}
|
||||||
|
|
||||||
|
var monthYearString: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMMM yyyy"
|
||||||
|
return formatter.string(from: selectedMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionsByDate: [Date: [ClimbSession]] {
|
||||||
|
Dictionary(grouping: sessions) { session in
|
||||||
|
calendar.startOfDay(for: session.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var daysInMonth: [Date?] {
|
||||||
|
guard let monthInterval = calendar.dateInterval(of: .month, for: selectedMonth),
|
||||||
|
calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) != nil
|
||||||
|
else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let days = calendar.generateDates(
|
||||||
|
inside: monthInterval,
|
||||||
|
matching: DateComponents(hour: 0, minute: 0, second: 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
let firstDayOfMonth = days.first ?? monthInterval.start
|
||||||
|
let firstWeekday = calendar.component(.weekday, from: firstDayOfMonth)
|
||||||
|
let offset = firstWeekday - 1
|
||||||
|
|
||||||
|
var paddedDays: [Date?] = Array(repeating: nil, count: offset)
|
||||||
|
paddedDays.append(contentsOf: days.map { $0 as Date? })
|
||||||
|
|
||||||
|
let remainder = paddedDays.count % 7
|
||||||
|
if remainder != 0 {
|
||||||
|
paddedDays.append(contentsOf: Array(repeating: nil, count: 7 - remainder))
|
||||||
|
}
|
||||||
|
|
||||||
|
return paddedDays
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if let activeSession = dataManager.activeSession,
|
||||||
|
let gym = dataManager.gym(withId: activeSession.gymId)
|
||||||
|
{
|
||||||
|
ActiveSessionBanner(session: activeSession, gym: gym)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Button(action: { changeMonth(by: -1) }) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(monthYearString)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: { changeMonth(by: 1) }) {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
let today = Date()
|
||||||
|
selectedMonth = today
|
||||||
|
selectedDate = today
|
||||||
|
}) {
|
||||||
|
Text("Today")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.blue)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(["S", "M", "T", "W", "T", "F", "S"], id: \.self) { day in
|
||||||
|
Text(day)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
LazyVGrid(
|
||||||
|
columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7),
|
||||||
|
spacing: 4
|
||||||
|
) {
|
||||||
|
ForEach(daysInMonth.indices, id: \.self) { index in
|
||||||
|
if let date = daysInMonth[index] {
|
||||||
|
CalendarDayCell(
|
||||||
|
date: date,
|
||||||
|
sessions: sessionsByDate[calendar.startOfDay(for: date)] ?? [],
|
||||||
|
isSelected: selectedDate.map {
|
||||||
|
calendar.isDate($0, inSameDayAs: date)
|
||||||
|
}
|
||||||
|
?? false,
|
||||||
|
isToday: calendar.isDateInToday(date),
|
||||||
|
isInCurrentMonth: calendar.isDate(
|
||||||
|
date, equalTo: selectedMonth, toGranularity: .month)
|
||||||
|
) {
|
||||||
|
if !sessionsByDate[calendar.startOfDay(for: date), default: []]
|
||||||
|
.isEmpty
|
||||||
|
{
|
||||||
|
if selectedDate.map({ calendar.isDate($0, inSameDayAs: date) })
|
||||||
|
?? false
|
||||||
|
{
|
||||||
|
selectedDate = nil
|
||||||
|
} else {
|
||||||
|
selectedDate = date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Color.clear
|
||||||
|
.aspectRatio(1, contentMode: .fit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if let selected = selectedDate,
|
||||||
|
let sessionsOnDate = sessionsByDate[calendar.startOfDay(for: selected)],
|
||||||
|
!sessionsOnDate.isEmpty
|
||||||
|
{
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Sessions on \(formatSelectedDate(selected))")
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(sessionsOnDate) { session in
|
||||||
|
SessionCard(
|
||||||
|
session: session,
|
||||||
|
onTap: {
|
||||||
|
onNavigateToSession(session.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeMonth(by value: Int) {
|
||||||
|
if let newMonth = calendar.date(byAdding: .month, value: value, to: selectedMonth) {
|
||||||
|
selectedMonth = newMonth
|
||||||
|
selectedDate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSelectedDate(_ date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMMM d, yyyy"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CalendarDayCell: View {
|
||||||
|
let date: Date
|
||||||
|
let sessions: [ClimbSession]
|
||||||
|
let isSelected: Bool
|
||||||
|
let isToday: Bool
|
||||||
|
let isInCurrentMonth: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var dayNumber: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "d"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text(dayNumber)
|
||||||
|
.font(.system(size: 17))
|
||||||
|
.fontWeight(sessions.isEmpty ? .regular : .medium)
|
||||||
|
.foregroundColor(
|
||||||
|
isSelected
|
||||||
|
? .white
|
||||||
|
: isToday
|
||||||
|
? .blue
|
||||||
|
: !isInCurrentMonth
|
||||||
|
? .secondary.opacity(0.3)
|
||||||
|
: sessions.isEmpty ? .secondary : .primary
|
||||||
|
)
|
||||||
|
|
||||||
|
if !sessions.isEmpty {
|
||||||
|
Circle()
|
||||||
|
.fill(isSelected ? .white : .blue)
|
||||||
|
.frame(width: 4, height: 4)
|
||||||
|
} else {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(
|
||||||
|
isSelected ? Color.blue : isToday ? Color.blue.opacity(0.1) : Color.clear
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(
|
||||||
|
isToday && !isSelected ? Color.blue.opacity(0.3) : Color.clear, lineWidth: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.disabled(sessions.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionCard: View {
|
||||||
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
|
let session: ClimbSession
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var gym: Gym? {
|
||||||
|
dataManager.gym(withId: session.gymId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(gym?.name ?? "Unknown Gym")
|
||||||
|
.font(.body)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
if let duration = session.duration {
|
||||||
|
Text("Duration: \(duration) minutes")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let notes = session.notes, !notes.isEmpty {
|
||||||
|
Text(notes)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.body)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(Color(.tertiaryLabel))
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
onTap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Calendar {
|
||||||
|
func generateDates(
|
||||||
|
inside interval: DateInterval,
|
||||||
|
matching components: DateComponents
|
||||||
|
) -> [Date] {
|
||||||
|
var dates: [Date] = []
|
||||||
|
dates.append(interval.start)
|
||||||
|
|
||||||
|
enumerateDates(
|
||||||
|
startingAfter: interval.start,
|
||||||
|
matching: components,
|
||||||
|
matchingPolicy: .nextTime
|
||||||
|
) { date, _, stop in
|
||||||
|
if let date = date {
|
||||||
|
if date < interval.end {
|
||||||
|
dates.append(date)
|
||||||
|
} else {
|
||||||
|
stop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
enum SessionViewMode: String {
|
||||||
|
case list
|
||||||
|
case calendar
|
||||||
|
}
|
||||||
|
|
||||||
struct SessionsView: View {
|
struct SessionsView: View {
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@State private var showingAddSession = false
|
@State private var showingAddSession = false
|
||||||
|
@AppStorage("sessionViewMode") private var viewMode: SessionViewMode = .list
|
||||||
|
@State private var selectedMonth = Date()
|
||||||
|
@State private var selectedDate: Date? = nil
|
||||||
|
@State private var selectedSessionId: UUID? = nil
|
||||||
|
|
||||||
|
private var completedSessions: [ClimbSession] {
|
||||||
|
dataManager.sessions
|
||||||
|
.filter { $0.status == .completed }
|
||||||
|
.sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -11,7 +26,18 @@ struct SessionsView: View {
|
|||||||
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
|
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
|
||||||
EmptySessionsView()
|
EmptySessionsView()
|
||||||
} else {
|
} else {
|
||||||
|
if viewMode == .list {
|
||||||
SessionsList()
|
SessionsList()
|
||||||
|
} else {
|
||||||
|
CalendarView(
|
||||||
|
sessions: completedSessions,
|
||||||
|
selectedMonth: $selectedMonth,
|
||||||
|
selectedDate: $selectedDate,
|
||||||
|
onNavigateToSession: { sessionId in
|
||||||
|
selectedSessionId = sessionId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Sessions")
|
.navigationTitle("Sessions")
|
||||||
@@ -36,6 +62,20 @@ struct SessionsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// View mode toggle
|
||||||
|
if !dataManager.sessions.isEmpty || dataManager.activeSession != nil {
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
viewMode = viewMode == .list ? .calendar : .list
|
||||||
|
selectedDate = nil
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: viewMode == .list ? "calendar" : "list.bullet")
|
||||||
|
.font(.body)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if dataManager.gyms.isEmpty {
|
if dataManager.gyms.isEmpty {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
} else if dataManager.activeSession == nil {
|
} else if dataManager.activeSession == nil {
|
||||||
@@ -52,6 +92,14 @@ struct SessionsView: View {
|
|||||||
.sheet(isPresented: $showingAddSession) {
|
.sheet(isPresented: $showingAddSession) {
|
||||||
AddEditSessionView()
|
AddEditSessionView()
|
||||||
}
|
}
|
||||||
|
.navigationDestination(isPresented: .constant(selectedSessionId != nil)) {
|
||||||
|
if let sessionId = selectedSessionId {
|
||||||
|
SessionDetailView(sessionId: sessionId)
|
||||||
|
.onDisappear {
|
||||||
|
selectedSessionId = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
sync/main.go
@@ -13,7 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const VERSION = "2.1.0"
|
const VERSION = "2.2.0"
|
||||||
|
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
if a < b {
|
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))
|
result := make([]DeletedItem, 0, len(deletedMap))
|
||||||
for _, item := range 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)
|
result = append(result, item)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -533,15 +541,16 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Merge client changes into server data
|
||||||
serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms)
|
serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms)
|
||||||
serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems)
|
serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems)
|
||||||
serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions)
|
serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions)
|
||||||
serverBackup.Attempts = s.mergeAttempts(serverBackup.Attempts, deltaRequest.Attempts)
|
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
|
// Save merged data
|
||||||
if err := s.saveData(serverBackup); err != nil {
|
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
|
// Parse client's last sync time
|
||||||
clientLastSync, err := time.Parse(time.RFC3339, deltaRequest.LastSyncTime)
|
clientLastSync, err := time.Parse(time.RFC3339, deltaRequest.LastSyncTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If parsing fails, send everything
|
|
||||||
clientLastSync = time.Time{}
|
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
|
// 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
|
// Filter gyms modified after client's last sync
|
||||||
for _, gym := range serverBackup.Gyms {
|
for _, gym := range serverBackup.Gyms {
|
||||||
|
if deletedItemMap["gym:"+gym.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
gymTime, err := time.Parse(time.RFC3339, gym.UpdatedAt)
|
gymTime, err := time.Parse(time.RFC3339, gym.UpdatedAt)
|
||||||
if err == nil && gymTime.After(clientLastSync) {
|
if err == nil && gymTime.After(clientLastSync) {
|
||||||
response.Gyms = append(response.Gyms, gym)
|
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
|
// Filter problems modified after client's last sync
|
||||||
for _, problem := range serverBackup.Problems {
|
for _, problem := range serverBackup.Problems {
|
||||||
|
if deletedItemMap["problem:"+problem.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
problemTime, err := time.Parse(time.RFC3339, problem.UpdatedAt)
|
problemTime, err := time.Parse(time.RFC3339, problem.UpdatedAt)
|
||||||
if err == nil && problemTime.After(clientLastSync) {
|
if err == nil && problemTime.After(clientLastSync) {
|
||||||
response.Problems = append(response.Problems, problem)
|
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
|
// Filter sessions modified after client's last sync
|
||||||
for _, session := range serverBackup.Sessions {
|
for _, session := range serverBackup.Sessions {
|
||||||
|
if deletedItemMap["session:"+session.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
sessionTime, err := time.Parse(time.RFC3339, session.UpdatedAt)
|
sessionTime, err := time.Parse(time.RFC3339, session.UpdatedAt)
|
||||||
if err == nil && sessionTime.After(clientLastSync) {
|
if err == nil && sessionTime.After(clientLastSync) {
|
||||||
response.Sessions = append(response.Sessions, session)
|
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
|
// Filter attempts created after client's last sync
|
||||||
for _, attempt := range serverBackup.Attempts {
|
for _, attempt := range serverBackup.Attempts {
|
||||||
|
if deletedItemMap["attempt:"+attempt.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
attemptTime, err := time.Parse(time.RFC3339, attempt.CreatedAt)
|
attemptTime, err := time.Parse(time.RFC3339, attempt.CreatedAt)
|
||||||
if err == nil && attemptTime.After(clientLastSync) {
|
if err == nil && attemptTime.After(clientLastSync) {
|
||||||
response.Attempts = append(response.Attempts, attempt)
|
response.Attempts = append(response.Attempts, attempt)
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||