Compare commits

...

27 Commits

Author SHA1 Message Date
a6508da413 iOS 2.4.2 - Fixed Swipe Action Colours 2025-12-07 01:43:19 -07:00
50b30442e8 oops 2025-12-03 15:45:05 -07:00
b365b967b2 Android 2.4.0 - Backend changes :) 2025-12-03 15:41:45 -07:00
cacd178817 iOS 2.4.1 - Minor Visual Tweaks 2025-12-03 00:10:08 -07:00
922412c2c2 Bumped build 2025-12-02 17:09:18 -07:00
acb1b1f532 2.4.0 - Updated Sync Architecture (Provider pattern) 2025-12-02 17:07:52 -07:00
c8694eacab iOS 2.4.0 - Colour accents and theming 2025-12-02 15:55:48 -07:00
57855b8332 Docs updates
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 5m14s
2025-12-01 17:07:30 -07:00
6342bfed5c 2.3.1 - Dependency Updates, Better Live Notifications, and Calendar Fixes 2025-12-01 11:46:31 -07:00
869ca0fc0d Merge pull request '2.3.0 - Unified logging and app intents' (#6) from logging into main
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 6m59s
Ascently - Sync Deploy / build-and-push (push) Successful in 2m0s
Reviewed-on: #6
2025-11-21 04:01:43 +00:00
33562e9d16 Merge branch 'main' into logging
All checks were successful
Ascently - Docs Deploy / build-and-push (pull_request) Successful in 7m42s
2025-11-21 04:01:16 +00:00
a212f3f3b5 2.3.0 - Unified logging and app intents
All checks were successful
Ascently - Docs Deploy / build-and-push (pull_request) Successful in 8m4s
2025-11-20 21:00:00 -07:00
a99196b9ca Deps for docs 2025-11-19 15:04:47 -07:00
93fb7a41fb iOS 2.2.1 2025-11-18 12:59:26 -07:00
6d67ae6d81 Logging overhaul 2025-11-18 12:58:45 -07:00
071e47f95e Update docs/src/content/docs/privacy.md
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m18s
2025-10-25 09:41:27 +00:00
c6c3e6084b Update docs/src/content/docs/privacy.md
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m19s
2025-10-25 09:33:33 +00:00
c2f95f2793 [Android] 2.2.1 - Better Widget 2025-10-21 10:22:31 -06:00
b7a3c98b2c [Android] 2.2.1 - Better Widget 2025-10-21 10:21:35 -06:00
fed9bab2ea Fixeds QR flashing
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m49s
2025-10-21 08:50:34 -06:00
862622b07b Fixed
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m58s
2025-10-20 00:03:35 -06:00
eba503eb5e Updated docs with QR Codes
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m26s
2025-10-19 23:55:30 -06:00
8c4a78ad50 2.2.0 - Final Builds
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m32s
2025-10-18 23:02:31 -06:00
3b16475dc6 [Mobile] 2.2.0 - Calendar View 2025-10-18 16:26:22 -06:00
105d39689d [Mobile] 2.2.0 - Calendar View 2025-10-18 16:26:17 -06:00
d4023133b7 App version 2.1.1 - Branding updates (Logo change)
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m59s
2025-10-17 09:46:19 -06:00
602b5f8938 Branding updates 2025-10-17 09:46:19 -06:00
127 changed files with 7457 additions and 4473 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.ascently"
minSdk = 31
targetSdk = 36
versionCode = 42
versionName = "2.1.0"
versionCode = 48
versionName = "2.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -38,7 +38,10 @@ android {
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
buildFeatures { compose = true }
buildFeatures {
compose = true
buildConfig = true
}
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }

View File

@@ -27,6 +27,7 @@
<!-- Permissions for notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />

View File

@@ -3,7 +3,7 @@ package com.atridad.ascently.data.health
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.atridad.ascently.utils.AppLogger
import androidx.activity.result.contract.ActivityResultContract
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController
@@ -60,7 +60,7 @@ class HealthConnectManager(private val context: Context) {
try {
HealthConnectClient.getOrCreate(context)
} catch (e: Exception) {
Log.e(TAG, "Failed to create Health Connect client", e)
AppLogger.e(TAG, e) { "Failed to create Health Connect client" }
_isCompatible.value = false
null
}
@@ -75,7 +75,7 @@ class HealthConnectManager(private val context: Context) {
val status = HealthConnectClient.getSdkStatus(context)
emit(status == HealthConnectClient.SDK_AVAILABLE)
} catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect availability", e)
AppLogger.e(TAG, e) { "Error checking Health Connect availability" }
_isCompatible.value = false
emit(false)
}
@@ -90,10 +90,10 @@ class HealthConnectManager(private val context: Context) {
try {
val alreadyHasPermissions = hasAllPermissions()
if (!alreadyHasPermissions) {
Log.d(TAG, "Health Connect enabled - permissions will be requested by UI")
AppLogger.d(TAG) { "Health Connect enabled - permissions will be requested by UI" }
}
} catch (e: Exception) {
Log.w(TAG, "Error checking permissions when enabling Health Connect", e)
AppLogger.w(TAG, e) { "Error checking permissions when enabling Health Connect" }
}
} else if (!enabled) {
setPermissionsGranted(false)
@@ -119,7 +119,7 @@ class HealthConnectManager(private val context: Context) {
setPermissionsGranted(hasAll)
hasAll
} catch (e: Exception) {
Log.e(TAG, "Error checking permissions", e)
AppLogger.e(TAG, e) { "Error checking permissions" }
setPermissionsGranted(false)
false
}
@@ -135,7 +135,7 @@ class HealthConnectManager(private val context: Context) {
val hasPerms = if (isAvailable) hasAllPermissions() else false
isAvailable && hasPerms
} catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect readiness", e)
AppLogger.e(TAG, e) { "Error checking Health Connect readiness" }
false
}
}
@@ -148,7 +148,7 @@ class HealthConnectManager(private val context: Context) {
return try {
REQUIRED_PERMISSIONS.map { it }.toSet()
} catch (e: Exception) {
Log.e(TAG, "Error getting required permissions", e)
AppLogger.e(TAG, e) { "Error getting required permissions" }
emptySet()
}
}
@@ -181,7 +181,7 @@ class HealthConnectManager(private val context: Context) {
)
}
Log.d(TAG, "Attempting to sync session '${session.id}' to Health Connect...")
AppLogger.d(TAG) { "Attempting to sync session '${session.id}' to Health Connect..." }
val records = mutableListOf<androidx.health.connect.client.records.Record>()
@@ -199,7 +199,7 @@ class HealthConnectManager(private val context: Context) {
)
records.add(exerciseSession)
} catch (e: Exception) {
Log.w(TAG, "Failed to create exercise session record", e)
AppLogger.w(TAG, e) { "Failed to create exercise session record" }
}
try {
@@ -220,23 +220,22 @@ class HealthConnectManager(private val context: Context) {
records.add(caloriesRecord)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to create calories record", e)
AppLogger.w(TAG, e) { "Failed to create calories record" }
}
try {
val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount)
heartRateRecord?.let { records.add(it) }
} catch (e: Exception) {
Log.w(TAG, "Failed to create heart rate record", e)
AppLogger.w(TAG, e) { "Failed to create heart rate record" }
}
if (records.isNotEmpty() && healthConnectClient != null) {
Log.d(TAG, "Writing ${records.size} records to Health Connect...")
AppLogger.d(TAG) { "Writing ${records.size} records to Health Connect..." }
healthConnectClient!!.insertRecords(records)
Log.i(
TAG,
AppLogger.i(TAG) {
"Successfully synced ${records.size} records for session '${session.id}' to Health Connect"
)
}
preferences
.edit()
@@ -249,13 +248,13 @@ class HealthConnectManager(private val context: Context) {
healthConnectClient == null -> "Health Connect client unavailable"
else -> "Unknown reason"
}
Log.w(TAG, "Sync failed for session '${session.id}': $reason")
AppLogger.w(TAG) { "Sync failed for session '${session.id}': $reason" }
return Result.failure(Exception("Sync failed: $reason"))
}
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Error syncing climbing session to Health Connect", e)
AppLogger.e(TAG, e) { "Error syncing climbing session to Health Connect" }
Result.failure(e)
}
}
@@ -266,7 +265,7 @@ class HealthConnectManager(private val context: Context) {
attemptCount: Int = 0
): Result<Unit> {
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
Log.d(TAG, "Auto-syncing completed session '${session.id}' to Health Connect...")
AppLogger.d(TAG) { "Auto-syncing completed session '${session.id}' to Health Connect..." }
syncCompletedSession(session, gymName, attemptCount)
} else {
val reason =
@@ -276,7 +275,7 @@ class HealthConnectManager(private val context: Context) {
!isReady() -> "Health Connect not ready"
else -> "unknown reason"
}
Log.d(TAG, "Auto-sync skipped for session '${session.id}': $reason")
AppLogger.d(TAG) { "Auto-sync skipped for session '${session.id}': $reason" }
Result.success(Unit)
}
}
@@ -328,7 +327,7 @@ class HealthConnectManager(private val context: Context) {
samples = samples
)
} catch (e: Exception) {
Log.e(TAG, "Error creating heart rate record", e)
AppLogger.e(TAG, e) { "Error creating heart rate record" }
null
}
}

View File

@@ -13,6 +13,7 @@ import com.atridad.ascently.data.format.DeletedItem
import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ZipExportImportUtils
import java.io.File
import kotlinx.coroutines.flow.Flow
@@ -43,11 +44,13 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun updateGym(gym: Gym) {
gymDao.updateGym(gym)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun deleteGym(gym: Gym) {
gymDao.deleteGym(gym)
trackDeletion(gym.id, "gym")
@@ -63,10 +66,12 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
problemDao.insertProblem(problem)
dataStateManager.updateDataState()
}
suspend fun updateProblem(problem: Problem) {
problemDao.updateProblem(problem)
dataStateManager.updateDataState()
}
suspend fun deleteProblem(problem: Problem) {
problemDao.deleteProblem(problem)
trackDeletion(problem.id, "problem")
@@ -78,6 +83,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
suspend fun insertSession(session: ClimbSession) {
@@ -88,6 +94,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync()
}
}
suspend fun updateSession(session: ClimbSession) {
sessionDao.updateSession(session)
dataStateManager.updateDataState()
@@ -96,12 +103,14 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync()
}
}
suspend fun deleteSession(session: ClimbSession) {
sessionDao.deleteSession(session)
trackDeletion(session.id, "session")
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun getLastUsedGym(): Gym? {
val recentSessions = sessionDao.getRecentSessions(1).first()
return if (recentSessions.isNotEmpty()) {
@@ -115,16 +124,20 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) {
attemptDao.insertAttempt(attempt)
dataStateManager.updateDataState()
}
suspend fun updateAttempt(attempt: Attempt) {
attemptDao.updateAttempt(attempt)
dataStateManager.updateDataState()
}
suspend fun deleteAttempt(attempt: Attempt) {
attemptDao.deleteAttempt(attempt)
trackDeletion(attempt.id, "attempt")
@@ -386,10 +399,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0
imagesDir.deleteRecursively()
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files")
AppLogger.i("ClimbRepository") { "Cleared $deletedCount image files" }
}
} catch (e: Exception) {
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}")
AppLogger.w("ClimbRepository", e) { "Failed to clear some images: ${e.message}" }
}
}
}

View File

@@ -2,8 +2,8 @@ package com.atridad.ascently.data.state
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.DateFormatUtils
/**
@@ -26,7 +26,7 @@ class DataStateManager(context: Context) {
if (!isInitialized()) {
updateDataState()
markAsInitialized()
Log.d(TAG, "DataStateManager initialized with timestamp: ${getLastModified()}")
AppLogger.d(TAG) { "DataStateManager initialized with timestamp: ${getLastModified()}" }
}
}
@@ -37,7 +37,7 @@ class DataStateManager(context: Context) {
fun updateDataState() {
val now = DateFormatUtils.nowISO8601()
prefs.edit { putString(KEY_LAST_MODIFIED, now) }
Log.d(TAG, "Data state updated to: $now")
AppLogger.d(TAG) { "Data state updated to: $now" }
}
/**

View File

@@ -0,0 +1,741 @@
package com.atridad.ascently.data.sync
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.annotation.RequiresPermission
import androidx.core.content.edit
import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.ascently.data.format.BackupClimbSession
import com.atridad.ascently.data.format.BackupGym
import com.atridad.ascently.data.format.BackupProblem
import com.atridad.ascently.data.format.ClimbDataBackup
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class AscentlySyncProvider(
private val context: Context,
private val repository: ClimbRepository
) : SyncProvider {
override val type: SyncProviderType = SyncProviderType.SERVER
private val dataStateManager = DataStateManager(context)
companion object {
private const val TAG = "AscentlySyncProvider"
}
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
private val httpClient =
OkHttpClient.Builder()
.connectTimeout(45, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS)
.build()
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
private val _isConnected = MutableStateFlow(false)
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _isConfigured = MutableStateFlow(false)
override val isConfigured: StateFlow<Boolean> = _isConfigured.asStateFlow()
private var isOfflineMode = false
private object Keys {
const val SERVER_URL = "server_url"
const val AUTH_TOKEN = "auth_token"
const val IS_CONNECTED = "is_connected"
const val LAST_SYNC_TIME = "last_sync_time"
const val OFFLINE_MODE = "offline_mode"
}
init {
loadInitialState()
updateConfiguredState()
}
private fun loadInitialState() {
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
isOfflineMode = sharedPreferences.getBoolean(Keys.OFFLINE_MODE, false)
}
private fun updateConfiguredState() {
_isConfigured.value = serverUrl.isNotBlank() && authToken.isNotBlank()
}
var serverUrl: String
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
}
var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
}
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
private fun isNetworkAvailable(): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
return when {
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
else -> false
}
}
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
override suspend fun sync() {
if (isOfflineMode) {
AppLogger.d(TAG) { "Sync skipped: Offline mode is enabled." }
return
}
if (!isNetworkAvailable()) {
AppLogger.d(TAG) { "Sync skipped: No internet connection." }
throw SyncException.NetworkError("No internet connection.")
}
if (!_isConfigured.value) {
throw SyncException.NotConfigured
}
if (!_isConnected.value) {
throw SyncException.NotConnected
}
val localBackup = createBackupFromRepository()
val serverBackup = downloadData()
val hasLocalData =
localBackup.gyms.isNotEmpty() ||
localBackup.problems.isNotEmpty() ||
localBackup.sessions.isNotEmpty() ||
localBackup.attempts.isNotEmpty()
val hasServerData =
serverBackup.gyms.isNotEmpty() ||
serverBackup.problems.isNotEmpty() ||
serverBackup.sessions.isNotEmpty() ||
serverBackup.attempts.isNotEmpty()
val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
if (hasLocalData && hasServerData && lastSyncTimeStr != null) {
AppLogger.d(TAG) { "Using delta sync for incremental updates" }
performDeltaSync(lastSyncTimeStr)
} else {
when {
!hasLocalData && hasServerData -> {
AppLogger.d(TAG) { "No local data found, performing full restore from server" }
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
AppLogger.d(TAG) { "Full restore completed" }
}
hasLocalData && !hasServerData -> {
AppLogger.d(TAG) { "No server data found, uploading local data to server" }
uploadData(localBackup)
syncImagesForBackup(localBackup)
AppLogger.d(TAG) { "Initial upload completed" }
}
hasLocalData && hasServerData -> {
AppLogger.d(TAG) { "Both local and server data exist, merging (server wins)" }
mergeDataSafely(serverBackup)
AppLogger.d(TAG) { "Merge completed" }
}
else -> {
AppLogger.d(TAG) { "No data to sync" }
}
}
}
val now = DateFormatUtils.nowISO8601()
sharedPreferences.edit { putString(Keys.LAST_SYNC_TIME, now) }
}
override fun disconnect() {
serverUrl = ""
authToken = ""
_isConnected.value = false
sharedPreferences.edit {
remove(Keys.LAST_SYNC_TIME)
putBoolean(Keys.IS_CONNECTED, false)
}
updateConfiguredState()
}
private suspend fun performDeltaSync(lastSyncTimeStr: String) {
AppLogger.d(TAG) { "Starting delta sync with lastSyncTime=$lastSyncTimeStr" }
val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0)
val allGyms = repository.getAllGyms().first()
val modifiedGyms =
allGyms
.filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true }
.map { BackupGym.fromGym(it) }
val allProblems = repository.getAllProblems().first()
val modifiedProblems =
allProblems
.filter { problem ->
parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true
}
.map { problem ->
val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(problem.id, index)
}
if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
}
val allSessions = repository.getAllSessions().first()
val modifiedSessions =
allSessions
.filter { session ->
parseISO8601(session.updatedAt)?.after(lastSyncDate) == true
}
.map { BackupClimbSession.fromClimbSession(it) }
val allAttempts = repository.getAllAttempts().first()
val modifiedAttempts =
allAttempts
.filter { attempt ->
parseISO8601(attempt.createdAt)?.after(lastSyncDate) == true
}
.map { BackupAttempt.fromAttempt(it) }
val allDeletions = repository.getDeletedItems()
val modifiedDeletions =
allDeletions.filter { item ->
parseISO8601(item.deletedAt)?.after(lastSyncDate) == true
}
AppLogger.d(TAG) {
"Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}"
}
val deltaRequest =
DeltaSyncRequest(
lastSyncTime = lastSyncTimeStr,
gyms = modifiedGyms,
problems = modifiedProblems,
sessions = modifiedSessions,
attempts = modifiedAttempts,
deletedItems = modifiedDeletions
)
val requestBody =
json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest)
.toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync/delta")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
val deltaResponse =
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(DeltaSyncResponse.serializer(), body)
} else {
throw SyncException.InvalidResponse("Empty response body")
}
} else {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
AppLogger.d(TAG) {
"Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}"
}
applyDeltaResponse(deltaResponse)
syncModifiedImages(modifiedProblems)
}
private fun parseISO8601(dateString: String): Date? {
return try {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
format.parse(dateString)
} catch (_: Exception) {
null
}
}
private suspend fun applyDeltaResponse(response: DeltaSyncResponse) {
// SyncService handles the "isSyncing" state to prevent recursive sync triggers
// when the repository is modified during a sync operation.
try {
// Merge and apply deletions first to prevent resurrection
val allDeletions = repository.getDeletedItems() + response.deletedItems
val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" }
AppLogger.d(TAG) { "Applying ${uniqueDeletions.size} deletion records before merging data" }
applyDeletions(uniqueDeletions)
// Build deleted item lookup set
val deletedItemSet = uniqueDeletions.map { "${it.type}:${it.id}" }.toSet()
// Download images for new/modified problems from server
val imagePathMapping = mutableMapOf<String, String>()
for (problem in response.problems) {
if (deletedItemSet.contains("problem:${problem.id}")) {
continue
}
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
val localImagePath = downloadImage(serverFilename)
if (localImagePath != null) {
imagePathMapping[imagePath] = localImagePath
}
} catch (e: Exception) {
AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
}
}
}
// Merge gyms
val existingGyms = repository.getAllGyms().first()
for (backupGym in response.gyms) {
if (deletedItemSet.contains("gym:${backupGym.id}")) {
continue
}
val existing = existingGyms.find { it.id == backupGym.id }
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
val gym = backupGym.toGym()
if (existing != null) {
repository.updateGym(gym)
} else {
repository.insertGym(gym)
}
}
}
// Merge problems
val existingProblems = repository.getAllProblems().first()
for (backupProblem in response.problems) {
if (deletedItemSet.contains("problem:${backupProblem.id}")) {
continue
}
val updatedImagePaths =
backupProblem.imagePaths?.map { oldPath ->
imagePathMapping[oldPath] ?: oldPath
}
val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths)
val problem = problemToMerge.toProblem()
val existing = existingProblems.find { it.id == backupProblem.id }
if (existing == null || backupProblem.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateProblem(problem)
} else {
repository.insertProblem(problem)
}
}
}
// Merge sessions
val existingSessions = repository.getAllSessions().first()
for (backupSession in response.sessions) {
if (deletedItemSet.contains("session:${backupSession.id}")) {
continue
}
val session = backupSession.toClimbSession()
val existing = existingSessions.find { it.id == backupSession.id }
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateSession(session)
} else {
repository.insertSession(session)
}
}
}
// Merge attempts
val existingAttempts = repository.getAllAttempts().first()
for (backupAttempt in response.attempts) {
if (deletedItemSet.contains("attempt:${backupAttempt.id}")) {
continue
}
val attempt = backupAttempt.toAttempt()
val existing = existingAttempts.find { it.id == backupAttempt.id }
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
if (existing != null) {
repository.updateAttempt(attempt)
} else {
repository.insertAttempt(attempt)
}
}
}
// Apply deletions again for safety
applyDeletions(uniqueDeletions)
// Update deletion records
repository.clearDeletedItems()
uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) }
} catch (e: Exception) {
AppLogger.e(TAG, e) { "Error applying delta response" }
throw e
}
}
private suspend fun applyDeletions(
deletions: List<com.atridad.ascently.data.format.DeletedItem>
) {
val existingGyms = repository.getAllGyms().first()
val existingProblems = repository.getAllProblems().first()
val existingSessions = repository.getAllSessions().first()
val existingAttempts = repository.getAllAttempts().first()
for (item in deletions) {
when (item.type) {
"gym" -> {
existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) }
}
"problem" -> {
existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) }
}
"session" -> {
existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) }
}
"attempt" -> {
existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) }
}
}
}
}
private suspend fun syncModifiedImages(modifiedProblems: List<BackupProblem>) {
if (modifiedProblems.isEmpty()) return
AppLogger.d(TAG) { "Syncing images for ${modifiedProblems.size} modified problems" }
for (backupProblem in modifiedProblems) {
backupProblem.imagePaths?.forEach { imagePath ->
val filename = imagePath.substringAfterLast('/')
uploadImage(imagePath, filename)
}
}
}
private suspend fun downloadData(): ClimbDataBackup {
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.get()
.build()
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(body)
} else {
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
gyms = emptyList(),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
}
} else {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
}
private suspend fun uploadData(backup: ClimbDataBackup) {
val requestBody =
json.encodeToString(backup).toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.put(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
}
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> {
val imagePathMapping = mutableMapOf<String, String>()
val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 }
AppLogger.d(TAG) { "Starting image download from server for $totalImages images" }
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
val localImagePath = downloadImage(serverFilename)
if (localImagePath != null) {
imagePathMapping[imagePath] = localImagePath
}
} catch (_: SyncException.ImageNotFound) {
AppLogger.w(TAG) { "Image not found on server: $imagePath" }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
}
}
}
}
return imagePathMapping
}
private suspend fun downloadImage(serverFilename: String): String? {
val request =
Request.Builder()
.url("$serverUrl/images/download?filename=$serverFilename")
.header("Authorization", "Bearer $authToken")
.build()
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body?.bytes()?.let {
ImageUtils.saveImageFromBytesWithFilename(context, it, serverFilename)
}
} else {
if (response.code == 404) throw SyncException.ImageNotFound
null
}
}
} catch (e: IOException) {
AppLogger.e(TAG, e) { "Network error downloading image $serverFilename" }
null
}
}
}
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
AppLogger.d(TAG) { "Starting image sync for backup with ${backup.problems.size} problems" }
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
problem.imagePaths?.forEach { localPath ->
val filename = localPath.substringAfterLast('/')
uploadImage(localPath, filename)
}
}
}
}
private suspend fun uploadImage(localPath: String, filename: String) {
val file = ImageUtils.getImageFile(context, localPath)
if (!file.exists()) {
AppLogger.w(TAG) { "Local image file not found, cannot upload: $localPath" }
return
}
val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/images/upload?filename=$filename")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
AppLogger.d(TAG) { "Successfully uploaded image: $filename" }
} else {
AppLogger.w(TAG) {
"Failed to upload image $filename. Server responded with ${response.code}"
}
}
}
} catch (e: IOException) {
AppLogger.e(TAG, e) { "Network error uploading image $filename" }
}
}
}
private suspend fun createBackupFromRepository(): ClimbDataBackup {
return withContext(Dispatchers.Default) {
ClimbDataBackup(
exportedAt = dataStateManager.getLastModified(),
gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) },
problems =
repository.getAllProblems().first().map { problem ->
val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(
problem.id,
index
)
}
if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
},
sessions =
repository.getAllSessions().first().map {
BackupClimbSession.fromClimbSession(it)
},
attempts =
repository.getAllAttempts().first().map {
BackupAttempt.fromAttempt(it)
},
deletedItems = repository.getDeletedItems()
)
}
}
private suspend fun importBackupToRepository(
backup: ClimbDataBackup,
imagePathMapping: Map<String, String>
) {
val gyms = backup.gyms.map { it.toGym() }
val problems =
backup.problems.map { backupProblem ->
val imagePaths = backupProblem.imagePaths
val updatedImagePaths =
imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath }
backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
}
val sessions = backup.sessions.map { it.toClimbSession() }
val attempts = backup.attempts.map { it.toAttempt() }
repository.resetAllData()
gyms.forEach { repository.insertGymWithoutSync(it) }
problems.forEach { repository.insertProblemWithoutSync(it) }
sessions.forEach { repository.insertSessionWithoutSync(it) }
attempts.forEach { repository.insertAttemptWithoutSync(it) }
repository.clearDeletedItems()
}
private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) {
AppLogger.d(TAG) { "Server data will overwrite local data. Performing full restore." }
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
}
private fun handleHttpError(code: Int): Nothing {
when (code) {
401 -> throw SyncException.Unauthorized
in 500..599 -> throw SyncException.ServerError(code)
else -> throw SyncException.InvalidResponse("HTTP error code: $code")
}
}
override suspend fun testConnection() {
if (!_isConfigured.value) {
_isConnected.value = false
throw SyncException.NotConfigured
}
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.head()
.build()
try {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute().use { response ->
_isConnected.value = response.isSuccessful || response.code == 405
}
}
if (!_isConnected.value) {
throw SyncException.NotConnected
}
} catch (e: Exception) {
_isConnected.value = false
throw SyncException.NetworkError(e.message ?: "Connection error")
} finally {
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) }
}
}
}

View File

@@ -0,0 +1,21 @@
package com.atridad.ascently.data.sync
import java.io.IOException
import java.io.Serializable
sealed class SyncException(message: String) : IOException(message), Serializable {
object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.")
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
object ImageNotFound : SyncException("Image not found on server")
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details")
}

View File

@@ -0,0 +1,18 @@
package com.atridad.ascently.data.sync
import kotlinx.coroutines.flow.StateFlow
interface SyncProvider {
val type: SyncProviderType
val isConfigured: StateFlow<Boolean>
val isConnected: StateFlow<Boolean>
suspend fun sync()
suspend fun testConnection()
fun disconnect()
}
enum class SyncProviderType {
NONE,
SERVER
}

View File

@@ -2,27 +2,9 @@ package com.atridad.ascently.data.sync
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.core.content.edit
import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.ascently.data.format.BackupClimbSession
import com.atridad.ascently.data.format.BackupGym
import com.atridad.ascently.data.format.BackupProblem
import com.atridad.ascently.data.format.ClimbDataBackup
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils
import java.io.IOException
import java.io.Serializable
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
import com.atridad.ascently.utils.AppLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -31,43 +13,21 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class SyncService(private val context: Context, private val repository: ClimbRepository) {
private val dataStateManager = DataStateManager(context)
private val syncMutex = Mutex()
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val syncMutex = Mutex()
companion object {
private const val TAG = "SyncService"
}
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
private val httpClient =
OkHttpClient.Builder()
.connectTimeout(45, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS)
.build()
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
// Currently we only support one provider, but this allows for future expansion
private val provider: SyncProvider = AscentlySyncProvider(context, repository)
// State
private val _isSyncing = MutableStateFlow(false)
@@ -79,11 +39,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private val _syncError = MutableStateFlow<String?>(null)
val syncError: StateFlow<String?> = _syncError.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _isConfigured = MutableStateFlow(false)
val isConfiguredFlow: StateFlow<Boolean> = _isConfigured.asStateFlow()
// Delegate to provider
val isConnected: StateFlow<Boolean> = provider.isConnected
val isConfiguredFlow: StateFlow<Boolean> = provider.isConfigured
private val _isTesting = MutableStateFlow(false)
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
@@ -91,56 +49,40 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private val _isAutoSyncEnabled = MutableStateFlow(true)
val isAutoSyncEnabled: StateFlow<Boolean> = _isAutoSyncEnabled.asStateFlow()
private var isOfflineMode = false
// Debounced sync properties
private var syncJob: Job? = null
private var pendingChanges = false
private val syncDebounceDelay = 2000L // 2 seconds
// Configuration keys
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
private object Keys {
const val SERVER_URL = "server_url"
const val AUTH_TOKEN = "auth_token"
const val IS_CONNECTED = "is_connected"
const val LAST_SYNC_TIME = "last_sync_time"
const val AUTO_SYNC_ENABLED = "auto_sync_enabled"
const val OFFLINE_MODE = "offline_mode"
}
init {
loadInitialState()
updateConfiguredState()
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
}
private fun loadInitialState() {
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
_isAutoSyncEnabled.value = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
isOfflineMode = sharedPreferences.getBoolean(Keys.OFFLINE_MODE, false)
}
private fun updateConfiguredState() {
_isConfigured.value = serverUrl.isNotBlank() && authToken.isNotBlank()
}
// Proxy properties for Ascently provider configuration
var serverUrl: String
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
get() = (provider as? AscentlySyncProvider)?.serverUrl ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
(provider as? AscentlySyncProvider)?.serverUrl = value
}
var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
get() = (provider as? AscentlySyncProvider)?.authToken ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
(provider as? AscentlySyncProvider)?.authToken = value
}
fun setAutoSyncEnabled(enabled: Boolean) {
@@ -148,90 +90,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
sharedPreferences.edit { putBoolean(Keys.AUTO_SYNC_ENABLED, enabled) }
}
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
private fun isNetworkAvailable(): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
return when {
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
else -> false
}
}
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
suspend fun syncWithServer() {
if (isOfflineMode) {
Log.d(TAG, "Sync skipped: Offline mode is enabled.")
return
}
if (!isNetworkAvailable()) {
_syncError.value = "No internet connection."
Log.d(TAG, "Sync skipped: No internet connection.")
return
}
if (!_isConfigured.value) {
if (!isConfiguredFlow.value) {
throw SyncException.NotConfigured
}
if (!_isConnected.value) {
throw SyncException.NotConnected
}
syncMutex.withLock {
_isSyncing.value = true
_syncError.value = null
try {
val localBackup = createBackupFromRepository()
val serverBackup = downloadData()
provider.sync()
val hasLocalData =
localBackup.gyms.isNotEmpty() ||
localBackup.problems.isNotEmpty() ||
localBackup.sessions.isNotEmpty() ||
localBackup.attempts.isNotEmpty()
// Update last sync time from shared prefs (provider updates it)
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
val hasServerData =
serverBackup.gyms.isNotEmpty() ||
serverBackup.problems.isNotEmpty() ||
serverBackup.sessions.isNotEmpty() ||
serverBackup.attempts.isNotEmpty()
// If both client and server have been synced before, use delta sync
val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
if (hasLocalData && hasServerData && lastSyncTimeStr != null) {
Log.d(TAG, "Using delta sync for incremental updates")
performDeltaSync(lastSyncTimeStr)
} else {
when {
!hasLocalData && hasServerData -> {
Log.d(TAG, "No local data found, performing full restore from server")
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Full restore completed")
}
hasLocalData && !hasServerData -> {
Log.d(TAG, "No server data found, uploading local data to server")
uploadData(localBackup)
syncImagesForBackup(localBackup)
Log.d(TAG, "Initial upload completed")
}
hasLocalData && hasServerData -> {
Log.d(TAG, "Both local and server data exist, merging (server wins)")
mergeDataSafely(serverBackup)
Log.d(TAG, "Merge completed")
}
else -> {
Log.d(TAG, "No data to sync")
}
}
}
val now = DateFormatUtils.nowISO8601()
_lastSyncTime.value = now
sharedPreferences.edit { putString(Keys.LAST_SYNC_TIME, now) }
} catch (e: Exception) {
_syncError.value = e.message
throw e
@@ -241,528 +114,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
private suspend fun performDeltaSync(lastSyncTimeStr: String) {
Log.d(TAG, "Starting delta sync with lastSyncTime=$lastSyncTimeStr")
// Parse last sync time to filter modified items
val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0)
// Collect items modified since last sync
val allGyms = repository.getAllGyms().first()
val modifiedGyms =
allGyms
.filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true }
.map { BackupGym.fromGym(it) }
val allProblems = repository.getAllProblems().first()
val modifiedProblems =
allProblems
.filter { problem ->
parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true
}
.map { problem ->
val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(problem.id, index)
}
if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
}
val allSessions = repository.getAllSessions().first()
val modifiedSessions =
allSessions
.filter { session ->
parseISO8601(session.updatedAt)?.after(lastSyncDate) == true
}
.map { BackupClimbSession.fromClimbSession(it) }
val allAttempts = repository.getAllAttempts().first()
val modifiedAttempts =
allAttempts
.filter { attempt ->
parseISO8601(attempt.createdAt)?.after(lastSyncDate) == true
}
.map { BackupAttempt.fromAttempt(it) }
val allDeletions = repository.getDeletedItems()
val modifiedDeletions =
allDeletions.filter { item ->
parseISO8601(item.deletedAt)?.after(lastSyncDate) == true
}
Log.d(
TAG,
"Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}"
)
// Create delta request
val deltaRequest =
DeltaSyncRequest(
lastSyncTime = lastSyncTimeStr,
gyms = modifiedGyms,
problems = modifiedProblems,
sessions = modifiedSessions,
attempts = modifiedAttempts,
deletedItems = modifiedDeletions
)
val requestBody =
json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest)
.toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync/delta")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
val deltaResponse =
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(DeltaSyncResponse.serializer(), body)
} else {
throw SyncException.InvalidResponse("Empty response body")
}
} else {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
Log.d(
TAG,
"Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}"
)
// Apply server changes to local data
applyDeltaResponse(deltaResponse)
// Sync only modified problem images
syncModifiedImages(modifiedProblems)
}
private fun parseISO8601(dateString: String): Date? {
return try {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
format.parse(dateString)
} catch (e: Exception) {
null
}
}
private suspend fun applyDeltaResponse(response: DeltaSyncResponse) {
// Temporarily disable auto-sync to prevent recursive sync triggers
repository.setAutoSyncCallback(null)
try {
// Download images for new/modified problems from server
val imagePathMapping = mutableMapOf<String, String>()
for (problem in response.problems) {
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
val localImagePath = downloadImage(serverFilename)
if (localImagePath != null) {
imagePathMapping[imagePath] = localImagePath
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
}
}
}
// Merge gyms - check if exists and compare timestamps
val existingGyms = repository.getAllGyms().first()
for (backupGym in response.gyms) {
val existing = existingGyms.find { it.id == backupGym.id }
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
val gym = backupGym.toGym()
if (existing != null) {
repository.updateGym(gym)
} else {
repository.insertGym(gym)
}
}
}
// Merge problems
val existingProblems = repository.getAllProblems().first()
for (backupProblem in response.problems) {
val updatedImagePaths =
backupProblem.imagePaths?.map { oldPath ->
imagePathMapping[oldPath] ?: oldPath
}
val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths)
val problem = problemToMerge.toProblem()
val existing = existingProblems.find { it.id == backupProblem.id }
if (existing == null || backupProblem.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateProblem(problem)
} else {
repository.insertProblem(problem)
}
}
}
// Merge sessions
val existingSessions = repository.getAllSessions().first()
for (backupSession in response.sessions) {
val session = backupSession.toClimbSession()
val existing = existingSessions.find { it.id == backupSession.id }
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateSession(session)
} else {
repository.insertSession(session)
}
}
}
// Merge attempts
val existingAttempts = repository.getAllAttempts().first()
for (backupAttempt in response.attempts) {
val attempt = backupAttempt.toAttempt()
val existing = existingAttempts.find { it.id == backupAttempt.id }
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
if (existing != null) {
repository.updateAttempt(attempt)
} else {
repository.insertAttempt(attempt)
}
}
}
// Apply deletions
applyDeletions(response.deletedItems)
// Update deletion records
val allDeletions = repository.getDeletedItems() + response.deletedItems
repository.clearDeletedItems()
allDeletions.distinctBy { "${it.type}:${it.id}" }.forEach {
repository.trackDeletion(it.id, it.type)
}
} finally {
// Re-enable auto-sync
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
}
}
private suspend fun applyDeletions(
deletions: List<com.atridad.ascently.data.format.DeletedItem>
) {
val existingGyms = repository.getAllGyms().first()
val existingProblems = repository.getAllProblems().first()
val existingSessions = repository.getAllSessions().first()
val existingAttempts = repository.getAllAttempts().first()
for (item in deletions) {
when (item.type) {
"gym" -> {
existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) }
}
"problem" -> {
existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) }
}
"session" -> {
existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) }
}
"attempt" -> {
existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) }
}
}
}
}
private suspend fun syncModifiedImages(modifiedProblems: List<BackupProblem>) {
if (modifiedProblems.isEmpty()) return
Log.d(TAG, "Syncing images for ${modifiedProblems.size} modified problems")
for (backupProblem in modifiedProblems) {
backupProblem.imagePaths?.forEach { imagePath ->
val filename = imagePath.substringAfterLast('/')
uploadImage(imagePath, filename)
}
}
}
private suspend fun downloadData(): ClimbDataBackup {
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.get()
.build()
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(body)
} else {
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
gyms = emptyList(),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
}
} else {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
}
private suspend fun uploadData(backup: ClimbDataBackup) {
val requestBody =
json.encodeToString(backup).toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
}
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> {
val imagePathMapping = mutableMapOf<String, String>()
val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 }
Log.d(TAG, "Starting image download from server for $totalImages images")
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
val localImagePath = downloadImage(serverFilename)
if (localImagePath != null) {
imagePathMapping[imagePath] = localImagePath
}
} catch (_: SyncException.ImageNotFound) {
Log.w(TAG, "Image not found on server: $imagePath")
} catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
}
}
}
}
return imagePathMapping
}
private suspend fun downloadImage(serverFilename: String): String? {
val request =
Request.Builder()
.url("$serverUrl/images/download?filename=$serverFilename")
.header("Authorization", "Bearer $authToken")
.build()
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body?.bytes()?.let {
ImageUtils.saveImageFromBytesWithFilename(context, it, serverFilename)
}
} else {
if (response.code == 404) throw SyncException.ImageNotFound
null
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error downloading image $serverFilename", e)
null
}
}
}
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems")
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
problem.imagePaths?.forEach { localPath ->
val filename = localPath.substringAfterLast('/')
uploadImage(localPath, filename)
}
}
}
}
private suspend fun uploadImage(localPath: String, filename: String) {
val file = ImageUtils.getImageFile(context, localPath)
if (!file.exists()) {
Log.w(TAG, "Local image file not found, cannot upload: $localPath")
return
}
val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/images/upload?filename=$filename")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
Log.d(TAG, "Successfully uploaded image: $filename")
} else {
Log.w(
TAG,
"Failed to upload image $filename. Server responded with ${response.code}"
)
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error uploading image $filename", e)
}
}
}
private suspend fun createBackupFromRepository(): ClimbDataBackup {
return withContext(Dispatchers.Default) {
ClimbDataBackup(
exportedAt = dataStateManager.getLastModified(),
gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) },
problems =
repository.getAllProblems().first().map { problem ->
val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(
problem.id,
index
)
}
if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
},
sessions =
repository.getAllSessions().first().map {
BackupClimbSession.fromClimbSession(it)
},
attempts =
repository.getAllAttempts().first().map {
BackupAttempt.fromAttempt(it)
},
deletedItems = repository.getDeletedItems()
)
}
}
private suspend fun importBackupToRepository(
backup: ClimbDataBackup,
imagePathMapping: Map<String, String>
) {
val gyms = backup.gyms.map { it.toGym() }
val problems =
backup.problems.map { backupProblem ->
val imagePaths = backupProblem.imagePaths
val updatedImagePaths =
imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath }
backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
}
val sessions = backup.sessions.map { it.toClimbSession() }
val attempts = backup.attempts.map { it.toAttempt() }
repository.resetAllData()
gyms.forEach { repository.insertGymWithoutSync(it) }
problems.forEach { repository.insertProblemWithoutSync(it) }
sessions.forEach { repository.insertSessionWithoutSync(it) }
attempts.forEach { repository.insertAttemptWithoutSync(it) }
repository.clearDeletedItems()
}
private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) {
Log.d(TAG, "Server data will overwrite local data. Performing full restore.")
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
}
private fun handleHttpError(code: Int): Nothing {
when (code) {
401 -> throw SyncException.Unauthorized
in 500..599 -> throw SyncException.ServerError(code)
else -> throw SyncException.InvalidResponse("HTTP error code: $code")
}
}
suspend fun testConnection() {
if (!_isConfigured.value) {
_isConnected.value = false
_syncError.value = "Server URL or Auth Token is not set."
return
}
_isTesting.value = true
_syncError.value = null
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.head()
.build()
try {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute().use { response ->
_isConnected.value = response.isSuccessful || response.code == 405
}
}
if (!_isConnected.value) {
_syncError.value = "Connection failed. Check URL and token."
}
provider.testConnection()
} catch (e: Exception) {
_isConnected.value = false
_syncError.value = "Connection error: ${e.message}"
throw e
} finally {
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) }
_isTesting.value = false
}
}
fun triggerAutoSync() {
if (!_isConfigured.value || !_isConnected.value || !_isAutoSyncEnabled.value) {
if (!isConfiguredFlow.value || !isConnected.value || !_isAutoSyncEnabled.value) {
return
}
if (_isSyncing.value) {
@@ -776,7 +142,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
try {
syncWithServer()
} catch (e: Exception) {
Log.e(TAG, "Auto-sync failed", e)
AppLogger.e(TAG, e) { "Auto-sync failed" }
}
if (pendingChanges) {
pendingChanges = false
@@ -787,29 +153,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
fun clearConfiguration() {
syncJob?.cancel()
serverUrl = ""
authToken = ""
provider.disconnect()
setAutoSyncEnabled(true)
_lastSyncTime.value = null
_isConnected.value = false
_syncError.value = null
sharedPreferences.edit { clear() }
updateConfiguredState()
}
}
sealed class SyncException(message: String) : IOException(message), Serializable {
object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.")
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
object ImageNotFound : SyncException("Image not found on server")
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details")
}

View File

@@ -6,16 +6,21 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.atridad.ascently.MainActivity
import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.runBlocking
class SessionTrackingService : Service() {
@@ -28,6 +33,7 @@ class SessionTrackingService : Service() {
private lateinit var notificationManager: NotificationManager
companion object {
private const val LOG_TAG = "SessionTrackingService"
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "session_tracking_channel"
const val ACTION_START_SESSION = "start_session"
@@ -67,16 +73,24 @@ class SessionTrackingService : Service() {
startSessionTracking(sessionId)
}
}
ACTION_STOP_SESSION -> {
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
serviceScope.launch {
try {
val targetSession = when {
val targetSession =
when {
sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession()
}
if (targetSession != null && targetSession.status == com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
val completed = with(com.atridad.ascently.data.model.ClimbSession) { targetSession.complete() }
if (targetSession != null &&
targetSession.status ==
com.atridad.ascently.data.model.SessionStatus.ACTIVE
) {
val completed =
with(com.atridad.ascently.data.model.ClimbSession) {
targetSession.complete()
}
repository.updateSession(completed)
}
} finally {
@@ -97,11 +111,14 @@ class SessionTrackingService : Service() {
try {
createAndShowNotification(sessionId)
// Update widget when session tracking starts
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e(LOG_TAG, e) { "Failed to initialize session tracking notification" }
}
notificationJob = serviceScope.launch {
notificationJob =
serviceScope.launch {
try {
if (!isNotificationActive()) {
delay(1000L)
@@ -113,11 +130,12 @@ class SessionTrackingService : Service() {
updateNotification(sessionId)
}
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e(LOG_TAG, e) { "Notification updater loop crashed" }
}
}
monitoringJob = serviceScope.launch {
monitoringJob =
serviceScope.launch {
try {
while (isActive) {
delay(10000L)
@@ -127,13 +145,17 @@ class SessionTrackingService : Service() {
}
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()
break
}
}
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e(LOG_TAG, e) { "Session monitoring loop crashed" }
}
}
}
@@ -143,6 +165,8 @@ class SessionTrackingService : Service() {
monitoringJob?.cancel()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
// Update widget when session tracking stops
ClimbStatsWidgetProvider.updateAllWidgets(this)
}
private fun isNotificationActive(): Boolean {
@@ -157,14 +181,16 @@ class SessionTrackingService : Service() {
private suspend fun updateNotification(sessionId: String) {
try {
createAndShowNotification(sessionId)
// Update widget when notification updates
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e(LOG_TAG, e) { "Failed to update notification; retrying in 10s" }
try {
delay(10000L)
createAndShowNotification(sessionId)
} catch (retryException: Exception) {
retryException.printStackTrace()
AppLogger.e(LOG_TAG, retryException) { "Retrying notification update failed" }
stopSessionTracking()
}
}
@@ -172,44 +198,22 @@ class SessionTrackingService : Service() {
private fun createAndShowNotification(sessionId: String) {
try {
val session = runBlocking {
repository.getSessionById(sessionId)
}
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
val session = runBlocking { repository.getSessionById(sessionId) }
if (session == null ||
session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE
) {
stopSessionTracking()
return
}
val gym = runBlocking {
repository.getGymById(session.gymId)
}
val gym = runBlocking { repository.getGymById(session.gymId) }
val attempts = runBlocking {
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
}
val duration = session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s"
}
} catch (_: Exception) {
"Active"
}
} ?: "Active"
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Climbing Session Active")
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts")
val notificationBuilder =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true)
.setAutoCancel(false)
@@ -227,20 +231,77 @@ class SessionTrackingService : Service() {
"End Session",
createStopPendingIntent(sessionId)
)
.build()
// Use Live Update
if (Build.VERSION.SDK_INT >= 36) {
val startTimeMillis =
session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val zoneId = ZoneId.systemDefault()
start.atZone(zoneId).toInstant().toEpochMilli()
} catch (_: Exception) {
System.currentTimeMillis()
}
}
?: System.currentTimeMillis()
notificationBuilder
.setContentTitle("Climbing Session Active")
.setContentText(
"${gym?.name ?: "Gym"}${attempts.size} attempts"
)
.setWhen(startTimeMillis)
.setUsesChronometer(true)
.setShowWhen(true)
val extras = Bundle()
extras.putBoolean("android.extra.REQUEST_PROMOTED_ONGOING", true)
notificationBuilder.setExtras(extras)
} else {
// Fallback for older versions
val duration =
session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s"
}
} catch (_: Exception) {
"Active"
}
}
?: "Active"
notificationBuilder
.setContentTitle("Climbing Session Active")
.setContentText(
"${gym?.name ?: "Gym"}$duration${attempts.size} attempts"
)
}
val notification = notificationBuilder.build()
startForeground(NOTIFICATION_ID, notification)
notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e(LOG_TAG, e) { "Failed to build session tracking notification" }
throw e
}
}
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
action = "OPEN_SESSION"
}
@@ -263,11 +324,13 @@ class SessionTrackingService : Service() {
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
val channel =
NotificationChannel(
CHANNEL_ID,
"Session Tracking",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
)
.apply {
description = "Shows active climbing session information"
setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC

View File

@@ -26,6 +26,7 @@ import com.atridad.ascently.ui.components.NotificationPermissionDialog
import com.atridad.ascently.ui.screens.*
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.AppShortcutManager
import com.atridad.ascently.utils.NotificationPermissionUtils
@@ -101,6 +102,7 @@ fun AscentlyApp(
launchSingleTop = true
}
}
AppShortcutManager.ACTION_END_SESSION -> {
navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true }
@@ -114,10 +116,7 @@ fun AscentlyApp(
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d(
"AscentlyApp",
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
)
AppLogger.d("AscentlyApp") { "Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}" }
if (activeSession == null) {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
@@ -125,14 +124,11 @@ fun AscentlyApp(
context
)
) {
android.util.Log.d("AscentlyApp", "Showing notification permission dialog")
AppLogger.d("AscentlyApp") { "Showing notification permission dialog" }
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) {
android.util.Log.d(
"AscentlyApp",
"Starting session with single gym: ${gyms.first().name}"
)
AppLogger.d("AscentlyApp") { "Starting session with single gym: ${gyms.first().name}" }
viewModel.startSession(context, gyms.first().id)
} else {
val targetGym =
@@ -140,25 +136,16 @@ fun AscentlyApp(
?: lastUsedGym
if (targetGym != null) {
android.util.Log.d(
"AscentlyApp",
"Starting session with target gym: ${targetGym.name}"
)
AppLogger.d("AscentlyApp") { "Starting session with target gym: ${targetGym.name}" }
viewModel.startSession(context, targetGym.id)
} else {
android.util.Log.d(
"AscentlyApp",
"No target gym found, navigating to selection"
)
AppLogger.d("AscentlyApp") { "No target gym found, navigating to selection" }
navController.navigate(Screen.AddEditSession())
}
}
}
} else {
android.util.Log.d(
"AscentlyApp",
"Active session already exists: ${activeSession?.id}"
)
AppLogger.d("AscentlyApp") { "Active session already exists: ${activeSession?.id}" }
}
onShortcutActionProcessed()

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.window.Dialog
import com.atridad.ascently.data.model.*
import com.atridad.ascently.ui.components.FullscreenImageViewer
import com.atridad.ascently.ui.components.ImageDisplaySection
import com.atridad.ascently.ui.components.ImagePicker
import com.atridad.ascently.ui.theme.CustomIcons
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.utils.DateFormatUtils
@@ -1489,6 +1490,7 @@ fun EnhancedAddAttemptDialog(
// New problem creation state
var newProblemName by remember { mutableStateOf("") }
var newProblemGrade by remember { mutableStateOf("") }
var newProblemImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) }
var selectedDifficultySystem by remember {
mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE)
@@ -1690,7 +1692,14 @@ fun EnhancedAddAttemptDialog(
color = MaterialTheme.colorScheme.onSurface
)
IconButton(onClick = { showCreateProblem = false }) {
IconButton(
onClick = {
showCreateProblem = false
newProblemName = ""
newProblemGrade = ""
newProblemImagePaths = emptyList()
}
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
@@ -1905,6 +1914,21 @@ fun EnhancedAddAttemptDialog(
}
}
}
// Photos Section
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Photos (Optional)",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
ImagePicker(
imageUris = newProblemImagePaths,
onImagesChanged = { newProblemImagePaths = it },
maxImages = 5
)
}
}
}
}
@@ -2069,7 +2093,9 @@ fun EnhancedAddAttemptDialog(
null
},
climbType = selectedClimbType,
difficulty = difficulty
difficulty = difficulty,
imagePaths =
newProblemImagePaths
)
onProblemCreated(newProblem)
@@ -2087,6 +2113,12 @@ fun EnhancedAddAttemptDialog(
notes = notes.ifBlank { null }
)
onAttemptAdded(attempt)
// Reset form
newProblemName = ""
newProblemGrade = ""
newProblemImagePaths = emptyList()
showCreateProblem = false
}
} else {
// Create attempt for selected problem

View File

@@ -1,16 +1,24 @@
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.res.painterResource
import androidx.compose.ui.text.font.FontWeight
@@ -23,6 +31,17 @@ import com.atridad.ascently.ui.components.ActiveSessionBanner
import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
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
import androidx.core.content.edit
enum class ViewMode {
LIST,
CALENDAR
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -33,7 +52,15 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
val activeSession by viewModel.activeSession.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?>(LocalDate.now()) }
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
@@ -55,12 +82,30 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
fontWeight = FontWeight.Bold,
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) }
}
) {
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)
}
Spacer(modifier = Modifier.height(16.dp))
// Active session banner
ActiveSessionBanner(
activeSession = activeSession,
gym = activeSessionGym,
@@ -83,20 +128,35 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
actionText = ""
)
} else {
when (viewMode) {
ViewMode.LIST -> {
LazyColumn {
items(completedSessions) { session ->
SessionCard(
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) }
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
ViewMode.CALENDAR -> {
CalendarView(
sessions = completedSessions,
gyms = gyms,
selectedMonth = selectedMonth,
onMonthChange = { selectedMonth = it },
selectedDate = selectedDate,
onDateSelected = { selectedDate = it },
onNavigateToSessionDetail = onNavigateToSessionDetail
)
}
}
}
}
// Show UI state messages and errors
uiState.message?.let { message ->
LaunchedEffect(message) {
kotlinx.coroutines.delay(5000)
@@ -245,6 +305,234 @@ fun EmptyStateMessage(
}
}
@Composable
fun CalendarView(
sessions: List<ClimbSession>,
gyms: List<com.atridad.ascently.data.model.Gym>,
selectedMonth: YearMonth,
onMonthChange: (YearMonth) -> Unit,
selectedDate: LocalDate?,
onDateSelected: (LocalDate?) -> Unit,
onNavigateToSessionDetail: (String) -> Unit
) {
val sessionsByDate =
remember(sessions) {
sessions.groupBy {
try {
java.time.Instant.parse(it.date)
.atZone(java.time.ZoneId.systemDefault())
.toLocalDate()
} catch (_: Exception) {
LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE)
}
}
}
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
}
val numRows = totalCells / 7
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
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))
}
items(numRows) { rowIndex ->
Row(modifier = Modifier.fillMaxWidth()) {
for (colIndex in 0 until 7) {
val index = rowIndex * 7 + colIndex
val dayNumber = index - firstDayOfWeek + 1
Box(modifier = Modifier.weight(1f)) {
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()
item {
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)
)
}
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))
}
item { Spacer(modifier = Modifier.height(16.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 {
return DateFormatUtils.formatDateForDisplay(dateString)
}

View File

@@ -216,9 +216,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
// Manual Sync Button
TextButton(
onClick = {
coroutineScope.launch {
viewModel.performManualSync()
}
},
enabled = isConnected && !isSyncing
) {
@@ -583,41 +581,6 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
Spacer(modifier = Modifier.height(12.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
painter =
painterResource(
id = R.drawable.ic_mountains
),
contentDescription = "Ascently Logo",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text("Ascently")
}
},
supportingContent = { Text("Track your climbing progress") },
leadingContent = {}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =

View File

@@ -8,6 +8,7 @@ import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.sync.SyncService
import com.atridad.ascently.service.SessionTrackingService
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ImageUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.io.File
@@ -192,7 +193,7 @@ class ClimbViewModel(
}
}
println("Deleted $deletedCount image files and cleared image references")
AppLogger.i("ClimbViewModel") { "Deleted $deletedCount image files and cleared image references" }
}
}
@@ -233,12 +234,12 @@ class ClimbViewModel(
// Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch {
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
AppLogger.d("ClimbViewModel") { "startSession called with gymId: $gymId" }
if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context)
) {
android.util.Log.d("ClimbViewModel", "Notification permission not granted")
AppLogger.d("ClimbViewModel") { "Notification permission not granted" }
_uiState.value =
_uiState.value.copy(
error =
@@ -249,10 +250,7 @@ class ClimbViewModel(
val existingActive = repository.getActiveSession()
if (existingActive != null) {
android.util.Log.d(
"ClimbViewModel",
"Active session already exists: ${existingActive.id}"
)
AppLogger.d("ClimbViewModel") { "Active session already exists: ${existingActive.id}" }
_uiState.value =
_uiState.value.copy(
error = "There's already an active session. Please end it first."
@@ -260,14 +258,11 @@ class ClimbViewModel(
return@launch
}
android.util.Log.d("ClimbViewModel", "Creating new session")
AppLogger.d("ClimbViewModel") { "Creating new session" }
val newSession = ClimbSession.create(gymId = gymId, notes = notes)
repository.insertSession(newSession)
android.util.Log.d(
"ClimbViewModel",
"Starting tracking service for session: ${newSession.id}"
)
AppLogger.d("ClimbViewModel") { "Starting tracking service for session: ${newSession.id}" }
// Start the tracking service
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
context.startForegroundService(serviceIntent)
@@ -416,13 +411,15 @@ class ClimbViewModel(
}
// Sync-related methods
suspend fun performManualSync() {
fun performManualSync() {
viewModelScope.launch {
try {
syncService.syncWithServer()
} catch (e: Exception) {
setError("Sync failed: ${e.message}")
}
}
}
suspend fun testSyncConnection() {
try {
@@ -477,15 +474,12 @@ class ClimbViewModel(
result.onFailure { error ->
if (healthConnectManager.isReadySync()) {
android.util.Log.w(
"ClimbViewModel",
"Health Connect sync failed: ${error.message}"
)
AppLogger.w("ClimbViewModel") { "Health Connect sync failed: ${error.message}" }
}
}
} catch (e: Exception) {
if (healthConnectManager.isReadySync()) {
android.util.Log.w("ClimbViewModel", "Health Connect sync error: ${e.message}")
AppLogger.w("ClimbViewModel") { "Health Connect sync error: ${e.message}" }
}
}
}

View File

@@ -0,0 +1,48 @@
package com.atridad.ascently.utils
import android.util.Log
import com.atridad.ascently.BuildConfig
object AppLogger {
private const val DEFAULT_TAG = "Ascently"
enum class Level(val androidLevel: Int) {
DEBUG(Log.DEBUG),
INFO(Log.INFO),
WARN(Log.WARN),
ERROR(Log.ERROR)
}
fun d(tag: String = DEFAULT_TAG, messageProvider: () -> String) {
log(Level.DEBUG, tag, messageProvider)
}
fun i(tag: String = DEFAULT_TAG, messageProvider: () -> String) {
log(Level.INFO, tag, messageProvider)
}
fun w(tag: String = DEFAULT_TAG, throwable: Throwable? = null, messageProvider: () -> String) {
log(Level.WARN, tag, messageProvider, throwable)
}
fun e(tag: String = DEFAULT_TAG, throwable: Throwable? = null, messageProvider: () -> String) {
log(Level.ERROR, tag, messageProvider, throwable)
}
private fun log(
level: Level,
tag: String,
messageProvider: () -> String,
throwable: Throwable? = null
) {
if (!BuildConfig.DEBUG) return
val message = messageProvider()
if (throwable != null) {
Log.println(level.androidLevel, tag, "$message\n${Log.getStackTraceString(throwable)}")
} else {
Log.println(level.androidLevel, tag, message)
}
}
}

View File

@@ -6,7 +6,6 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.util.Log
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import java.io.File
@@ -73,7 +72,7 @@ object ImageUtils {
compressedBitmap.recycle()
true
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Error saving image with EXIF data" }
false
}
}
@@ -119,7 +118,7 @@ object ImageUtils {
val file = getImageFile(context, relativePath)
file.delete()
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to delete image: $relativePath" }
false
}
}
@@ -137,7 +136,7 @@ object ImageUtils {
sourceFile.copyTo(destFile, overwrite = true)
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to import image from source: ${sourceFile.name}" }
null
}
}
@@ -157,7 +156,7 @@ object ImageUtils {
}
?: emptyList()
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to enumerate images directory" }
emptyList()
}
}
@@ -178,7 +177,7 @@ object ImageUtils {
tempFilename
} catch (e: Exception) {
Log.e("ImageUtils", "Error saving temporary image from URI", e)
AppLogger.e("ImageUtils", e) { "Error saving temporary image from URI" }
null
}
}
@@ -193,7 +192,7 @@ object ImageUtils {
return try {
val tempFile = File(getImagesDirectory(context), tempFilename)
if (!tempFile.exists()) {
Log.e("ImageUtils", "Temporary file does not exist: $tempFilename")
AppLogger.e("ImageUtils") { "Temporary file does not exist: $tempFilename" }
return null
}
@@ -202,17 +201,14 @@ object ImageUtils {
val finalFile = File(getImagesDirectory(context), deterministicFilename)
if (tempFile.renameTo(finalFile)) {
Log.d(
"ImageUtils",
"Renamed temporary image: $tempFilename -> $deterministicFilename"
)
AppLogger.d("ImageUtils") { "Renamed temporary image: $tempFilename -> $deterministicFilename" }
deterministicFilename
} else {
Log.e("ImageUtils", "Failed to rename temporary image: $tempFilename")
AppLogger.e("ImageUtils") { "Failed to rename temporary image: $tempFilename" }
null
}
} catch (e: Exception) {
Log.e("ImageUtils", "Error renaming temporary image", e)
AppLogger.e("ImageUtils", e) { "Error renaming temporary image" }
null
}
}
@@ -249,7 +245,7 @@ object ImageUtils {
destExif.saveAttributes()
} catch (e: Exception) {
// If EXIF preservation fails, continue without it
Log.w("ImageUtils", "Failed to preserve EXIF data: ${e.message}")
AppLogger.w("ImageUtils") { "Failed to preserve EXIF data: ${e.message}" }
}
bitmap.recycle()
@@ -262,7 +258,7 @@ object ImageUtils {
// Return relative path
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to save image from bytes: $filename" }
null
}
}
@@ -275,7 +271,7 @@ object ImageUtils {
orphanedImages.forEach { path -> deleteImage(context, path) }
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to clean up orphaned images" }
}
}
}

View File

@@ -2,7 +2,6 @@ package com.atridad.ascently.utils
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
class MigrationManager(private val context: Context) {
@@ -22,11 +21,11 @@ class MigrationManager(private val context: Context) {
*/
fun migrateIfNeeded() {
if (migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) {
Log.d(TAG, "Migration already completed, skipping")
AppLogger.d(TAG) { "Migration already completed, skipping" }
return
}
Log.i(TAG, "🔄 Starting migration from OpenClimb to Ascently...")
AppLogger.i(TAG) { "🔄 Starting migration from OpenClimb to Ascently..." }
var migrationCount = 0
// Migrate SharedPreferences
@@ -36,12 +35,9 @@ class MigrationManager(private val context: Context) {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, true) }
if (migrationCount > 0) {
Log.i(
TAG,
"🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently"
)
AppLogger.i(TAG) { "🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently" }
} else {
Log.i(TAG, " No OpenClimb data found to migrate")
AppLogger.i(TAG) { " No OpenClimb data found to migrate" }
}
}
@@ -95,10 +91,7 @@ class MigrationManager(private val context: Context) {
// Clear old preferences
oldPrefs.edit { clear() }
Log.d(
TAG,
"✅ Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)"
)
AppLogger.d(TAG) { "Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)" }
return oldPrefs.all.size
}
@@ -150,7 +143,7 @@ class MigrationManager(private val context: Context) {
}
}
Log.d(TAG, "Migrated ${keysToMigrate.size} keys in $prefFileName")
AppLogger.d(TAG) { "Migrated ${keysToMigrate.size} keys in $prefFileName" }
count += keysToMigrate.size
}
}
@@ -166,6 +159,6 @@ class MigrationManager(private val context: Context) {
/** Reset migration state (for testing purposes) */
fun resetMigrationState() {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, false) }
Log.d(TAG, "Migration state reset")
AppLogger.d(TAG) { "Migration state reset" }
}
}

View File

@@ -52,7 +52,8 @@ object ZipExportImportUtils {
zipOut.closeEntry()
// Add JSON data file
val json = Json {
val json =
Json {
prettyPrint = true
ignoreUnknownKeys = true
}
@@ -78,24 +79,21 @@ object ZipExportImportUtils {
zipOut.closeEntry()
successfulImages++
} else {
android.util.Log.w(
"ZipExportImportUtils",
AppLogger.w("ZipExportImportUtils") {
"Image file not found or empty: $imagePath"
)
}
}
} catch (e: Exception) {
android.util.Log.e(
"ZipExportImportUtils",
AppLogger.e("ZipExportImportUtils", e) {
"Failed to add image $imagePath: ${e.message}"
)
}
}
}
// Log export summary
android.util.Log.i(
"ZipExportImportUtils",
AppLogger.i("ZipExportImportUtils") {
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
)
}
}
// Validate the created ZIP file
@@ -131,7 +129,8 @@ object ZipExportImportUtils {
zipOut.closeEntry()
// Add JSON data file
val json = Json {
val json =
Json {
prettyPrint = true
ignoreUnknownKeys = true
}
@@ -158,17 +157,15 @@ object ZipExportImportUtils {
successfulImages++
}
} catch (e: Exception) {
android.util.Log.e(
"ZipExportImportUtils",
AppLogger.e("ZipExportImportUtils", e) {
"Failed to add image $imagePath: ${e.message}"
)
}
}
}
android.util.Log.i(
"ZipExportImportUtils",
AppLogger.i("ZipExportImportUtils") {
"Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included"
)
}
}
}
?: throw IOException("Could not open output stream")
@@ -217,16 +214,17 @@ object ZipExportImportUtils {
// Read metadata for validation
val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("metadata")
android.util.Log.i(
"ZipExportImportUtils",
AppLogger.i("ZipExportImportUtils") {
"Found metadata: ${metadataContent.lines().take(3).joinToString()}"
)
}
}
entry.name == DATA_JSON_FILENAME -> {
// Read JSON data
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("data")
}
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
// Extract image file
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
@@ -248,37 +246,33 @@ object ZipExportImportUtils {
val newPath = ImageUtils.importImageFile(context, tempFile)
if (newPath != null) {
importedImagePaths[originalFilename] = newPath
android.util.Log.d(
"ZipExportImportUtils",
AppLogger.d("ZipExportImportUtils") {
"Successfully imported image: $originalFilename -> $newPath"
)
} else {
android.util.Log.w(
"ZipExportImportUtils",
"Failed to import image: $originalFilename"
)
}
} else {
android.util.Log.w(
"ZipExportImportUtils",
AppLogger.w("ZipExportImportUtils") {
"Failed to import image: $originalFilename"
}
}
} else {
AppLogger.w("ZipExportImportUtils") {
"Extracted image is empty: $originalFilename"
)
}
}
// Clean up temp file
tempFile.delete()
} catch (e: Exception) {
android.util.Log.e(
"ZipExportImportUtils",
AppLogger.e("ZipExportImportUtils", e) {
"Failed to process image $originalFilename: ${e.message}"
)
}
}
}
else -> {
android.util.Log.d(
"ZipExportImportUtils",
AppLogger.d("ZipExportImportUtils") {
"Skipping ZIP entry: ${entry.name}"
)
}
}
}
@@ -296,10 +290,9 @@ object ZipExportImportUtils {
throw IOException("Invalid ZIP file: data.json is empty")
}
android.util.Log.i(
"ZipExportImportUtils",
AppLogger.i("ZipExportImportUtils") {
"Import extraction completed: ${importedImagePaths.size} images processed"
)
}
return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) {

View File

@@ -11,6 +11,7 @@ import com.atridad.ascently.MainActivity
import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository
import java.time.LocalDate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -48,53 +49,47 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
val database = AscentlyDatabase.getDatabase(context)
val repository = ClimbRepository(database, context)
// Fetch stats data
// Get last 7 days date range (rolling period)
val today = LocalDate.now()
val sevenDaysAgo = today.minusDays(6) // Today + 6 days ago = 7 days total
// Fetch all sessions and attempts
val sessions = repository.getAllSessions().first()
val problems = repository.getAllProblems().first()
val attempts = repository.getAllAttempts().first()
val gyms = repository.getAllGyms().first()
// Calculate stats
val completedSessions = sessions.filter { it.endTime != null }
// Count problems that have been completed (have at least one successful attempt)
val completedProblems =
problems
.filter { problem ->
attempts.any { attempt ->
attempt.problemId == problem.id &&
(attempt.result ==
com.atridad.ascently.data.model
.AttemptResult.SUCCESS ||
attempt.result ==
com.atridad.ascently.data.model
.AttemptResult.FLASH)
// Filter for last 7 days across all gyms
val weekSessions =
sessions.filter { session ->
try {
val sessionDate = LocalDate.parse(session.date.substring(0, 10))
!sessionDate.isBefore(sevenDaysAgo) && !sessionDate.isAfter(today)
} catch (_: Exception) {
false
}
}
.size
val favoriteGym =
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
(gymId, _) ->
gyms.find { it.id == gymId }?.name
}
?: "No sessions yet"
val weekSessionIds = weekSessions.map { it.id }.toSet()
// Count total attempts this week
val totalAttempts =
attempts.count { attempt -> weekSessionIds.contains(attempt.sessionId) }
// Count sessions this week
val totalSessions = weekSessions.size
launch(Dispatchers.Main) {
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
views.setTextViewText(
R.id.widget_total_sessions,
completedSessions.size.toString()
)
views.setTextViewText(
R.id.widget_problems_completed,
completedProblems.toString()
)
views.setTextViewText(R.id.widget_total_problems, problems.size.toString())
views.setTextViewText(R.id.widget_favorite_gym, favoriteGym)
// Set weekly stats
views.setTextViewText(R.id.widget_attempts_value, totalAttempts.toString())
views.setTextViewText(R.id.widget_sessions_value, totalSessions.toString())
val intent = Intent(context, MainActivity::class.java)
val intent =
Intent(context, MainActivity::class.java).apply {
flags =
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent =
PendingIntent.getActivity(
context,
@@ -110,10 +105,8 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
} catch (_: Exception) {
launch(Dispatchers.Main) {
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
views.setTextViewText(R.id.widget_total_sessions, "0")
views.setTextViewText(R.id.widget_problems_completed, "0")
views.setTextViewText(R.id.widget_total_problems, "0")
views.setTextViewText(R.id.widget_favorite_gym, "No data")
views.setTextViewText(R.id.widget_attempts_value, "0")
views.setTextViewText(R.id.widget_sessions_value, "0")
val intent = Intent(context, MainActivity::class.java)
val pendingIntent =

View 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="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>

View 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>

View File

@@ -4,27 +4,6 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
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>
<path android:fillColor="#FFC107" android:pathData="M24.000,78.545 L41.851,38.380 L59.702,78.545 Z" />
<path android:fillColor="#F44336" android:pathData="M39.372,78.545 L61.686,29.455 L84.000,78.545 Z" />
</vector>

View File

@@ -4,29 +4,6 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Left mountain (yellow/amber) -->
<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" />
<path android:fillColor="#FFC107" android:pathData="M2.000,20.182 L7.950,6.793 L13.901,20.182 Z" />
<path android:fillColor="#F44336" android:pathData="M7.124,20.182 L14.562,3.818 L22.000,20.182 Z" />
</vector>

View 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>

View File

@@ -5,190 +5,84 @@
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:padding="12dp">
android:padding="12dp"
android:gravity="center">
<!-- Header -->
<!-- Header with icon and "Weekly" text -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_width="28dp"
android:layout_height="28dp"
android:src="@drawable/ic_mountains"
android:tint="@color/widget_primary"
android:layout_marginEnd="8dp" />
android:layout_marginEnd="8dp"
android:contentDescription="@string/ascently_icon" />
<TextView
android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Ascently"
android:textSize="16sp"
android:text="@string/weekly"
android:textSize="18sp"
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: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>
<!-- Stats Grid -->
<!-- Sessions Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
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:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
android:gravity="center_vertical">
<!-- Sessions Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginEnd="4dp"
android:padding="12dp">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_play_arrow_24"
android:tint="@color/widget_primary"
android:layout_marginEnd="12dp"
android:contentDescription="@string/sessions_icon" />
<TextView
android:id="@+id/widget_total_sessions"
android:id="@+id/widget_sessions_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:text="@string/_0"
android:textSize="40sp"
android:textStyle="bold"
android:textColor="@color/widget_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>
android:textColor="@color/widget_text_primary" />
</LinearLayout>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 854 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -12,5 +12,9 @@
<string name="shortcut_end_session_disabled">No active session to end</string>
<!-- Widget -->
<string name="widget_description">View your climbing stats at a glance</string>
<string name="widget_description">View your weekly climbing stats</string>
<string name="ascently_icon">Ascently icon</string>
<string name="weekly">Weekly</string>
<string name="sessions_icon">Sessions icon</string>
<string name="_0">0</string>
</resources>

View File

@@ -4,7 +4,7 @@
<style name="Theme.Ascently.Splash" parent="Theme.Ascently">
<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>
</style>
</resources>

View File

@@ -5,10 +5,6 @@
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>

View File

@@ -3,15 +3,14 @@
android:description="@string/widget_description"
android:initialKeyguardLayout="@layout/widget_climb_stats"
android:initialLayout="@layout/widget_climb_stats"
android:minWidth="250dp"
android:minHeight="180dp"
android:minWidth="110dp"
android:minHeight="110dp"
android:maxResizeWidth="110dp"
android:maxResizeHeight="110dp"
android:previewImage="@drawable/ic_mountains"
android:previewLayout="@layout/widget_climb_stats"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:resizeMode="none"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable"
android:maxResizeWidth="320dp"
android:maxResizeHeight="240dp" />
android:widgetCategory="home_screen" />

View File

@@ -1,6 +1,6 @@
[versions]
agp = "8.12.3"
kotlin = "2.2.20"
kotlin = "2.2.21"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
@@ -9,17 +9,17 @@ androidxTestCore = "1.7.0"
androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2025.10.00"
room = "2.8.2"
navigation = "2.9.5"
viewmodel = "2.9.4"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.0"
composeBom = "2025.11.01"
room = "2.8.4"
navigation = "2.9.6"
viewmodel = "2.10.0"
kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2"
coil = "2.7.0"
ksp = "2.2.20-2.0.3"
exifinterface = "1.3.6"
exifinterface = "1.4.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }

3
branding/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.tmp
.DS_Store
*.log

394
branding/generate.py Executable file
View 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
View 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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

BIN
branding/logos/logo-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
branding/logos/logo-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
branding/logos/logo-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

BIN
branding/logos/logo-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

View 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

View 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

View 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
View 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

View File

@@ -1,7 +1,7 @@
{
"name": "ascently-docs",
"type": "module",
"version": "1.0.0",
"version": "1.1.0",
"description": "Documentation site for Ascently - FOSS climbing tracking app",
"repository": {
"type": "git",
@@ -25,9 +25,13 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^9.5.0",
"@astrojs/starlight": "^0.36.1",
"astro": "^5.14.5",
"sharp": "^0.34.4"
"@astrojs/node": "^9.5.1",
"@astrojs/starlight": "^0.37.0",
"astro": "^5.16.3",
"qrcode": "^1.5.4",
"sharp": "^0.34.5"
},
"devDependencies": {
"@types/qrcode": "^1.5.6"
}
}

1398
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 B

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,15 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- Left mountain (amber/yellow) -->
<polygon points="6,24 12,8 18,24"
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"/>
<polygon points="3.000,26.636 10.736,9.231 18.471,26.636" fill="#FFC107"/>
<polygon points="9.661,26.636 19.331,5.364 29.000,26.636" fill="#F44336"/>
</svg>

Before

Width:  |  Height:  |  Size: 475 B

After

Width:  |  Height:  |  Size: 244 B

View File

@@ -1,15 +1,4 @@
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<!-- Left mountain (amber/yellow) -->
<polygon points="48,192 96,64 144,192"
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"/>
<polygon points="24.000,213.091 85.884,73.851 147.769,213.091" fill="#FFC107"/>
<polygon points="77.289,213.091 154.645,42.909 232.000,213.091" fill="#F44336"/>
</svg>

Before

Width:  |  Height:  |  Size: 490 B

After

Width:  |  Height:  |  Size: 259 B

View File

@@ -1,15 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- Left mountain (amber/yellow) -->
<polygon points="6,24 12,8 18,24"
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"/>
<polygon points="3.000,26.636 10.736,9.231 18.471,26.636" fill="#FFC107"/>
<polygon points="9.661,26.636 19.331,5.364 29.000,26.636" fill="#F44336"/>
</svg>

Before

Width:  |  Height:  |  Size: 475 B

After

Width:  |  Height:  |  Size: 244 B

View 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>

View 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
View 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/tags?q=Android",
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;

View File

@@ -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+**

View 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} />

View File

@@ -5,7 +5,7 @@ description: Ascently's Privacy Policy
**Last updated: September 29, 2025**
This Privacy Policy describes our policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
This Privacy Policy describes my policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
## No Data Collection
@@ -36,7 +36,7 @@ You may optionally integrate with Apple Health or Android Health Connect to impo
This software does not use cookies, tracking pixels, or any other analytics or tracking mechanisms. Your usage of the software is completely private.
## Contact Us
## Contact
If you have any questions about this Privacy Policy, you can contact me:

View File

@@ -25,7 +25,7 @@ final class LiveActivityManager {
pushType: nil
)
} catch {
print("Failed to start live activity: \(error)")
AppLogger.error("Failed to start live activity: \(error)", tag: "LegacyLiveActivityManager")
}
}

View File

@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -487,7 +487,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.1.0;
MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -513,7 +513,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -535,7 +535,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.1.0;
MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -602,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -613,7 +613,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.1.0;
MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -643,7 +643,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.1.0;
MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -0,0 +1,24 @@
import AppIntents
/// Defines the App Shortcuts available in the Shortcuts app.
struct AscentlyShortcuts: AppShortcutsProvider {
static var shortcutTileColor: ShortcutTileColor {
.teal
}
static var appShortcuts: [AppShortcut] {
return [
AppShortcut(
intent: ToggleSessionIntent(),
phrases: [
"Toggle climb in \(.applicationName)",
"Start or stop climb in \(.applicationName)",
"Climb toggle in \(.applicationName)",
],
shortTitle: "Toggle Session",
systemImageName: "figure.climbing"
)
]
}
}

View File

@@ -0,0 +1,111 @@
import Foundation
/// User-visible errors that can arise while handling session-related intents.
enum SessionIntentError: LocalizedError {
case noRecentGym
case noActiveSession
case failedToStartSession
case failedToEndSession
var errorDescription: String? {
switch self {
case .noRecentGym:
return "There's no recent gym to start a session with."
case .noActiveSession:
return "There isn't an active session to end right now."
case .failedToStartSession:
return "Ascently couldn't start a new session."
case .failedToEndSession:
return "Ascently couldn't finish the active session."
}
}
}
struct SessionIntentSummary: Sendable {
let sessionId: UUID
let gymName: String
let status: SessionStatus
}
/// Controller for handling session operations from App Intents.
@MainActor
final class SessionIntentController {
private let dataManager: ClimbingDataManager
init(dataManager: ClimbingDataManager = .shared) {
self.dataManager = dataManager
}
/// Starts a new session using the most recently visited gym.
func startSessionWithLastUsedGym() async throws -> SessionIntentSummary {
// Wait for data to load
if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000)
}
guard let lastGym = dataManager.getLastUsedGym() else {
logFailure(.noRecentGym, context: "No recorded sessions available")
throw SessionIntentError.noRecentGym
}
guard let startedSession = await dataManager.startSessionAsync(gymId: lastGym.id) else {
logFailure(.failedToStartSession, context: "Data manager failed to create new session")
throw SessionIntentError.failedToStartSession
}
return SessionIntentSummary(
sessionId: startedSession.id,
gymName: lastGym.name,
status: startedSession.status
)
}
/// Ends the currently active climbing session, if one exists.
func endActiveSession() async throws -> SessionIntentSummary {
guard let activeSession = dataManager.activeSession else {
logFailure(.noActiveSession, context: "No active session stored in data manager")
throw SessionIntentError.noActiveSession
}
guard let completedSession = await dataManager.endSessionAsync(activeSession.id) else {
logFailure(
.failedToEndSession, context: "Data manager failed to complete active session")
throw SessionIntentError.failedToEndSession
}
guard let gym = dataManager.gym(withId: completedSession.gymId) else {
logFailure(
.failedToEndSession,
context: "Gym missing for completed session \(completedSession.id)")
throw SessionIntentError.failedToEndSession
}
return SessionIntentSummary(
sessionId: completedSession.id,
gymName: gym.name,
status: completedSession.status
)
}
private func logFailure(_ error: SessionIntentError, context: String) {
// Log error for debugging
print("SessionIntentError: \(error). Context: \(context)")
}
/// Toggles the session state: ends active session if one exists, otherwise starts a new one.
func toggleSession() async throws -> (summary: SessionIntentSummary, wasStarted: Bool) {
// Wait for data to load
if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000)
}
if dataManager.activeSession != nil {
let summary = try await endActiveSession()
return (summary, false)
} else {
let summary = try await startSessionWithLastUsedGym()
return (summary, true)
}
}
}

View File

@@ -0,0 +1,40 @@
import AppIntents
import Foundation
/// Toggles the climbing session state: starts a session if none is active, or ends the current one.
struct ToggleSessionIntent: AppIntent {
static var title: LocalizedStringResource {
"Toggle Climbing Session"
}
static var description: IntentDescription {
IntentDescription(
"Starts a new session at your last gym if you're not climbing, or ends your current session if you are."
)
}
static var openAppWhenRun: Bool {
false
}
func perform() async throws -> some IntentResult & ProvidesDialog {
// Wait for app initialization
try? await Task.sleep(nanoseconds: 1_000_000_000)
let controller = await SessionIntentController()
let (summary, wasStarted) = try await controller.toggleSession()
if wasStarted {
// Wait for Live Activity
try? await Task.sleep(nanoseconds: 500_000_000)
return .result(dialog: IntentDialog("Session started at \(summary.gymName). Have an awesome climb!"))
} else {
return .result(dialog: IntentDialog("Session at \(summary.gymName) ended. Nice work!"))
}
}
static var parameterSummary: some ParameterSummary {
Summary("Toggle my climbing session")
}
}

View File

@@ -1,10 +1,25 @@
import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
return true
}
}
@main
struct AscentlyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) private var scenePhase
@StateObject private var themeManager = ThemeManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(themeManager)
.tint(themeManager.accentColor)
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,22 +1,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">
<!-- Dark background with rounded corners for iOS -->
<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"/>
<!-- Transform to match Android layout exactly -->
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain (yellow/amber) - matches Android coordinates with white border -->
<polygon points="15,70 35,25 55,70"
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"/>
<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>

Before

Width:  |  Height:  |  Size: 913 B

After

Width:  |  Height:  |  Size: 411 B

View File

@@ -1,22 +1,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">
<!-- White background with rounded corners for iOS -->
<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"/>
<!-- Transform to match Android layout exactly -->
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain (yellow/amber) - matches Android coordinates -->
<polygon points="15,70 35,25 55,70"
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"/>
<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>

Before

Width:  |  Height:  |  Size: 878 B

After

Width:  |  Height:  |  Size: 411 B

View File

@@ -1,24 +1,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">
<!-- Transparent background with rounded corners for iOS tinted mode -->
<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"/>
<!-- Transform to match Android layout exactly -->
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain - matches Android coordinates, black fill for tinting -->
<polygon points="15,70 35,25 55,70"
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"/>
<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>

Before

Width:  |  Height:  |  Size: 981 B

After

Width:  |  Height:  |  Size: 443 B

View File

@@ -8,6 +8,7 @@ struct PhotoOptionSheet: View {
let onCameraSelected: () -> Void
let onPhotoLibrarySelected: () -> Void
let onDismiss: () -> Void
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
NavigationView {
@@ -29,7 +30,7 @@ struct PhotoOptionSheet: View {
HStack {
Image(systemName: "photo.on.rectangle")
.font(.title2)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
Text("Photo Library")
.font(.headline)
Spacer()
@@ -52,7 +53,7 @@ struct PhotoOptionSheet: View {
HStack {
Image(systemName: "camera.fill")
.font(.title2)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
Text("Camera")
.font(.headline)
Spacer()

View File

@@ -1,7 +1,7 @@
import SwiftUI
struct ContentView: View {
@StateObject private var dataManager = ClimbingDataManager()
@StateObject private var dataManager = ClimbingDataManager.shared
@State private var selectedTab = 0
@Environment(\.scenePhase) private var scenePhase
@State private var notificationObservers: [NSObjectProtocol] = []
@@ -91,11 +91,12 @@ struct ContentView: View {
object: nil,
queue: .main
) { _ in
print("App will enter foreground - preparing Live Activity check")
Task {
Task { @MainActor in
AppLogger.info(
"App will enter foreground - preparing Live Activity check", tag: "Lifecycle")
// Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
await dataManager.onAppBecomeActive()
dataManager.onAppBecomeActive()
// Re-verify health integration when returning from background
await dataManager.healthKitService.verifyAndRestoreIntegration()
}
@@ -107,10 +108,11 @@ struct ContentView: View {
object: nil,
queue: .main
) { _ in
print("App did become active - checking Live Activity status")
Task {
Task { @MainActor in
AppLogger.info(
"App did become active - checking Live Activity status", tag: "Lifecycle")
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive()
dataManager.onAppBecomeActive()
await dataManager.healthKitService.verifyAndRestoreIntegration()
}
}

View File

@@ -6,6 +6,7 @@
<true/>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library to add photos to climbing problems.</string>
<key>NSCameraUsageDescription</key>

View File

@@ -38,7 +38,7 @@ class HealthKitService: ObservableObject {
{
currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId
print("HealthKit: Restored active workout from \(startDate)")
AppLogger.info("HealthKit: Restored active workout from \(startDate)", tag: "HealthKit")
}
}
@@ -56,31 +56,34 @@ class HealthKitService: ObservableObject {
guard isEnabled else { return }
guard HKHealthStore.isHealthDataAvailable() else {
print("HealthKit: Device does not support HealthKit")
AppLogger.warning("HealthKit: Device does not support HealthKit", tag: "HealthKit")
return
}
checkAuthorization()
if !isAuthorized {
print(
"HealthKit: Integration was enabled but authorization lost, attempting to restore..."
)
AppLogger.warning(
"HealthKit: Integration was enabled but authorization lost, attempting to restore...",
tag: "HealthKit")
do {
try await requestAuthorization()
print("HealthKit: Authorization restored successfully")
AppLogger.info("HealthKit: Authorization restored successfully", tag: "HealthKit")
} catch {
print("HealthKit: Failed to restore authorization: \(error.localizedDescription)")
AppLogger.error(
"HealthKit: Failed to restore authorization: \(error.localizedDescription)",
tag: "HealthKit")
}
} else {
print("HealthKit: Integration verified - authorization is valid")
AppLogger.info(
"HealthKit: Integration verified - authorization is valid", tag: "HealthKit")
}
if hasActiveWorkout() {
print(
"HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)"
)
AppLogger.info(
"HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)",
tag: "HealthKit")
}
}
@@ -130,7 +133,7 @@ class HealthKitService: ObservableObject {
currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId
persistActiveWorkout()
print("HealthKit: Started workout for session \(sessionId)")
AppLogger.info("HealthKit: Started workout for session \(sessionId)", tag: "HealthKit")
}
func endWorkout(endDate: Date) async throws {
@@ -178,15 +181,17 @@ class HealthKitService: ObservableObject {
try await builder.endCollection(at: endDate)
let workout = try await builder.finishWorkout()
print(
"HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")"
)
AppLogger.info(
"HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")",
tag: "HealthKit")
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
} catch {
print("HealthKit: Failed to save workout: \(error.localizedDescription)")
AppLogger.error(
"HealthKit: Failed to save workout: \(error.localizedDescription)", tag: "HealthKit"
)
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
@@ -199,7 +204,7 @@ class HealthKitService: ObservableObject {
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
print("HealthKit: Workout cancelled")
AppLogger.info("HealthKit: Workout cancelled", tag: "HealthKit")
}
func hasActiveWorkout() -> Bool {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
import Foundation
struct SyncMerger {
private static let logTag = "SyncMerger"
static func mergeDataSafely(
localBackup: ClimbDataBackup,
serverBackup: ClimbDataBackup,
dataManager: ClimbingDataManager,
imagePathMapping: [String: String]
) throws -> (gyms: [Gym], problems: [Problem], sessions: [ClimbSession], attempts: [Attempt], uniqueDeletions: [DeletedItem]) {
// Merge deletion lists first to prevent resurrection of deleted items
let localDeletions = dataManager.getDeletedItems()
let allDeletions = localDeletions + serverBackup.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
AppLogger.info("Merging gyms...", tag: logTag)
let mergedGyms = mergeGyms(
local: dataManager.gyms,
server: serverBackup.gyms,
deletedItems: uniqueDeletions)
AppLogger.info("Merging problems...", tag: logTag)
let mergedProblems = try mergeProblems(
local: dataManager.problems,
server: serverBackup.problems,
imagePathMapping: imagePathMapping,
deletedItems: uniqueDeletions)
AppLogger.info("Merging sessions...", tag: logTag)
let mergedSessions = try mergeSessions(
local: dataManager.sessions,
server: serverBackup.sessions,
deletedItems: uniqueDeletions)
AppLogger.info("Merging attempts...", tag: logTag)
let mergedAttempts = try mergeAttempts(
local: dataManager.attempts,
server: serverBackup.attempts,
deletedItems: uniqueDeletions)
return (mergedGyms, mergedProblems, mergedSessions, mergedAttempts, uniqueDeletions)
}
private static func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym] {
var merged = local
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
let localGymIds = Set(local.map { $0.id.uuidString })
merged.removeAll { deletedGymIds.contains($0.id.uuidString) }
// Add new items from server (excluding deleted ones)
for serverGym in server {
if let serverGymConverted = try? serverGym.toGym() {
let localHasGym = localGymIds.contains(serverGym.id)
let isDeleted = deletedGymIds.contains(serverGym.id)
if !localHasGym && !isDeleted {
merged.append(serverGymConverted)
}
}
}
return merged
}
private static func mergeProblems(
local: [Problem],
server: [BackupProblem],
imagePathMapping: [String: String],
deletedItems: [DeletedItem]
) throws -> [Problem] {
var merged = local
let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id })
let localProblemIds = Set(local.map { $0.id.uuidString })
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
for serverProblem in server {
let localHasProblem = localProblemIds.contains(serverProblem.id)
let isDeleted = deletedProblemIds.contains(serverProblem.id)
if !localHasProblem && !isDeleted {
var problemToAdd = serverProblem
if !imagePathMapping.isEmpty, let imagePaths = serverProblem.imagePaths, !imagePaths.isEmpty {
let updatedImagePaths = imagePaths.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
}
if updatedImagePaths != imagePaths {
problemToAdd = BackupProblem(
id: serverProblem.id,
gymId: serverProblem.gymId,
name: serverProblem.name,
description: serverProblem.description,
climbType: serverProblem.climbType,
difficulty: serverProblem.difficulty,
tags: serverProblem.tags,
location: serverProblem.location,
imagePaths: updatedImagePaths,
isActive: serverProblem.isActive,
dateSet: serverProblem.dateSet,
notes: serverProblem.notes,
createdAt: serverProblem.createdAt,
updatedAt: serverProblem.updatedAt
)
}
}
if let serverProblemConverted = try? problemToAdd.toProblem() {
merged.append(serverProblemConverted)
}
}
}
return merged
}
private static func mergeSessions(
local: [ClimbSession], server: [BackupClimbSession], deletedItems: [DeletedItem]
) throws -> [ClimbSession] {
var merged = local
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
let localSessionIds = Set(local.map { $0.id.uuidString })
merged.removeAll { session in
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
}
for serverSession in server {
let localHasSession = localSessionIds.contains(serverSession.id)
let isDeleted = deletedSessionIds.contains(serverSession.id)
if !localHasSession && !isDeleted {
if let serverSessionConverted = try? serverSession.toClimbSession() {
merged.append(serverSessionConverted)
}
}
}
return merged
}
private static func mergeAttempts(
local: [Attempt], server: [BackupAttempt], deletedItems: [DeletedItem]
) throws -> [Attempt] {
var merged = local
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
let localAttemptIds = Set(local.map { $0.id.uuidString })
// Get active session IDs to protect their attempts
let activeSessionIds = Set(
local.compactMap { attempt in
return attempt.sessionId
}.filter { sessionId in
// Check if this session ID belongs to an active session
// For now, we'll be conservative and not delete attempts during merge
return true
})
// Remove items that were deleted on other devices (but be conservative with attempts)
merged.removeAll { attempt in
deletedAttemptIds.contains(attempt.id.uuidString)
&& !activeSessionIds.contains(attempt.sessionId)
}
for serverAttempt in server {
let localHasAttempt = localAttemptIds.contains(serverAttempt.id)
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
if !localHasAttempt && !isDeleted {
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
merged.append(serverAttemptConverted)
}
}
}
return merged
}
}

View File

@@ -0,0 +1,73 @@
import Foundation
enum SyncProviderType: String, CaseIterable, Identifiable {
case none
case server
case iCloud
var id: String { rawValue }
var displayName: String {
switch self {
case .none: return "None"
case .server: return "Self-Hosted Server"
case .iCloud: return "iCloud"
}
}
}
protocol SyncProvider {
var type: SyncProviderType { get }
var isConfigured: Bool { get }
var isConnected: Bool { get }
func sync(dataManager: ClimbingDataManager) async throws
func testConnection() async throws
func disconnect()
}
enum SyncError: LocalizedError {
case notConfigured
case notConnected
case invalidURL
case invalidResponse
case unauthorized
case badRequest
case serverError(Int)
case decodingError(Error)
case exportFailed
case importFailed(Error)
case imageNotFound
case imageUploadFailed
case providerError(String)
var errorDescription: String? {
switch self {
case .notConfigured:
return "Sync server not configured. Please set server URL and auth token."
case .notConnected:
return "Not connected to sync server. Please test connection first."
case .invalidURL:
return "Invalid server URL."
case .invalidResponse:
return "Invalid response from server."
case .unauthorized:
return "Authentication failed. Check your auth token."
case .badRequest:
return "Bad request. Check your data format."
case .serverError(let code):
return "Server error (code \(code))."
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
case .exportFailed:
return "Failed to export local data."
case .importFailed(let error):
return "Failed to import data: \(error.localizedDescription)"
case .imageNotFound:
return "Image not found on server."
case .imageUploadFailed:
return "Failed to upload image to server."
case .providerError(let message):
return "Sync provider error: \(message)"
}
}
}

Some files were not shown because too many files have changed in this diff Show More