Compare commits

...

22 Commits

Author SHA1 Message Date
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
8529f76c22 Fixed doc issue
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m50s
2025-10-16 12:36:13 -06:00
879aae0721 Update docs with App Store link
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m9s
2025-10-16 00:39:11 -06:00
6fc86558b2 Fixed docs
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m59s
2025-10-15 18:25:48 -06:00
23de8a6fc6 [All Platforms] 2.1.0 - Sync Optimizations
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m31s
Ascently - Docs Deploy / build-and-push (push) Successful in 3m30s
2025-10-15 18:17:19 -06:00
122 changed files with 6072 additions and 2445 deletions

View File

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

View File

@@ -32,13 +32,12 @@ data class BackupGym(
val supportedClimbTypes: List<ClimbType>, val supportedClimbTypes: List<ClimbType>,
val difficultySystems: List<DifficultySystem>, val difficultySystems: List<DifficultySystem>,
@kotlinx.serialization.SerialName("customDifficultyGrades") @kotlinx.serialization.SerialName("customDifficultyGrades")
val customDifficultyGrades: List<String> = emptyList(), val customDifficultyGrades: List<String>? = null,
val notes: String? = null, val notes: String? = null,
val createdAt: String, val createdAt: String,
val updatedAt: String val updatedAt: String
) { ) {
companion object { companion object {
/** Create BackupGym from native Android Gym model */
fun fromGym(gym: Gym): BackupGym { fun fromGym(gym: Gym): BackupGym {
return BackupGym( return BackupGym(
id = gym.id, id = gym.id,
@@ -46,7 +45,7 @@ data class BackupGym(
location = gym.location, location = gym.location,
supportedClimbTypes = gym.supportedClimbTypes, supportedClimbTypes = gym.supportedClimbTypes,
difficultySystems = gym.difficultySystems, difficultySystems = gym.difficultySystems,
customDifficultyGrades = gym.customDifficultyGrades, customDifficultyGrades = gym.customDifficultyGrades.ifEmpty { null },
notes = gym.notes, notes = gym.notes,
createdAt = gym.createdAt, createdAt = gym.createdAt,
updatedAt = gym.updatedAt updatedAt = gym.updatedAt
@@ -54,7 +53,6 @@ data class BackupGym(
} }
} }
/** Convert to native Android Gym model */
fun toGym(): Gym { fun toGym(): Gym {
return Gym( return Gym(
id = id, id = id,
@@ -62,7 +60,7 @@ data class BackupGym(
location = location, location = location,
supportedClimbTypes = supportedClimbTypes, supportedClimbTypes = supportedClimbTypes,
difficultySystems = difficultySystems, difficultySystems = difficultySystems,
customDifficultyGrades = customDifficultyGrades, customDifficultyGrades = customDifficultyGrades ?: emptyList(),
notes = notes, notes = notes,
createdAt = createdAt, createdAt = createdAt,
updatedAt = updatedAt updatedAt = updatedAt
@@ -79,7 +77,7 @@ data class BackupProblem(
val description: String? = null, val description: String? = null,
val climbType: ClimbType, val climbType: ClimbType,
val difficulty: DifficultyGrade, val difficulty: DifficultyGrade,
val tags: List<String> = emptyList(), val tags: List<String>? = null,
val location: String? = null, val location: String? = null,
val imagePaths: List<String>? = null, val imagePaths: List<String>? = null,
val isActive: Boolean = true, val isActive: Boolean = true,
@@ -89,7 +87,6 @@ data class BackupProblem(
val updatedAt: String val updatedAt: String
) { ) {
companion object { companion object {
/** Create BackupProblem from native Android Problem model */
fun fromProblem(problem: Problem): BackupProblem { fun fromProblem(problem: Problem): BackupProblem {
return BackupProblem( return BackupProblem(
id = problem.id, id = problem.id,
@@ -112,7 +109,6 @@ data class BackupProblem(
} }
} }
/** Convert to native Android Problem model */
fun toProblem(): Problem { fun toProblem(): Problem {
return Problem( return Problem(
id = id, id = id,
@@ -121,7 +117,7 @@ data class BackupProblem(
description = description, description = description,
climbType = climbType, climbType = climbType,
difficulty = difficulty, difficulty = difficulty,
tags = tags, tags = tags ?: emptyList(),
location = location, location = location,
imagePaths = imagePaths ?: emptyList(), imagePaths = imagePaths ?: emptyList(),
isActive = isActive, isActive = isActive,
@@ -132,7 +128,6 @@ data class BackupProblem(
) )
} }
/** Create a copy with updated image paths for import processing */
fun withUpdatedImagePaths(newImagePaths: List<String>): BackupProblem { fun withUpdatedImagePaths(newImagePaths: List<String>): BackupProblem {
return copy(imagePaths = newImagePaths.ifEmpty { null }) return copy(imagePaths = newImagePaths.ifEmpty { null })
} }
@@ -153,7 +148,6 @@ data class BackupClimbSession(
val updatedAt: String val updatedAt: String
) { ) {
companion object { companion object {
/** Create BackupClimbSession from native Android ClimbSession model */
fun fromClimbSession(session: ClimbSession): BackupClimbSession { fun fromClimbSession(session: ClimbSession): BackupClimbSession {
return BackupClimbSession( return BackupClimbSession(
id = session.id, id = session.id,
@@ -170,7 +164,6 @@ data class BackupClimbSession(
} }
} }
/** Convert to native Android ClimbSession model */
fun toClimbSession(): ClimbSession { fun toClimbSession(): ClimbSession {
return ClimbSession( return ClimbSession(
id = id, id = id,
@@ -203,7 +196,6 @@ data class BackupAttempt(
val updatedAt: String? = null val updatedAt: String? = null
) { ) {
companion object { companion object {
/** Create BackupAttempt from native Android Attempt model */
fun fromAttempt(attempt: Attempt): BackupAttempt { fun fromAttempt(attempt: Attempt): BackupAttempt {
return BackupAttempt( return BackupAttempt(
id = attempt.id, id = attempt.id,
@@ -221,7 +213,6 @@ data class BackupAttempt(
} }
} }
/** Convert to native Android Attempt model */
fun toAttempt(): Attempt { fun toAttempt(): Attempt {
return Attempt( return Attempt(
id = id, id = id,

View File

@@ -3,7 +3,7 @@ package com.atridad.ascently.data.health
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import com.atridad.ascently.utils.AppLogger
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController import androidx.health.connect.client.PermissionController
@@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.flow
class HealthConnectManager(private val context: Context) { class HealthConnectManager(private val context: Context) {
private val preferences: SharedPreferences = private val preferences: SharedPreferences =
context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE) context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE)
private val _isEnabled = MutableStateFlow(preferences.getBoolean("enabled", false)) private val _isEnabled = MutableStateFlow(preferences.getBoolean("enabled", false))
private val _hasPermissions = MutableStateFlow(preferences.getBoolean("permissions", false)) private val _hasPermissions = MutableStateFlow(preferences.getBoolean("permissions", false))
@@ -46,27 +46,26 @@ class HealthConnectManager(private val context: Context) {
private const val TAG = "HealthConnectManager" private const val TAG = "HealthConnectManager"
val REQUIRED_PERMISSIONS = val REQUIRED_PERMISSIONS =
setOf( setOf(
HealthPermission.getReadPermission(ExerciseSessionRecord::class), HealthPermission.getReadPermission(ExerciseSessionRecord::class),
HealthPermission.getWritePermission(ExerciseSessionRecord::class), HealthPermission.getWritePermission(ExerciseSessionRecord::class),
HealthPermission.getReadPermission(HeartRateRecord::class), HealthPermission.getReadPermission(HeartRateRecord::class),
HealthPermission.getWritePermission(HeartRateRecord::class), HealthPermission.getWritePermission(HeartRateRecord::class),
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class) HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class)
) )
} }
private val healthConnectClient by lazy { private val healthConnectClient by lazy {
try { try {
HealthConnectClient.getOrCreate(context) HealthConnectClient.getOrCreate(context)
} catch (e: Exception) { } 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 _isCompatible.value = false
null null
} }
} }
/** Check if Health Connect is available on this device */
fun isHealthConnectAvailable(): Flow<Boolean> = flow { fun isHealthConnectAvailable(): Flow<Boolean> = flow {
try { try {
if (!_isCompatible.value) { if (!_isCompatible.value) {
@@ -76,16 +75,12 @@ class HealthConnectManager(private val context: Context) {
val status = HealthConnectClient.getSdkStatus(context) val status = HealthConnectClient.getSdkStatus(context)
emit(status == HealthConnectClient.SDK_AVAILABLE) emit(status == HealthConnectClient.SDK_AVAILABLE)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect availability", e) AppLogger.e(TAG, e) { "Error checking Health Connect availability" }
_isCompatible.value = false _isCompatible.value = false
emit(false) emit(false)
} }
} }
/**
* Enable or disable Health Connect integration and automatically request permissions if
* enabling
*/
suspend fun setEnabled(enabled: Boolean) { suspend fun setEnabled(enabled: Boolean) {
preferences.edit().putBoolean("enabled", enabled).apply() preferences.edit().putBoolean("enabled", enabled).apply()
_isEnabled.value = enabled _isEnabled.value = enabled
@@ -95,91 +90,85 @@ class HealthConnectManager(private val context: Context) {
try { try {
val alreadyHasPermissions = hasAllPermissions() val alreadyHasPermissions = hasAllPermissions()
if (!alreadyHasPermissions) { 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) { } 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) { } else if (!enabled) {
setPermissionsGranted(false) setPermissionsGranted(false)
} }
} }
/** Update the permissions granted state */
fun setPermissionsGranted(granted: Boolean) { fun setPermissionsGranted(granted: Boolean) {
preferences.edit().putBoolean("permissions", granted).apply() preferences.edit().putBoolean("permissions", granted).apply()
_hasPermissions.value = granted _hasPermissions.value = granted
} }
/** Check if all required permissions are granted */
suspend fun hasAllPermissions(): Boolean { suspend fun hasAllPermissions(): Boolean {
return try { return try {
if (!_isCompatible.value || healthConnectClient == null) { if (!_isCompatible.value || healthConnectClient == null) {
return false return false
} }
val grantedPermissions = val grantedPermissions =
healthConnectClient!!.permissionController.getGrantedPermissions() healthConnectClient!!.permissionController.getGrantedPermissions()
val hasAll = val hasAll =
REQUIRED_PERMISSIONS.all { permission -> REQUIRED_PERMISSIONS.all { permission ->
grantedPermissions.contains(permission) grantedPermissions.contains(permission)
} }
setPermissionsGranted(hasAll) setPermissionsGranted(hasAll)
hasAll hasAll
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking permissions", e) AppLogger.e(TAG, e) { "Error checking permissions" }
setPermissionsGranted(false) setPermissionsGranted(false)
false false
} }
} }
/** Check if Health Connect is ready for use */
suspend fun isReady(): Boolean { suspend fun isReady(): Boolean {
return try { return try {
if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null) if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null)
return false return false
val isAvailable = val isAvailable =
HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
val hasPerms = if (isAvailable) hasAllPermissions() else false val hasPerms = if (isAvailable) hasAllPermissions() else false
isAvailable && hasPerms isAvailable && hasPerms
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect readiness", e) AppLogger.e(TAG, e) { "Error checking Health Connect readiness" }
false false
} }
} }
/** Get permission request contract */
fun getPermissionRequestContract(): ActivityResultContract<Set<String>, Set<String>> { fun getPermissionRequestContract(): ActivityResultContract<Set<String>, Set<String>> {
return PermissionController.createRequestPermissionResultContract() return PermissionController.createRequestPermissionResultContract()
} }
/** Get required permissions as strings */
fun getRequiredPermissions(): Set<String> { fun getRequiredPermissions(): Set<String> {
return try { return try {
REQUIRED_PERMISSIONS.map { it }.toSet() REQUIRED_PERMISSIONS.map { it }.toSet()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error getting required permissions", e) AppLogger.e(TAG, e) { "Error getting required permissions" }
emptySet() emptySet()
} }
} }
/** Sync a completed climbing session to Health Connect (only when auto-sync is enabled) */
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
suspend fun syncCompletedSession( suspend fun syncCompletedSession(
session: ClimbSession, session: ClimbSession,
gymName: String, gymName: String,
attemptCount: Int = 0 attemptCount: Int = 0
): Result<Unit> { ): Result<Unit> {
return try { return try {
if (!isReady() || !_autoSync.value) { if (!isReady() || !_autoSync.value) {
return Result.failure( return Result.failure(
IllegalStateException("Health Connect not ready or auto-sync disabled") IllegalStateException("Health Connect not ready or auto-sync disabled")
) )
} }
if (session.status != SessionStatus.COMPLETED) { if (session.status != SessionStatus.COMPLETED) {
return Result.failure( return Result.failure(
IllegalArgumentException("Only completed sessions can be synced") IllegalArgumentException("Only completed sessions can be synced")
) )
} }
@@ -188,29 +177,29 @@ class HealthConnectManager(private val context: Context) {
if (startTime == null || endTime == null) { if (startTime == null || endTime == null) {
return Result.failure( return Result.failure(
IllegalArgumentException("Session must have valid start and end times") IllegalArgumentException("Session must have valid start and end times")
) )
} }
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>() val records = mutableListOf<androidx.health.connect.client.records.Record>()
try { try {
val exerciseSession = val exerciseSession =
ExerciseSessionRecord( ExerciseSessionRecord(
startTime = startTime, startTime = startTime,
startZoneOffset = startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime), ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime, endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
exerciseType = exerciseType =
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
title = "Rock Climbing at $gymName" title = "Rock Climbing at $gymName"
) )
records.add(exerciseSession) records.add(exerciseSession)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to create exercise session record", e) AppLogger.w(TAG, e) { "Failed to create exercise session record" }
} }
try { try {
@@ -219,109 +208,105 @@ class HealthConnectManager(private val context: Context) {
if (estimatedCalories > 0) { if (estimatedCalories > 0) {
val caloriesRecord = val caloriesRecord =
TotalCaloriesBurnedRecord( TotalCaloriesBurnedRecord(
startTime = startTime, startTime = startTime,
startZoneOffset = startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime), ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime, endTime = endTime,
endZoneOffset = endZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(endTime), ZoneOffset.systemDefault().rules.getOffset(endTime),
energy = Energy.calories(estimatedCalories) energy = Energy.calories(estimatedCalories)
) )
records.add(caloriesRecord) records.add(caloriesRecord)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to create calories record", e) AppLogger.w(TAG, e) { "Failed to create calories record" }
} }
try { try {
val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount) val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount)
heartRateRecord?.let { records.add(it) } heartRateRecord?.let { records.add(it) }
} catch (e: Exception) { } 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) { 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) healthConnectClient!!.insertRecords(records)
Log.i( AppLogger.i(TAG) {
TAG, "Successfully synced ${records.size} records for session '${session.id}' to Health Connect"
"Successfully synced ${records.size} records for session '${session.id}' to Health Connect" }
)
preferences preferences
.edit() .edit()
.putString("last_sync_success", DateFormatUtils.nowISO8601()) .putString("last_sync_success", DateFormatUtils.nowISO8601())
.apply() .apply()
} else { } else {
val reason = val reason =
when { when {
records.isEmpty() -> "No records created" records.isEmpty() -> "No records created"
healthConnectClient == null -> "Health Connect client unavailable" healthConnectClient == null -> "Health Connect client unavailable"
else -> "Unknown reason" 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")) return Result.failure(Exception("Sync failed: $reason"))
} }
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } 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) Result.failure(e)
} }
} }
/** Auto-sync a completed session if enabled - this is the only way to sync sessions */
suspend fun autoSyncCompletedSession( suspend fun autoSyncCompletedSession(
session: ClimbSession, session: ClimbSession,
gymName: String, gymName: String,
attemptCount: Int = 0 attemptCount: Int = 0
): Result<Unit> { ): Result<Unit> {
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) { 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) syncCompletedSession(session, gymName, attemptCount)
} else { } else {
val reason = val reason =
when { when {
session.status != SessionStatus.COMPLETED -> "session not completed" session.status != SessionStatus.COMPLETED -> "session not completed"
!_autoSync.value -> "auto-sync disabled" !_autoSync.value -> "auto-sync disabled"
!isReady() -> "Health Connect not ready" !isReady() -> "Health Connect not ready"
else -> "unknown reason" 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) Result.success(Unit)
} }
} }
/** Estimate calories burned during climbing */
private fun estimateCaloriesForClimbing(durationMinutes: Long, attemptCount: Int): Double { private fun estimateCaloriesForClimbing(durationMinutes: Long, attemptCount: Int): Double {
val baseCaloriesPerMinute = 8.0 val baseCaloriesPerMinute = 8.0
val intensityMultiplier = val intensityMultiplier =
when { when {
attemptCount >= 20 -> 1.3 attemptCount >= 20 -> 1.3
attemptCount >= 10 -> 1.1 attemptCount >= 10 -> 1.1
else -> 0.9 else -> 0.9
} }
return durationMinutes * baseCaloriesPerMinute * intensityMultiplier return durationMinutes * baseCaloriesPerMinute * intensityMultiplier
} }
/** Create heart rate data */
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
private fun createHeartRateRecord( private fun createHeartRateRecord(
startTime: Instant, startTime: Instant,
endTime: Instant, endTime: Instant,
attemptCount: Int attemptCount: Int
): HeartRateRecord? { ): HeartRateRecord? {
return try { return try {
val samples = mutableListOf<HeartRateRecord.Sample>() val samples = mutableListOf<HeartRateRecord.Sample>()
val intervalMinutes = 5L val intervalMinutes = 5L
val baseHeartRate = val baseHeartRate =
when { when {
attemptCount >= 20 -> 155L attemptCount >= 20 -> 155L
attemptCount >= 10 -> 145L attemptCount >= 10 -> 145L
else -> 135L else -> 135L
} }
var currentTime = startTime var currentTime = startTime
while (currentTime.isBefore(endTime)) { while (currentTime.isBefore(endTime)) {
@@ -335,21 +320,19 @@ class HealthConnectManager(private val context: Context) {
if (samples.isEmpty()) return null if (samples.isEmpty()) return null
HeartRateRecord( HeartRateRecord(
startTime = startTime, startTime = startTime,
startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime), startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime, endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
samples = samples samples = samples
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error creating heart rate record", e) AppLogger.e(TAG, e) { "Error creating heart rate record" }
null null
} }
} }
/** Check if ready for use */
fun isReadySync(): Boolean { fun isReadySync(): Boolean {
return _isEnabled.value && _hasPermissions.value return _isEnabled.value && _hasPermissions.value
} }
} }

View File

@@ -13,6 +13,7 @@ import com.atridad.ascently.data.format.DeletedItem
import com.atridad.ascently.data.model.* import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.state.DataStateManager import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ZipExportImportUtils import com.atridad.ascently.utils.ZipExportImportUtils
import java.io.File import java.io.File
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -26,7 +27,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
private val attemptDao = database.attemptDao() private val attemptDao = database.attemptDao()
private val dataStateManager = DataStateManager(context) private val dataStateManager = DataStateManager(context)
private val deletionPreferences: SharedPreferences = private val deletionPreferences: SharedPreferences =
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE) context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
private var autoSyncCallback: (() -> Unit)? = null private var autoSyncCallback: (() -> Unit)? = null
@@ -43,11 +44,13 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync() triggerAutoSync()
} }
suspend fun updateGym(gym: Gym) { suspend fun updateGym(gym: Gym) {
gymDao.updateGym(gym) gymDao.updateGym(gym)
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync() triggerAutoSync()
} }
suspend fun deleteGym(gym: Gym) { suspend fun deleteGym(gym: Gym) {
gymDao.deleteGym(gym) gymDao.deleteGym(gym)
trackDeletion(gym.id, "gym") trackDeletion(gym.id, "gym")
@@ -63,10 +66,12 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
problemDao.insertProblem(problem) problemDao.insertProblem(problem)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun updateProblem(problem: Problem) { suspend fun updateProblem(problem: Problem) {
problemDao.updateProblem(problem) problemDao.updateProblem(problem)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun deleteProblem(problem: Problem) { suspend fun deleteProblem(problem: Problem) {
problemDao.deleteProblem(problem) problemDao.deleteProblem(problem)
trackDeletion(problem.id, "problem") trackDeletion(problem.id, "problem")
@@ -77,7 +82,8 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions() fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId) sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow() fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
suspend fun insertSession(session: ClimbSession) { suspend fun insertSession(session: ClimbSession) {
@@ -88,6 +94,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync() triggerAutoSync()
} }
} }
suspend fun updateSession(session: ClimbSession) { suspend fun updateSession(session: ClimbSession) {
sessionDao.updateSession(session) sessionDao.updateSession(session)
dataStateManager.updateDataState() dataStateManager.updateDataState()
@@ -96,12 +103,14 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync() triggerAutoSync()
} }
} }
suspend fun deleteSession(session: ClimbSession) { suspend fun deleteSession(session: ClimbSession) {
sessionDao.deleteSession(session) sessionDao.deleteSession(session)
trackDeletion(session.id, "session") trackDeletion(session.id, "session")
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync() triggerAutoSync()
} }
suspend fun getLastUsedGym(): Gym? { suspend fun getLastUsedGym(): Gym? {
val recentSessions = sessionDao.getRecentSessions(1).first() val recentSessions = sessionDao.getRecentSessions(1).first()
return if (recentSessions.isNotEmpty()) { return if (recentSessions.isNotEmpty()) {
@@ -114,17 +123,21 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
// Attempt operations // Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts() fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId) attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsByProblem(problemId) attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) { suspend fun insertAttempt(attempt: Attempt) {
attemptDao.insertAttempt(attempt) attemptDao.insertAttempt(attempt)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun updateAttempt(attempt: Attempt) { suspend fun updateAttempt(attempt: Attempt) {
attemptDao.updateAttempt(attempt) attemptDao.updateAttempt(attempt)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun deleteAttempt(attempt: Attempt) { suspend fun deleteAttempt(attempt: Attempt) {
attemptDao.deleteAttempt(attempt) attemptDao.deleteAttempt(attempt)
trackDeletion(attempt.id, "attempt") trackDeletion(attempt.id, "attempt")
@@ -141,38 +154,38 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val backupData = val backupData =
ClimbDataBackup( ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(), exportedAt = DateFormatUtils.nowISO8601(),
version = "2.0", version = "2.0",
formatVersion = "2.0", formatVersion = "2.0",
gyms = allGyms.map { BackupGym.fromGym(it) }, gyms = allGyms.map { BackupGym.fromGym(it) },
problems = allProblems.map { BackupProblem.fromProblem(it) }, problems = allProblems.map { BackupProblem.fromProblem(it) },
sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) }, sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) } attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
) )
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths = val validImagePaths =
referencedImagePaths referencedImagePaths
.filter { imagePath -> .filter { imagePath ->
try { try {
val imageFile = val imageFile =
com.atridad.ascently.utils.ImageUtils.getImageFile( com.atridad.ascently.utils.ImageUtils.getImageFile(
context, context,
imagePath imagePath
) )
imageFile.exists() && imageFile.length() > 0 imageFile.exists() && imageFile.length() > 0
} catch (_: Exception) { } catch (_: Exception) {
false false
} }
} }
.toSet() .toSet()
ZipExportImportUtils.createExportZipToUri( ZipExportImportUtils.createExportZipToUri(
context = context, context = context,
uri = uri, uri = uri,
exportData = backupData, exportData = backupData,
referencedImagePaths = validImagePaths referencedImagePaths = validImagePaths
) )
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Export failed: ${e.message}") throw Exception("Export failed: ${e.message}")
@@ -192,11 +205,11 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
} }
val importData = val importData =
try { try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent) json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Invalid data format: ${e.message}") throw Exception("Invalid data format: ${e.message}")
} }
validateImportData(importData) validateImportData(importData)
@@ -214,17 +227,17 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
} }
val updatedBackupProblems = val updatedBackupProblems =
ZipExportImportUtils.updateProblemImagePaths( ZipExportImportUtils.updateProblemImagePaths(
importData.problems, importData.problems,
importResult.importedImagePaths importResult.importedImagePaths
) )
updatedBackupProblems.forEach { backupProblem -> updatedBackupProblems.forEach { backupProblem ->
try { try {
problemDao.insertProblem(backupProblem.toProblem()) problemDao.insertProblem(backupProblem.toProblem())
} catch (e: Exception) { } catch (e: Exception) {
throw Exception( throw Exception(
"Failed to import problem '${backupProblem.name}': ${e.message}" "Failed to import problem '${backupProblem.name}': ${e.message}"
) )
} }
} }
@@ -251,26 +264,18 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
} }
} }
/**
* Sets the callback for auto-sync functionality. This should be called by the SyncService to
* register itself for auto-sync triggers.
*/
fun setAutoSyncCallback(callback: (() -> Unit)?) { fun setAutoSyncCallback(callback: (() -> Unit)?) {
autoSyncCallback = callback autoSyncCallback = callback
} }
/**
* Triggers auto-sync if enabled. This is called after any data modification to keep data
* synchronized across devices automatically.
*/
private fun triggerAutoSync() { private fun triggerAutoSync() {
autoSyncCallback?.invoke() autoSyncCallback?.invoke()
} }
private fun trackDeletion(itemId: String, itemType: String) { fun trackDeletion(itemId: String, itemType: String) {
val currentDeletions = getDeletedItems().toMutableList() val currentDeletions = getDeletedItems().toMutableList()
val newDeletion = val newDeletion =
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601()) DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
currentDeletions.add(newDeletion) currentDeletions.add(newDeletion)
val json = json.encodeToString(newDeletion) val json = json.encodeToString(newDeletion)
@@ -300,23 +305,23 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
} }
private fun validateDataIntegrity( private fun validateDataIntegrity(
gyms: List<Gym>, gyms: List<Gym>,
problems: List<Problem>, problems: List<Problem>,
sessions: List<ClimbSession>, sessions: List<ClimbSession>,
attempts: List<Attempt> attempts: List<Attempt>
) { ) {
val gymIds = gyms.map { it.id }.toSet() val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds } val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) { if (invalidProblems.isNotEmpty()) {
throw Exception( throw Exception(
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms" "Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
) )
} }
val invalidSessions = sessions.filter { it.gymId !in gymIds } val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) { if (invalidSessions.isNotEmpty()) {
throw Exception( throw Exception(
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms" "Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
) )
} }
@@ -324,10 +329,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
val sessionIds = sessions.map { it.id }.toSet() val sessionIds = sessions.map { it.id }.toSet()
val invalidAttempts = val invalidAttempts =
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds } attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
if (invalidAttempts.isNotEmpty()) { if (invalidAttempts.isNotEmpty()) {
throw Exception( throw Exception(
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions" "Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
) )
} }
} }
@@ -342,9 +347,9 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
} }
if (importData.gyms.size > 1000 || if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 || importData.problems.size > 10000 ||
importData.sessions.size > 10000 || importData.sessions.size > 10000 ||
importData.attempts.size > 100000 importData.attempts.size > 100000
) { ) {
throw Exception("Import data is too large: possible corruption or malicious file") throw Exception("Import data is too large: possible corruption or malicious file")
} }
@@ -394,10 +399,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
if (imagesDir.exists() && imagesDir.isDirectory) { if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0 val deletedCount = imagesDir.listFiles()?.size ?: 0
imagesDir.deleteRecursively() imagesDir.deleteRecursively()
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files") AppLogger.i("ClimbRepository") { "Cleared $deletedCount image files" }
} }
} catch (e: Exception) { } 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.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
/** /**
@@ -20,13 +20,13 @@ class DataStateManager(context: Context) {
} }
private val prefs: SharedPreferences = private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
init { init {
if (!isInitialized()) { if (!isInitialized()) {
updateDataState() updateDataState()
markAsInitialized() 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() { fun updateDataState() {
val now = DateFormatUtils.nowISO8601() val now = DateFormatUtils.nowISO8601()
prefs.edit { putString(KEY_LAST_MODIFIED, now) } prefs.edit { putString(KEY_LAST_MODIFIED, now) }
Log.d(TAG, "Data state updated to: $now") AppLogger.d(TAG) { "Data state updated to: $now" }
} }
/** /**
@@ -46,7 +46,7 @@ class DataStateManager(context: Context) {
*/ */
fun getLastModified(): String { fun getLastModified(): String {
return prefs.getString(KEY_LAST_MODIFIED, DateFormatUtils.nowISO8601()) return prefs.getString(KEY_LAST_MODIFIED, DateFormatUtils.nowISO8601())
?: DateFormatUtils.nowISO8601() ?: DateFormatUtils.nowISO8601()
} }
/** Checks if the data state has been initialized. */ /** Checks if the data state has been initialized. */

View File

@@ -0,0 +1,30 @@
package com.atridad.ascently.data.sync
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.DeletedItem
import kotlinx.serialization.Serializable
/** Request structure for delta sync - sends only changes since last sync */
@Serializable
data class DeltaSyncRequest(
val lastSyncTime: String,
val gyms: List<BackupGym>,
val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>,
val deletedItems: List<DeletedItem>
)
/** Response structure for delta sync - receives only changes from server */
@Serializable
data class DeltaSyncResponse(
val serverTime: String,
val gyms: List<BackupGym>,
val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>,
val deletedItems: List<DeletedItem>
)

View File

@@ -4,10 +4,10 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.util.Log
import androidx.annotation.RequiresPermission import androidx.annotation.RequiresPermission
import androidx.core.content.edit import androidx.core.content.edit
import com.atridad.ascently.data.format.BackupAttempt import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.data.format.BackupClimbSession import com.atridad.ascently.data.format.BackupClimbSession
import com.atridad.ascently.data.format.BackupGym import com.atridad.ascently.data.format.BackupGym
import com.atridad.ascently.data.format.BackupProblem import com.atridad.ascently.data.format.BackupProblem
@@ -19,6 +19,9 @@ import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils import com.atridad.ascently.utils.ImageUtils
import java.io.IOException import java.io.IOException
import java.io.Serializable import java.io.Serializable
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -50,19 +53,20 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
private val sharedPreferences: SharedPreferences = private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE) context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
private val httpClient = private val httpClient =
OkHttpClient.Builder() OkHttpClient.Builder()
.connectTimeout(45, TimeUnit.SECONDS) .connectTimeout(45, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS) .readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS) .writeTimeout(90, TimeUnit.SECONDS)
.build() .build()
private val json = Json { private val json = Json {
prettyPrint = true prettyPrint = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
explicitNulls = false explicitNulls = false
coerceInputValues = true
} }
// State // State
@@ -147,7 +151,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
private fun isNetworkAvailable(): Boolean { private fun isNetworkAvailable(): Boolean {
val connectivityManager = val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false val network = connectivityManager.activeNetwork ?: return false
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
return when { return when {
@@ -160,12 +164,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
suspend fun syncWithServer() { suspend fun syncWithServer() {
if (isOfflineMode) { if (isOfflineMode) {
Log.d(TAG, "Sync skipped: Offline mode is enabled.") AppLogger.d(TAG) { "Sync skipped: Offline mode is enabled." }
return return
} }
if (!isNetworkAvailable()) { if (!isNetworkAvailable()) {
_syncError.value = "No internet connection." _syncError.value = "No internet connection."
Log.d(TAG, "Sync skipped: No internet connection.") AppLogger.d(TAG) { "Sync skipped: No internet connection." }
return return
} }
if (!_isConfigured.value) { if (!_isConfigured.value) {
@@ -184,37 +188,47 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val serverBackup = downloadData() val serverBackup = downloadData()
val hasLocalData = val hasLocalData =
localBackup.gyms.isNotEmpty() || localBackup.gyms.isNotEmpty() ||
localBackup.problems.isNotEmpty() || localBackup.problems.isNotEmpty() ||
localBackup.sessions.isNotEmpty() || localBackup.sessions.isNotEmpty() ||
localBackup.attempts.isNotEmpty() localBackup.attempts.isNotEmpty()
val hasServerData = val hasServerData =
serverBackup.gyms.isNotEmpty() || serverBackup.gyms.isNotEmpty() ||
serverBackup.problems.isNotEmpty() || serverBackup.problems.isNotEmpty() ||
serverBackup.sessions.isNotEmpty() || serverBackup.sessions.isNotEmpty() ||
serverBackup.attempts.isNotEmpty() serverBackup.attempts.isNotEmpty()
when { // If both client and server have been synced before, use delta sync
!hasLocalData && hasServerData -> { val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
Log.d(TAG, "No local data found, performing full restore from server") if (hasLocalData && hasServerData && lastSyncTimeStr != null) {
val imagePathMapping = syncImagesFromServer(serverBackup) AppLogger.d(TAG) { "Using delta sync for incremental updates" }
importBackupToRepository(serverBackup, imagePathMapping) performDeltaSync(lastSyncTimeStr)
Log.d(TAG, "Full restore completed") } else {
} when {
hasLocalData && !hasServerData -> { !hasLocalData && hasServerData -> {
Log.d(TAG, "No server data found, uploading local data to server") AppLogger.d(TAG) { "No local data found, performing full restore from server" }
uploadData(localBackup) val imagePathMapping = syncImagesFromServer(serverBackup)
syncImagesForBackup(localBackup) importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Initial upload completed") AppLogger.d(TAG) { "Full restore completed" }
} }
hasLocalData && hasServerData -> {
Log.d(TAG, "Both local and server data exist, merging (server wins)") hasLocalData && !hasServerData -> {
mergeDataSafely(serverBackup) AppLogger.d(TAG) { "No server data found, uploading local data to server" }
Log.d(TAG, "Merge completed") uploadData(localBackup)
} syncImagesForBackup(localBackup)
else -> { AppLogger.d(TAG) { "Initial upload completed" }
Log.d(TAG, "No data to sync") }
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" }
}
} }
} }
@@ -230,13 +244,295 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
private suspend fun performDeltaSync(lastSyncTimeStr: String) {
AppLogger.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
}
AppLogger.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")
}
}
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}"
}
// 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 {
// 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) }
} 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
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 { private suspend fun downloadData(): ClimbDataBackup {
val request = val request =
Request.Builder() Request.Builder()
.url("$serverUrl/sync") .url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken") .header("Authorization", "Bearer $authToken")
.get() .get()
.build() .build()
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
@@ -247,11 +543,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
json.decodeFromString(body) json.decodeFromString(body)
} else { } else {
ClimbDataBackup( ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(), exportedAt = DateFormatUtils.nowISO8601(),
gyms = emptyList(), gyms = emptyList(),
problems = emptyList(), problems = emptyList(),
sessions = emptyList(), sessions = emptyList(),
attempts = emptyList() attempts = emptyList()
) )
} }
} else { } else {
@@ -266,14 +562,14 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun uploadData(backup: ClimbDataBackup) { private suspend fun uploadData(backup: ClimbDataBackup) {
val requestBody = val requestBody =
json.encodeToString(backup).toRequestBody("application/json".toMediaType()) json.encodeToString(backup).toRequestBody("application/json".toMediaType())
val request = val request =
Request.Builder() Request.Builder()
.url("$serverUrl/sync") .url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken") .header("Authorization", "Bearer $authToken")
.post(requestBody) .put(requestBody)
.build() .build()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
@@ -291,7 +587,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> { private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> {
val imagePathMapping = mutableMapOf<String, String>() val imagePathMapping = mutableMapOf<String, String>()
val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 } val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 }
Log.d(TAG, "Starting image download from server for $totalImages images") AppLogger.d(TAG) { "Starting image download from server for $totalImages images" }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
backup.problems.forEach { problem -> backup.problems.forEach { problem ->
@@ -303,9 +599,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
imagePathMapping[imagePath] = localImagePath imagePathMapping[imagePath] = localImagePath
} }
} catch (_: SyncException.ImageNotFound) { } catch (_: SyncException.ImageNotFound) {
Log.w(TAG, "Image not found on server: $imagePath") AppLogger.w(TAG) { "Image not found on server: $imagePath" }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}") AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
} }
} }
} }
@@ -315,10 +611,10 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun downloadImage(serverFilename: String): String? { private suspend fun downloadImage(serverFilename: String): String? {
val request = val request =
Request.Builder() Request.Builder()
.url("$serverUrl/images/download?filename=$serverFilename") .url("$serverUrl/images/download?filename=$serverFilename")
.header("Authorization", "Bearer $authToken") .header("Authorization", "Bearer $authToken")
.build() .build()
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
@@ -333,14 +629,14 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Network error downloading image $serverFilename", e) AppLogger.e(TAG, e) { "Network error downloading image $serverFilename" }
null null
} }
} }
} }
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) { private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems") AppLogger.d(TAG) { "Starting image sync for backup with ${backup.problems.size} problems" }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
backup.problems.forEach { problem -> backup.problems.forEach { problem ->
problem.imagePaths?.forEach { localPath -> problem.imagePaths?.forEach { localPath ->
@@ -354,33 +650,32 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun uploadImage(localPath: String, filename: String) { private suspend fun uploadImage(localPath: String, filename: String) {
val file = ImageUtils.getImageFile(context, localPath) val file = ImageUtils.getImageFile(context, localPath)
if (!file.exists()) { if (!file.exists()) {
Log.w(TAG, "Local image file not found, cannot upload: $localPath") AppLogger.w(TAG) { "Local image file not found, cannot upload: $localPath" }
return return
} }
val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType()) val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType())
val request = val request =
Request.Builder() Request.Builder()
.url("$serverUrl/images/upload?filename=$filename") .url("$serverUrl/images/upload?filename=$filename")
.header("Authorization", "Bearer $authToken") .header("Authorization", "Bearer $authToken")
.post(requestBody) .post(requestBody)
.build() .build()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
httpClient.newCall(request).execute().use { response -> httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) { if (response.isSuccessful) {
Log.d(TAG, "Successfully uploaded image: $filename") AppLogger.d(TAG) { "Successfully uploaded image: $filename" }
} else { } else {
Log.w( AppLogger.w(TAG) {
TAG, "Failed to upload image $filename. Server responded with ${response.code}"
"Failed to upload image $filename. Server responded with ${response.code}" }
)
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Network error uploading image $filename", e) AppLogger.e(TAG, e) { "Network error uploading image $filename" }
} }
} }
} }
@@ -388,49 +683,49 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun createBackupFromRepository(): ClimbDataBackup { private suspend fun createBackupFromRepository(): ClimbDataBackup {
return withContext(Dispatchers.Default) { return withContext(Dispatchers.Default) {
ClimbDataBackup( ClimbDataBackup(
exportedAt = dataStateManager.getLastModified(), exportedAt = dataStateManager.getLastModified(),
gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) }, gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) },
problems = problems =
repository.getAllProblems().first().map { problem -> repository.getAllProblems().first().map { problem ->
val backupProblem = BackupProblem.fromProblem(problem) val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths = val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ -> problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename( ImageNamingUtils.generateImageFilename(
problem.id, problem.id,
index index
) )
} }
if (normalizedImagePaths.isNotEmpty()) { if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths) backupProblem.copy(imagePaths = normalizedImagePaths)
} else { } else {
backupProblem backupProblem
} }
}, },
sessions = sessions =
repository.getAllSessions().first().map { repository.getAllSessions().first().map {
BackupClimbSession.fromClimbSession(it) BackupClimbSession.fromClimbSession(it)
}, },
attempts = attempts =
repository.getAllAttempts().first().map { repository.getAllAttempts().first().map {
BackupAttempt.fromAttempt(it) BackupAttempt.fromAttempt(it)
}, },
deletedItems = repository.getDeletedItems() deletedItems = repository.getDeletedItems()
) )
} }
} }
private suspend fun importBackupToRepository( private suspend fun importBackupToRepository(
backup: ClimbDataBackup, backup: ClimbDataBackup,
imagePathMapping: Map<String, String> imagePathMapping: Map<String, String>
) { ) {
val gyms = backup.gyms.map { it.toGym() } val gyms = backup.gyms.map { it.toGym() }
val problems = val problems =
backup.problems.map { backupProblem -> backup.problems.map { backupProblem ->
val imagePaths = backupProblem.imagePaths val imagePaths = backupProblem.imagePaths
val updatedImagePaths = val updatedImagePaths =
imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath } imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath }
backupProblem.copy(imagePaths = updatedImagePaths).toProblem() backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
} }
val sessions = backup.sessions.map { it.toClimbSession() } val sessions = backup.sessions.map { it.toClimbSession() }
val attempts = backup.attempts.map { it.toAttempt() } val attempts = backup.attempts.map { it.toAttempt() }
@@ -445,7 +740,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) { private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) {
Log.d(TAG, "Server data will overwrite local data. Performing full restore.") AppLogger.d(TAG) { "Server data will overwrite local data. Performing full restore." }
val imagePathMapping = syncImagesFromServer(serverBackup) val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping) importBackupToRepository(serverBackup, imagePathMapping)
} }
@@ -468,11 +763,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
_syncError.value = null _syncError.value = null
val request = val request =
Request.Builder() Request.Builder()
.url("$serverUrl/sync") .url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken") .header("Authorization", "Bearer $authToken")
.head() .head()
.build() .build()
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
httpClient.newCall(request).execute().use { response -> httpClient.newCall(request).execute().use { response ->
@@ -501,18 +796,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
syncJob?.cancel() syncJob?.cancel()
syncJob = syncJob =
serviceScope.launch { serviceScope.launch {
delay(syncDebounceDelay) delay(syncDebounceDelay)
try { try {
syncWithServer() syncWithServer()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Auto-sync failed", e) AppLogger.e(TAG, e) { "Auto-sync failed" }
}
if (pendingChanges) {
pendingChanges = false
triggerAutoSync()
}
} }
if (pendingChanges) {
pendingChanges = false
triggerAutoSync()
}
}
} }
fun clearConfiguration() { fun clearConfiguration() {
@@ -530,7 +825,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
sealed class SyncException(message: String) : IOException(message), Serializable { sealed class SyncException(message: String) : IOException(message), Serializable {
object NotConfigured : object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.") SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.") object NotConnected : SyncException("Not connected to server. Please test connection first.")
@@ -540,6 +835,7 @@ sealed class SyncException(message: String) : IOException(message), Serializable
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code") data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) : data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details") SyncException("Invalid server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details") data class NetworkError(val details: String) : SyncException("Network error: $details")
} }

View File

@@ -12,10 +12,12 @@ import com.atridad.ascently.MainActivity
import com.atridad.ascently.R import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import kotlinx.coroutines.* import com.atridad.ascently.utils.AppLogger
import kotlinx.coroutines.flow.firstOrNull import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class SessionTrackingService : Service() { class SessionTrackingService : Service() {
@@ -28,6 +30,7 @@ class SessionTrackingService : Service() {
private lateinit var notificationManager: NotificationManager private lateinit var notificationManager: NotificationManager
companion object { companion object {
private const val LOG_TAG = "SessionTrackingService"
const val NOTIFICATION_ID = 1001 const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "session_tracking_channel" const val CHANNEL_ID = "session_tracking_channel"
const val ACTION_START_SESSION = "start_session" const val ACTION_START_SESSION = "start_session"
@@ -67,16 +70,24 @@ class SessionTrackingService : Service() {
startSessionTracking(sessionId) startSessionTracking(sessionId)
} }
} }
ACTION_STOP_SESSION -> { ACTION_STOP_SESSION -> {
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID) val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
serviceScope.launch { serviceScope.launch {
try { try {
val targetSession = when { val targetSession =
sessionId != null -> repository.getSessionById(sessionId) when {
else -> repository.getActiveSession() 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) repository.updateSession(completed)
} }
} finally { } finally {
@@ -97,45 +108,53 @@ class SessionTrackingService : Service() {
try { try {
createAndShowNotification(sessionId) createAndShowNotification(sessionId)
// Update widget when session tracking starts
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e(LOG_TAG, e) { "Failed to initialize session tracking notification" }
} }
notificationJob = serviceScope.launch { notificationJob =
try { serviceScope.launch {
if (!isNotificationActive()) { try {
delay(1000L)
createAndShowNotification(sessionId)
}
while (isActive) {
delay(5000L)
updateNotification(sessionId)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
monitoringJob = serviceScope.launch {
try {
while (isActive) {
delay(10000L)
if (!isNotificationActive()) { if (!isNotificationActive()) {
delay(1000L)
createAndShowNotification(sessionId)
}
while (isActive) {
delay(5000L)
updateNotification(sessionId) updateNotification(sessionId)
} }
} catch (e: Exception) {
val session = repository.getSessionById(sessionId) AppLogger.e(LOG_TAG, e) { "Notification updater loop crashed" }
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) { }
stopSessionTracking() }
break
} monitoringJob =
serviceScope.launch {
try {
while (isActive) {
delay(10000L)
if (!isNotificationActive()) {
updateNotification(sessionId)
}
val session = repository.getSessionById(sessionId)
if (session == null ||
session.status !=
com.atridad.ascently.data.model.SessionStatus
.ACTIVE
) {
stopSessionTracking()
break
}
}
} catch (e: Exception) {
AppLogger.e(LOG_TAG, e) { "Session monitoring loop crashed" }
} }
} catch (e: Exception) {
e.printStackTrace()
} }
}
} }
private fun stopSessionTracking() { private fun stopSessionTracking() {
@@ -143,6 +162,8 @@ class SessionTrackingService : Service() {
monitoringJob?.cancel() monitoringJob?.cancel()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
// Update widget when session tracking stops
ClimbStatsWidgetProvider.updateAllWidgets(this)
} }
private fun isNotificationActive(): Boolean { private fun isNotificationActive(): Boolean {
@@ -157,14 +178,16 @@ class SessionTrackingService : Service() {
private suspend fun updateNotification(sessionId: String) { private suspend fun updateNotification(sessionId: String) {
try { try {
createAndShowNotification(sessionId) createAndShowNotification(sessionId)
// Update widget when notification updates
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e(LOG_TAG, e) { "Failed to update notification; retrying in 10s" }
try { try {
delay(10000L) delay(10000L)
createAndShowNotification(sessionId) createAndShowNotification(sessionId)
} catch (retryException: Exception) { } catch (retryException: Exception) {
retryException.printStackTrace() AppLogger.e(LOG_TAG, retryException) { "Retrying notification update failed" }
stopSessionTracking() stopSessionTracking()
} }
} }
@@ -172,78 +195,81 @@ class SessionTrackingService : Service() {
private fun createAndShowNotification(sessionId: String) { private fun createAndShowNotification(sessionId: String) {
try { try {
val session = runBlocking { val session = runBlocking { repository.getSessionById(sessionId) }
repository.getSessionById(sessionId) if (session == null ||
} session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) { ) {
stopSessionTracking() stopSessionTracking()
return return
} }
val gym = runBlocking { val gym = runBlocking { repository.getGymById(session.gymId) }
repository.getGymById(session.gymId)
}
val attempts = runBlocking { val attempts = runBlocking {
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList() repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
} }
val duration = session.startTime?.let { startTime -> val duration =
try { session.startTime?.let { startTime ->
val start = LocalDateTime.parse(startTime) try {
val now = LocalDateTime.now() val start = LocalDateTime.parse(startTime)
val totalSeconds = ChronoUnit.SECONDS.between(start, now) val now = LocalDateTime.now()
val hours = totalSeconds / 3600 val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val minutes = (totalSeconds % 3600) / 60 val hours = totalSeconds / 3600
val seconds = totalSeconds % 60 val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when { when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s" hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s" minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s" else -> "${totalSeconds}s"
}
} catch (_: Exception) {
"Active"
} }
} catch (_: Exception) {
"Active"
} }
} ?: "Active" ?: "Active"
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification =
.setContentTitle("Climbing Session Active") NotificationCompat.Builder(this, CHANNEL_ID)
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts") .setContentTitle("Climbing Session Active")
.setSmallIcon(R.drawable.ic_mountains) .setContentText(
.setOngoing(true) "${gym?.name ?: "Gym"}$duration${attempts.size} attempts"
.setAutoCancel(false) )
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setSmallIcon(R.drawable.ic_mountains)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setAutoCancel(false)
.setContentIntent(createOpenAppIntent()) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.addAction( .setCategory(NotificationCompat.CATEGORY_SERVICE)
R.drawable.ic_mountains, .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
"Open Session", .setContentIntent(createOpenAppIntent())
createOpenAppIntent() .addAction(
) R.drawable.ic_mountains,
.addAction( "Open Session",
android.R.drawable.ic_menu_close_clear_cancel, createOpenAppIntent()
"End Session", )
createStopPendingIntent(sessionId) .addAction(
) android.R.drawable.ic_menu_close_clear_cancel,
.build() "End Session",
createStopPendingIntent(sessionId)
)
.build()
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e(LOG_TAG, e) { "Failed to build session tracking notification" }
throw e throw e
} }
} }
private fun createOpenAppIntent(): PendingIntent { private fun createOpenAppIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply { val intent =
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP Intent(this, MainActivity::class.java).apply {
action = "OPEN_SESSION" flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
} action = "OPEN_SESSION"
}
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, this,
0, 0,
@@ -263,18 +289,20 @@ class SessionTrackingService : Service() {
} }
private fun createNotificationChannel() { private fun createNotificationChannel() {
val channel = NotificationChannel( val channel =
CHANNEL_ID, NotificationChannel(
"Session Tracking", CHANNEL_ID,
NotificationManager.IMPORTANCE_DEFAULT "Session Tracking",
).apply { NotificationManager.IMPORTANCE_DEFAULT
description = "Shows active climbing session information" )
setShowBadge(false) .apply {
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC description = "Shows active climbing session information"
enableLights(false) setShowBadge(false)
enableVibration(false) lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
setSound(null, null) enableLights(false)
} enableVibration(false)
setSound(null, null)
}
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }

View File

@@ -26,15 +26,16 @@ import com.atridad.ascently.ui.components.NotificationPermissionDialog
import com.atridad.ascently.ui.screens.* import com.atridad.ascently.ui.screens.*
import com.atridad.ascently.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.AppShortcutManager import com.atridad.ascently.utils.AppShortcutManager
import com.atridad.ascently.utils.NotificationPermissionUtils import com.atridad.ascently.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AscentlyApp( fun AscentlyApp(
shortcutAction: String? = null, shortcutAction: String? = null,
lastUsedGymId: String? = null, lastUsedGymId: String? = null,
onShortcutActionProcessed: () -> Unit = {} onShortcutActionProcessed: () -> Unit = {}
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
@@ -45,26 +46,26 @@ fun AscentlyApp(
val repository = remember { ClimbRepository(database, context) } val repository = remember { ClimbRepository(database, context) }
val syncService = remember { SyncService(context, repository) } val syncService = remember { SyncService(context, repository) }
val viewModel: ClimbViewModel = val viewModel: ClimbViewModel =
viewModel(factory = ClimbViewModelFactory(repository, syncService, context)) viewModel(factory = ClimbViewModelFactory(repository, syncService, context))
var showNotificationPermissionDialog by remember { mutableStateOf(false) } var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) } var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
val permissionLauncher = val permissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean -> ) { isGranted: Boolean ->
if (!isGranted) { if (!isGranted) {
showNotificationPermissionDialog = false showNotificationPermissionDialog = false
}
} }
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) { if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true hasCheckedNotificationPermission = true
if (NotificationPermissionUtils.shouldRequestNotificationPermission() && if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context) !NotificationPermissionUtils.isNotificationPermissionGranted(context)
) { ) {
showNotificationPermissionDialog = true showNotificationPermissionDialog = true
} }
@@ -86,10 +87,10 @@ fun AscentlyApp(
LaunchedEffect(activeSession, gyms, lastUsedGym) { LaunchedEffect(activeSession, gyms, lastUsedGym) {
AppShortcutManager.updateShortcuts( AppShortcutManager.updateShortcuts(
context = context, context = context,
hasActiveSession = activeSession != null, hasActiveSession = activeSession != null,
hasGyms = gyms.isNotEmpty(), hasGyms = gyms.isNotEmpty(),
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
) )
} }
@@ -101,6 +102,7 @@ fun AscentlyApp(
launchSingleTop = true launchSingleTop = true
} }
} }
AppShortcutManager.ACTION_END_SESSION -> { AppShortcutManager.ACTION_END_SESSION -> {
navController.navigate(Screen.Sessions) { navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
@@ -114,51 +116,36 @@ fun AscentlyApp(
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) { LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) { if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}" }
"AscentlyApp",
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
)
if (activeSession == null) { if (activeSession == null) {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() && if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted( !NotificationPermissionUtils.isNotificationPermissionGranted(
context context
) )
) { ) {
android.util.Log.d("AscentlyApp", "Showing notification permission dialog") AppLogger.d("AscentlyApp") { "Showing notification permission dialog" }
showNotificationPermissionDialog = true showNotificationPermissionDialog = true
} else { } else {
if (gyms.size == 1) { if (gyms.size == 1) {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Starting session with single gym: ${gyms.first().name}" }
"AscentlyApp",
"Starting session with single gym: ${gyms.first().name}"
)
viewModel.startSession(context, gyms.first().id) viewModel.startSession(context, gyms.first().id)
} else { } else {
val targetGym = val targetGym =
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } } lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
?: lastUsedGym ?: lastUsedGym
if (targetGym != null) { if (targetGym != null) {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Starting session with target gym: ${targetGym.name}" }
"AscentlyApp",
"Starting session with target gym: ${targetGym.name}"
)
viewModel.startSession(context, targetGym.id) viewModel.startSession(context, targetGym.id)
} else { } else {
android.util.Log.d( AppLogger.d("AscentlyApp") { "No target gym found, navigating to selection" }
"AscentlyApp",
"No target gym found, navigating to selection"
)
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
} }
} }
} }
} else { } else {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Active session already exists: ${activeSession?.id}" }
"AscentlyApp",
"Active session already exists: ${activeSession?.id}"
)
} }
onShortcutActionProcessed() onShortcutActionProcessed()
@@ -168,79 +155,79 @@ fun AscentlyApp(
var fabConfig by remember { mutableStateOf<FabConfig?>(null) } var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
Scaffold( Scaffold(
bottomBar = { AscentlyBottomNavigation(navController = navController) }, bottomBar = { AscentlyBottomNavigation(navController = navController) },
floatingActionButton = { floatingActionButton = {
fabConfig?.let { config -> fabConfig?.let { config ->
FloatingActionButton( FloatingActionButton(
onClick = config.onClick, onClick = config.onClick,
containerColor = MaterialTheme.colorScheme.primary containerColor = MaterialTheme.colorScheme.primary
) { ) {
Icon( Icon(
imageVector = config.icon, imageVector = config.icon,
contentDescription = config.contentDescription contentDescription = config.contentDescription
) )
}
} }
} }
}
) { innerPadding -> ) { innerPadding ->
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Screen.Sessions, startDestination = Screen.Sessions,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) { ) {
composable<Screen.Sessions> { composable<Screen.Sessions> {
LaunchedEffect(gyms, activeSession) { LaunchedEffect(gyms, activeSession) {
fabConfig = fabConfig =
if (gyms.isNotEmpty() && activeSession == null) { if (gyms.isNotEmpty() && activeSession == null) {
FabConfig( FabConfig(
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
contentDescription = "Start Session", contentDescription = "Start Session",
onClick = { onClick = {
if (NotificationPermissionUtils if (NotificationPermissionUtils
.shouldRequestNotificationPermission() && .shouldRequestNotificationPermission() &&
!NotificationPermissionUtils !NotificationPermissionUtils
.isNotificationPermissionGranted( .isNotificationPermissionGranted(
context context
) )
) { ) {
showNotificationPermissionDialog = true showNotificationPermissionDialog = true
} else { } else {
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
} }
} }
) )
} else { } else {
null null
} }
} }
SessionsScreen( SessionsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToSessionDetail = { sessionId -> onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId)) navController.navigate(Screen.SessionDetail(sessionId))
} }
) )
} }
composable<Screen.Problems> { composable<Screen.Problems> {
LaunchedEffect(gyms) { LaunchedEffect(gyms) {
fabConfig = fabConfig =
if (gyms.isNotEmpty()) { if (gyms.isNotEmpty()) {
FabConfig( FabConfig(
icon = Icons.Default.Add, icon = Icons.Default.Add,
contentDescription = "Add Problem", contentDescription = "Add Problem",
onClick = { onClick = {
navController.navigate(Screen.AddEditProblem()) navController.navigate(Screen.AddEditProblem())
} }
) )
} else { } else {
null null
} }
} }
ProblemsScreen( ProblemsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToProblemDetail = { problemId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
@@ -252,17 +239,17 @@ fun AscentlyApp(
composable<Screen.Gyms> { composable<Screen.Gyms> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
fabConfig = fabConfig =
FabConfig( FabConfig(
icon = Icons.Default.Add, icon = Icons.Default.Add,
contentDescription = "Add Gym", contentDescription = "Add Gym",
onClick = { navController.navigate(Screen.AddEditGym()) } onClick = { navController.navigate(Screen.AddEditGym()) }
) )
} }
GymsScreen( GymsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToGymDetail = { gymId -> onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId)) navController.navigate(Screen.GymDetail(gymId))
} }
) )
} }
@@ -275,12 +262,12 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.SessionDetail>() val args = backStackEntry.toRoute<Screen.SessionDetail>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
SessionDetailScreen( SessionDetailScreen(
sessionId = args.sessionId, sessionId = args.sessionId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToProblemDetail = { problemId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
@@ -288,12 +275,12 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.ProblemDetail>() val args = backStackEntry.toRoute<Screen.ProblemDetail>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
ProblemDetailScreen( ProblemDetailScreen(
problemId = args.problemId, problemId = args.problemId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { problemId -> onNavigateToEdit = { problemId ->
navController.navigate(Screen.AddEditProblem(problemId = problemId)) navController.navigate(Screen.AddEditProblem(problemId = problemId))
} }
) )
} }
@@ -301,18 +288,18 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.GymDetail>() val args = backStackEntry.toRoute<Screen.GymDetail>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
GymDetailScreen( GymDetailScreen(
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId -> onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = gymId)) navController.navigate(Screen.AddEditGym(gymId = gymId))
}, },
onNavigateToSessionDetail = { sessionId -> onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId)) navController.navigate(Screen.SessionDetail(sessionId))
}, },
onNavigateToProblemDetail = { problemId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
@@ -320,9 +307,9 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditGym>() val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen( AddEditGymScreen(
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
@@ -330,10 +317,10 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditProblem>() val args = backStackEntry.toRoute<Screen.AddEditProblem>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
AddEditProblemScreen( AddEditProblemScreen(
problemId = args.problemId, problemId = args.problemId,
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
@@ -341,22 +328,22 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditSession>() val args = backStackEntry.toRoute<Screen.AddEditSession>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
AddEditSessionScreen( AddEditSessionScreen(
sessionId = args.sessionId, sessionId = args.sessionId,
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
} }
if (showNotificationPermissionDialog) { if (showNotificationPermissionDialog) {
NotificationPermissionDialog( NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false }, onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = { onRequestPermission = {
permissionLauncher.launch( permissionLauncher.launch(
NotificationPermissionUtils.getNotificationPermissionString() NotificationPermissionUtils.getNotificationPermissionString()
) )
} }
) )
} }
} }
@@ -370,34 +357,34 @@ fun AscentlyBottomNavigation(navController: NavHostController) {
NavigationBar { NavigationBar {
bottomNavigationItems.forEach { item -> bottomNavigationItems.forEach { item ->
val isSelected = val isSelected =
when (item.screen) { when (item.screen) {
is Screen.Sessions -> currentRoute?.contains("Session") == true is Screen.Sessions -> currentRoute?.contains("Session") == true
is Screen.Problems -> currentRoute?.contains("Problem") == true is Screen.Problems -> currentRoute?.contains("Problem") == true
is Screen.Gyms -> currentRoute?.contains("Gym") == true is Screen.Gyms -> currentRoute?.contains("Gym") == true
is Screen.Analytics -> currentRoute?.contains("Analytics") == true is Screen.Analytics -> currentRoute?.contains("Analytics") == true
is Screen.Settings -> currentRoute?.contains("Settings") == true is Screen.Settings -> currentRoute?.contains("Settings") == true
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
} }
NavigationBarItem( NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) }, icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) }, label = { Text(item.label) },
selected = isSelected, selected = isSelected,
onClick = { onClick = {
navController.navigate(item.screen) { navController.navigate(item.screen) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
launchSingleTop = true launchSingleTop = true
// Don't restore state - always start fresh when switching tabs // Don't restore state - always start fresh when switching tabs
restoreState = false restoreState = false
}
} }
}
) )
} }
} }
} }
data class FabConfig( data class FabConfig(
val icon: androidx.compose.ui.graphics.vector.ImageVector, val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String, val contentDescription: String,
val onClick: () -> Unit val onClick: () -> Unit
) )

View File

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

View File

@@ -1,16 +1,26 @@
package com.atridad.ascently.ui.screens package com.atridad.ascently.ui.screens
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -23,6 +33,16 @@ import com.atridad.ascently.ui.components.ActiveSessionBanner
import com.atridad.ascently.ui.components.SyncIndicator import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.Locale
enum class ViewMode {
LIST,
CALENDAR
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -33,7 +53,15 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
val activeSession by viewModel.activeSession.collectAsState() val activeSession by viewModel.activeSession.collectAsState()
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
// Filter out active sessions from regular session list val sharedPreferences =
context.getSharedPreferences("SessionsPreferences", Context.MODE_PRIVATE)
val savedViewMode = sharedPreferences.getString("view_mode", "LIST")
var viewMode by remember {
mutableStateOf(if (savedViewMode == "CALENDAR") ViewMode.CALENDAR else ViewMode.LIST)
}
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
var selectedDate by remember { mutableStateOf<LocalDate?>(null) }
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED } val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } } val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
@@ -55,12 +83,30 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
IconButton(
onClick = {
viewMode =
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
selectedDate = null
sharedPreferences.edit().putString("view_mode", viewMode.name).apply()
}
) {
Icon(
imageVector =
if (viewMode == ViewMode.LIST) Icons.Default.CalendarMonth
else Icons.AutoMirrored.Filled.List,
contentDescription =
if (viewMode == ViewMode.LIST) "Calendar View" else "List View",
tint = MaterialTheme.colorScheme.primary
)
}
SyncIndicator(isSyncing = viewModel.syncService.isSyncing) SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Active session banner
ActiveSessionBanner( ActiveSessionBanner(
activeSession = activeSession, activeSession = activeSession,
gym = activeSessionGym, gym = activeSessionGym,
@@ -83,20 +129,40 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
actionText = "" actionText = ""
) )
} else { } else {
LazyColumn { when (viewMode) {
items(completedSessions) { session -> ViewMode.LIST -> {
SessionCard( LazyColumn {
session = session, items(completedSessions) { session ->
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym", SessionCard(
onClick = { onNavigateToSessionDetail(session.id) } session = session,
gymName = gyms.find { it.id == session.gymId }?.name
?: "Unknown Gym",
onClick = { onNavigateToSessionDetail(session.id) }
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
ViewMode.CALENDAR -> {
CalendarView(
sessions = completedSessions,
gyms = gyms,
activeSession = activeSession,
activeSessionGym = activeSessionGym,
selectedMonth = selectedMonth,
onMonthChange = { selectedMonth = it },
selectedDate = selectedDate,
onDateSelected = { selectedDate = it },
onNavigateToSessionDetail = onNavigateToSessionDetail,
onEndSession = {
activeSession?.let { viewModel.endSession(context, it.id) }
}
) )
Spacer(modifier = Modifier.height(8.dp))
} }
} }
} }
} }
// Show UI state messages and errors
uiState.message?.let { message -> uiState.message?.let { message ->
LaunchedEffect(message) { LaunchedEffect(message) {
kotlinx.coroutines.delay(5000) kotlinx.coroutines.delay(5000)
@@ -245,6 +311,226 @@ fun EmptyStateMessage(
} }
} }
@Composable
fun CalendarView(
sessions: List<ClimbSession>,
gyms: List<com.atridad.ascently.data.model.Gym>,
activeSession: ClimbSession?,
activeSessionGym: com.atridad.ascently.data.model.Gym?,
selectedMonth: YearMonth,
onMonthChange: (YearMonth) -> Unit,
selectedDate: LocalDate?,
onDateSelected: (LocalDate?) -> Unit,
onNavigateToSessionDetail: (String) -> Unit,
onEndSession: () -> Unit
) {
val sessionsByDate =
remember(sessions) {
sessions.groupBy {
try {
java.time.Instant.parse(it.date)
.atZone(java.time.ZoneId.systemDefault())
.toLocalDate()
} catch (e: Exception) {
LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE)
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) {
Text("", style = MaterialTheme.typography.headlineMedium)
}
Text(
text =
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) {
Text("", style = MaterialTheme.typography.headlineMedium)
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
val today = LocalDate.now()
onMonthChange(YearMonth.from(today))
onDateSelected(today)
},
shape = RoundedCornerShape(50),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp)
) {
Text(
text = "Today",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat").forEach { day ->
Text(
text = day,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
val firstDayOfMonth = selectedMonth.atDay(1)
val daysInMonth = selectedMonth.lengthOfMonth()
val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7
val totalCells =
((firstDayOfWeek + daysInMonth) / 7.0).let {
if (it == it.toInt().toDouble()) it.toInt() * 7 else (it.toInt() + 1) * 7
}
LazyVerticalGrid(columns = GridCells.Fixed(7), modifier = Modifier.fillMaxWidth()) {
items(totalCells) { index ->
val dayNumber = index - firstDayOfWeek + 1
if (dayNumber in 1..daysInMonth) {
val date = selectedMonth.atDay(dayNumber)
val sessionsOnDate = sessionsByDate[date] ?: emptyList()
val isSelected = date == selectedDate
val isToday = date == LocalDate.now()
CalendarDay(
day = dayNumber,
hasSession = sessionsOnDate.isNotEmpty(),
isSelected = isSelected,
isToday = isToday,
onClick = {
if (sessionsOnDate.isNotEmpty()) {
onDateSelected(if (isSelected) null else date)
}
}
)
} else {
Spacer(modifier = Modifier.aspectRatio(1f))
}
}
}
if (selectedDate != null) {
val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList()
Spacer(modifier = Modifier.height(16.dp))
Text(
text =
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp)
)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(sessionsOnSelectedDate) { session ->
SessionCard(
session = session,
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
onClick = { onNavigateToSessionDetail(session.id) }
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
}
@Composable
fun CalendarDay(
day: Int,
hasSession: Boolean,
isSelected: Boolean,
isToday: Boolean,
onClick: () -> Unit
) {
Box(
modifier =
Modifier.aspectRatio(1f)
.padding(2.dp)
.clip(CircleShape)
.background(
when {
isSelected -> MaterialTheme.colorScheme.primaryContainer
isToday -> MaterialTheme.colorScheme.secondaryContainer
else -> Color.Transparent
}
)
.clickable(enabled = hasSession, onClick = onClick),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = day.toString(),
style = MaterialTheme.typography.bodyMedium,
color =
when {
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
isToday -> MaterialTheme.colorScheme.onSecondaryContainer
!hasSession -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurface
},
fontWeight = if (hasSession || isToday) FontWeight.Bold else FontWeight.Normal
)
if (hasSession) {
Box(
modifier =
Modifier.size(6.dp)
.clip(CircleShape)
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.primary.copy(
alpha = 0.7f
)
)
)
}
}
}
}
private fun formatDate(dateString: String): String { private fun formatDate(dateString: String): String {
return DateFormatUtils.formatDateForDisplay(dateString) return DateFormatUtils.formatDateForDisplay(dateString)
} }

View File

@@ -8,6 +8,7 @@ import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.sync.SyncService import com.atridad.ascently.data.sync.SyncService
import com.atridad.ascently.service.SessionTrackingService import com.atridad.ascently.service.SessionTrackingService
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ImageUtils import com.atridad.ascently.utils.ImageUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.io.File import java.io.File
@@ -15,9 +16,9 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ClimbViewModel( class ClimbViewModel(
private val repository: ClimbRepository, private val repository: ClimbRepository,
val syncService: SyncService, val syncService: SyncService,
private val context: Context private val context: Context
) : ViewModel() { ) : ViewModel() {
// Health Connect manager // Health Connect manager
@@ -29,49 +30,49 @@ class ClimbViewModel(
// Data flows // Data flows
val gyms = val gyms =
repository repository
.getAllGyms() .getAllGyms()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
val problems = val problems =
repository repository
.getAllProblems() .getAllProblems()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
val sessions = val sessions =
repository repository
.getAllSessions() .getAllSessions()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
val activeSession = val activeSession =
repository repository
.getActiveSessionFlow() .getActiveSessionFlow()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = null initialValue = null
) )
val attempts = val attempts =
repository repository
.getAllAttempts() .getAllAttempts()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(),
initialValue = emptyList() initialValue = emptyList()
) )
// Gym operations // Gym operations
fun addGym(gym: Gym, updateWidgets: Boolean = true) { fun addGym(gym: Gym, updateWidgets: Boolean = true) {
@@ -124,7 +125,7 @@ class ClimbViewModel(
problem.imagePaths.forEachIndexed { index, tempPath -> problem.imagePaths.forEachIndexed { index, tempPath ->
if (tempPath.startsWith("temp_")) { if (tempPath.startsWith("temp_")) {
val finalPath = val finalPath =
ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index) ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index)
finalImagePaths.add(finalPath ?: tempPath) finalImagePaths.add(finalPath ?: tempPath)
} else { } else {
finalImagePaths.add(tempPath) finalImagePaths.add(tempPath)
@@ -176,23 +177,23 @@ class ClimbViewModel(
val allProblems = repository.getAllProblems().first() val allProblems = repository.getAllProblems().first()
val updatedProblems = val updatedProblems =
allProblems.map { problem -> allProblems.map { problem ->
if (problem.imagePaths.isNotEmpty()) { if (problem.imagePaths.isNotEmpty()) {
problem.copy(imagePaths = emptyList()) problem.copy(imagePaths = emptyList())
} else { } else {
problem problem
}
} }
}
for (updatedProblem in updatedProblems) { for (updatedProblem in updatedProblems) {
if (updatedProblem.imagePaths != if (updatedProblem.imagePaths !=
allProblems.find { it.id == updatedProblem.id }?.imagePaths allProblems.find { it.id == updatedProblem.id }?.imagePaths
) { ) {
repository.insertProblemWithoutSync(updatedProblem) repository.insertProblemWithoutSync(updatedProblem)
} }
} }
println("Deleted $deletedCount image files and cleared image references") AppLogger.i("ClimbViewModel") { "Deleted $deletedCount image files and cleared image references" }
} }
} }
@@ -225,7 +226,7 @@ class ClimbViewModel(
} }
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
repository.getSessionsByGym(gymId) repository.getSessionsByGym(gymId)
// Get last used gym for shortcut functionality // Get last used gym for shortcut functionality
suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym() suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym()
@@ -233,41 +234,35 @@ class ClimbViewModel(
// Active session management // Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) { fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch { 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 if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context) .isNotificationPermissionGranted(context)
) { ) {
android.util.Log.d("ClimbViewModel", "Notification permission not granted") AppLogger.d("ClimbViewModel") { "Notification permission not granted" }
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
error = error =
"Notification permission is required to track your climbing session. Please enable notifications in settings." "Notification permission is required to track your climbing session. Please enable notifications in settings."
) )
return@launch return@launch
} }
val existingActive = repository.getActiveSession() val existingActive = repository.getActiveSession()
if (existingActive != null) { if (existingActive != null) {
android.util.Log.d( AppLogger.d("ClimbViewModel") { "Active session already exists: ${existingActive.id}" }
"ClimbViewModel",
"Active session already exists: ${existingActive.id}"
)
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
error = "There's already an active session. Please end it first." error = "There's already an active session. Please end it first."
) )
return@launch return@launch
} }
android.util.Log.d("ClimbViewModel", "Creating new session") AppLogger.d("ClimbViewModel") { "Creating new session" }
val newSession = ClimbSession.create(gymId = gymId, notes = notes) val newSession = ClimbSession.create(gymId = gymId, notes = notes)
repository.insertSession(newSession) repository.insertSession(newSession)
android.util.Log.d( AppLogger.d("ClimbViewModel") { "Starting tracking service for session: ${newSession.id}" }
"ClimbViewModel",
"Starting tracking service for session: ${newSession.id}"
)
// Start the tracking service // Start the tracking service
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id) val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
context.startForegroundService(serviceIntent) context.startForegroundService(serviceIntent)
@@ -281,13 +276,13 @@ class ClimbViewModel(
fun endSession(context: Context, sessionId: String) { fun endSession(context: Context, sessionId: String) {
viewModelScope.launch { viewModelScope.launch {
if (!com.atridad.ascently.utils.NotificationPermissionUtils if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context) .isNotificationPermissionGranted(context)
) { ) {
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
error = error =
"Notification permission is required to manage your climbing session. Please enable notifications in settings." "Notification permission is required to manage your climbing session. Please enable notifications in settings."
) )
return@launch return@launch
} }
@@ -313,7 +308,7 @@ class ClimbViewModel(
val activeSession = repository.getActiveSession() val activeSession = repository.getActiveSession()
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) { if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
val serviceIntent = val serviceIntent =
SessionTrackingService.createStartIntent(context, activeSession.id) SessionTrackingService.createStartIntent(context, activeSession.id)
context.startForegroundService(serviceIntent) context.startForegroundService(serviceIntent)
} }
} }
@@ -348,32 +343,32 @@ class ClimbViewModel(
} }
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId) repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
repository.getAttemptsByProblem(problemId) repository.getAttemptsByProblem(problemId)
fun exportDataToZipUri(context: Context, uri: android.net.Uri) { fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch { viewModelScope.launch {
try { try {
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = true, isLoading = true,
message = "Creating ZIP file with images..." message = "Creating ZIP file with images..."
) )
repository.exportAllDataToZipUri(context, uri) repository.exportAllDataToZipUri(context, uri)
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
message = message =
"Export complete! Your climbing data and images have been saved." "Export complete! Your climbing data and images have been saved."
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
error = "Export failed: ${e.message}" error = "Export failed: ${e.message}"
) )
} }
} }
} }
@@ -385,23 +380,23 @@ class ClimbViewModel(
if (!file.name.lowercase().endsWith(".zip")) { if (!file.name.lowercase().endsWith(".zip")) {
throw Exception( throw Exception(
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently." "Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
) )
} }
repository.importDataFromZip(file) repository.importDataFromZip(file)
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
message = "Data imported successfully from ${file.name}" message = "Data imported successfully from ${file.name}"
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
error = "Import failed: ${e.message}" error = "Import failed: ${e.message}"
) )
} }
} }
} }
@@ -448,13 +443,13 @@ class ClimbViewModel(
repository.resetAllData() repository.resetAllData()
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
message = "All data has been reset successfully" message = "All data has been reset successfully"
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value =
_uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}") _uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
} }
} }
} }
@@ -469,23 +464,20 @@ class ClimbViewModel(
val attemptCount = attempts.size val attemptCount = attempts.size
val result = val result =
healthConnectManager.autoSyncCompletedSession( healthConnectManager.autoSyncCompletedSession(
session, session,
gymName, gymName,
attemptCount attemptCount
) )
result.onFailure { error -> result.onFailure { error ->
if (healthConnectManager.isReadySync()) { if (healthConnectManager.isReadySync()) {
android.util.Log.w( AppLogger.w("ClimbViewModel") { "Health Connect sync failed: ${error.message}" }
"ClimbViewModel",
"Health Connect sync failed: ${error.message}"
)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
if (healthConnectManager.isReadySync()) { if (healthConnectManager.isReadySync()) {
android.util.Log.w("ClimbViewModel", "Health Connect sync error: ${e.message}") AppLogger.w("ClimbViewModel") { "Health Connect sync error: ${e.message}" }
} }
} }
} }
@@ -493,7 +485,7 @@ class ClimbViewModel(
} }
data class ClimbUiState( data class ClimbUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val message: String? = null, val message: String? = null,
val error: String? = null val error: String? = null
) )

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.BitmapFactory
import android.graphics.ImageDecoder import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.core.graphics.scale import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import java.io.File import java.io.File
@@ -30,17 +29,17 @@ object ImageUtils {
/** Saves an image from a URI while preserving EXIF orientation data */ /** Saves an image from a URI while preserving EXIF orientation data */
private fun saveImageWithExif( private fun saveImageWithExif(
context: Context, context: Context,
imageUri: Uri, imageUri: Uri,
originalBitmap: Bitmap, originalBitmap: Bitmap,
outputFile: File outputFile: File
): Boolean { ): Boolean {
return try { return try {
// Get EXIF data from original image // Get EXIF data from original image
val originalExif = val originalExif =
context.contentResolver.openInputStream(imageUri)?.use { input -> context.contentResolver.openInputStream(imageUri)?.use { input ->
ExifInterface(input) ExifInterface(input)
} }
// Compress and save the bitmap // Compress and save the bitmap
val compressedBitmap = compressImage(originalBitmap) val compressedBitmap = compressImage(originalBitmap)
@@ -73,7 +72,7 @@ object ImageUtils {
compressedBitmap.recycle() compressedBitmap.recycle()
true true
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Error saving image with EXIF data" }
false false
} }
} }
@@ -86,11 +85,11 @@ object ImageUtils {
// Calculate the scaling factor // Calculate the scaling factor
val scaleFactor = val scaleFactor =
if (width > height) { if (width > height) {
if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f
} else { } else {
if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f
} }
return if (scaleFactor < 1f) { return if (scaleFactor < 1f) {
val newWidth = (width * scaleFactor).toInt() val newWidth = (width * scaleFactor).toInt()
@@ -119,7 +118,7 @@ object ImageUtils {
val file = getImageFile(context, relativePath) val file = getImageFile(context, relativePath)
file.delete() file.delete()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to delete image: $relativePath" }
false false
} }
} }
@@ -137,7 +136,7 @@ object ImageUtils {
sourceFile.copyTo(destFile, overwrite = true) sourceFile.copyTo(destFile, overwrite = true)
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to import image from source: ${sourceFile.name}" }
null null
} }
} }
@@ -148,16 +147,16 @@ object ImageUtils {
val imagesDir = getImagesDirectory(context) val imagesDir = getImagesDirectory(context)
imagesDir.listFiles()?.mapNotNull { file -> imagesDir.listFiles()?.mapNotNull { file ->
if (file.isFile && if (file.isFile &&
(file.extension == "jpg" || (file.extension == "jpg" ||
file.extension == "jpeg" || file.extension == "jpeg" ||
file.extension == "png") file.extension == "png")
) { ) {
"$IMAGES_DIR/${file.name}" "$IMAGES_DIR/${file.name}"
} else null } else null
} }
?: emptyList() ?: emptyList()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to enumerate images directory" }
emptyList() emptyList()
} }
} }
@@ -178,50 +177,47 @@ object ImageUtils {
tempFilename tempFilename
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ImageUtils", "Error saving temporary image from URI", e) AppLogger.e("ImageUtils", e) { "Error saving temporary image from URI" }
null null
} }
} }
/** Renames a temporary image */ /** Renames a temporary image */
fun renameTemporaryImage( fun renameTemporaryImage(
context: Context, context: Context,
tempFilename: String, tempFilename: String,
problemId: String, problemId: String,
imageIndex: Int imageIndex: Int
): String? { ): String? {
return try { return try {
val tempFile = File(getImagesDirectory(context), tempFilename) val tempFile = File(getImagesDirectory(context), tempFilename)
if (!tempFile.exists()) { if (!tempFile.exists()) {
Log.e("ImageUtils", "Temporary file does not exist: $tempFilename") AppLogger.e("ImageUtils") { "Temporary file does not exist: $tempFilename" }
return null return null
} }
val deterministicFilename = val deterministicFilename =
ImageNamingUtils.generateImageFilename(problemId, imageIndex) ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val finalFile = File(getImagesDirectory(context), deterministicFilename) val finalFile = File(getImagesDirectory(context), deterministicFilename)
if (tempFile.renameTo(finalFile)) { if (tempFile.renameTo(finalFile)) {
Log.d( AppLogger.d("ImageUtils") { "Renamed temporary image: $tempFilename -> $deterministicFilename" }
"ImageUtils",
"Renamed temporary image: $tempFilename -> $deterministicFilename"
)
deterministicFilename deterministicFilename
} else { } else {
Log.e("ImageUtils", "Failed to rename temporary image: $tempFilename") AppLogger.e("ImageUtils") { "Failed to rename temporary image: $tempFilename" }
null null
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ImageUtils", "Error renaming temporary image", e) AppLogger.e("ImageUtils", e) { "Error renaming temporary image" }
null null
} }
} }
/** Saves image data with a specific filename */ /** Saves image data with a specific filename */
fun saveImageFromBytesWithFilename( fun saveImageFromBytesWithFilename(
context: Context, context: Context,
imageData: ByteArray, imageData: ByteArray,
filename: String filename: String
): String? { ): String? {
return try { return try {
val imageFile = File(getImagesDirectory(context), filename) val imageFile = File(getImagesDirectory(context), filename)
@@ -230,7 +226,7 @@ object ImageUtils {
if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold
// For large images, decode, compress, and try to preserve EXIF // For large images, decode, compress, and try to preserve EXIF
val bitmap = val bitmap =
BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap) val compressedBitmap = compressImage(bitmap)
// Save compressed image // Save compressed image
@@ -249,7 +245,7 @@ object ImageUtils {
destExif.saveAttributes() destExif.saveAttributes()
} catch (e: Exception) { } catch (e: Exception) {
// If EXIF preservation fails, continue without it // 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() bitmap.recycle()
@@ -262,7 +258,7 @@ object ImageUtils {
// Return relative path // Return relative path
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to save image from bytes: $filename" }
null null
} }
} }
@@ -275,7 +271,7 @@ object ImageUtils {
orphanedImages.forEach { path -> deleteImage(context, path) } orphanedImages.forEach { path -> deleteImage(context, path) }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to clean up orphaned images" }
} }
} }
} }

View File

@@ -2,13 +2,8 @@ package com.atridad.ascently.utils
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
/**
* Handles migration of data from OpenClimb to Ascently This includes SharedPreferences, database
* names, and other local storage
*/
class MigrationManager(private val context: Context) { class MigrationManager(private val context: Context) {
companion object { companion object {
@@ -18,7 +13,7 @@ class MigrationManager(private val context: Context) {
} }
private val migrationPrefs: SharedPreferences = private val migrationPrefs: SharedPreferences =
context.getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE)
/** /**
* Perform migration from OpenClimb to Ascently if needed This should be called early in app * Perform migration from OpenClimb to Ascently if needed This should be called early in app
@@ -26,11 +21,11 @@ class MigrationManager(private val context: Context) {
*/ */
fun migrateIfNeeded() { fun migrateIfNeeded() {
if (migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) { if (migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) {
Log.d(TAG, "Migration already completed, skipping") AppLogger.d(TAG) { "Migration already completed, skipping" }
return return
} }
Log.i(TAG, "🔄 Starting migration from OpenClimb to Ascently...") AppLogger.i(TAG) { "🔄 Starting migration from OpenClimb to Ascently..." }
var migrationCount = 0 var migrationCount = 0
// Migrate SharedPreferences // Migrate SharedPreferences
@@ -40,12 +35,9 @@ class MigrationManager(private val context: Context) {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, true) } migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, true) }
if (migrationCount > 0) { if (migrationCount > 0) {
Log.i( AppLogger.i(TAG) { "🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently" }
TAG,
"🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently"
)
} else { } else {
Log.i(TAG, " No OpenClimb data found to migrate") AppLogger.i(TAG) { " No OpenClimb data found to migrate" }
} }
} }
@@ -55,12 +47,12 @@ class MigrationManager(private val context: Context) {
// Define preference file migrations // Define preference file migrations
val preferenceFileMigrations = val preferenceFileMigrations =
listOf( listOf(
"openclimb_data_state" to "ascently_data_state", "openclimb_data_state" to "ascently_data_state",
"health_connect_prefs" to "health_connect_prefs", // Keep same name "health_connect_prefs" to "health_connect_prefs", // Keep same name
"deleted_items" to "deleted_items", // Keep same name "deleted_items" to "deleted_items", // Keep same name
"sync_preferences" to "sync_preferences" // Keep same name "sync_preferences" to "sync_preferences" // Keep same name
) )
for ((oldFileName, newFileName) in preferenceFileMigrations) { for ((oldFileName, newFileName) in preferenceFileMigrations) {
if (oldFileName != newFileName) { if (oldFileName != newFileName) {
@@ -99,10 +91,7 @@ class MigrationManager(private val context: Context) {
// Clear old preferences // Clear old preferences
oldPrefs.edit { clear() } oldPrefs.edit { clear() }
Log.d( AppLogger.d(TAG) { "Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)" }
TAG,
"✅ Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)"
)
return oldPrefs.all.size return oldPrefs.all.size
} }
@@ -115,12 +104,12 @@ class MigrationManager(private val context: Context) {
// Check for any openclimb-prefixed keys across all preference files // Check for any openclimb-prefixed keys across all preference files
val preferencesToCheck = val preferencesToCheck =
listOf( listOf(
"ascently_data_state", "ascently_data_state",
"health_connect_prefs", "health_connect_prefs",
"deleted_items", "deleted_items",
"sync_preferences" "sync_preferences"
) )
for (prefFileName in preferencesToCheck) { for (prefFileName in preferencesToCheck) {
val prefs = context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
@@ -154,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 count += keysToMigrate.size
} }
} }
@@ -170,6 +159,6 @@ class MigrationManager(private val context: Context) {
/** Reset migration state (for testing purposes) */ /** Reset migration state (for testing purposes) */
fun resetMigrationState() { fun resetMigrationState() {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, false) } migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, false) }
Log.d(TAG, "Migration state reset") AppLogger.d(TAG) { "Migration state reset" }
} }
} }

View File

@@ -22,19 +22,19 @@ object ZipExportImportUtils {
/** Creates a ZIP file containing the JSON data and all referenced images */ /** Creates a ZIP file containing the JSON data and all referenced images */
fun createExportZip( fun createExportZip(
context: Context, context: Context,
exportData: ClimbDataBackup, exportData: ClimbDataBackup,
referencedImagePaths: Set<String>, referencedImagePaths: Set<String>,
directory: File? = null directory: File? = null
): File { ): File {
val exportDir = val exportDir =
directory directory
?: File( ?: File(
context.getExternalFilesDir( context.getExternalFilesDir(
android.os.Environment.DIRECTORY_DOCUMENTS android.os.Environment.DIRECTORY_DOCUMENTS
), ),
"Ascently" "Ascently"
) )
if (!exportDir.exists()) { if (!exportDir.exists()) {
exportDir.mkdirs() exportDir.mkdirs()
} }
@@ -52,10 +52,11 @@ object ZipExportImportUtils {
zipOut.closeEntry() zipOut.closeEntry()
// Add JSON data file // Add JSON data file
val json = Json { val json =
prettyPrint = true Json {
ignoreUnknownKeys = true prettyPrint = true
} ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(exportData) val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME) val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
@@ -78,24 +79,21 @@ object ZipExportImportUtils {
zipOut.closeEntry() zipOut.closeEntry()
successfulImages++ successfulImages++
} else { } else {
android.util.Log.w( AppLogger.w("ZipExportImportUtils") {
"ZipExportImportUtils", "Image file not found or empty: $imagePath"
"Image file not found or empty: $imagePath" }
)
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e( AppLogger.e("ZipExportImportUtils", e) {
"ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}"
"Failed to add image $imagePath: ${e.message}" }
)
} }
} }
// Log export summary // Log export summary
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils", "Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included" }
)
} }
// Validate the created ZIP file // Validate the created ZIP file
@@ -115,10 +113,10 @@ object ZipExportImportUtils {
/** Creates a ZIP file and writes it to a provided URI */ /** Creates a ZIP file and writes it to a provided URI */
fun createExportZipToUri( fun createExportZipToUri(
context: Context, context: Context,
uri: android.net.Uri, uri: android.net.Uri,
exportData: ClimbDataBackup, exportData: ClimbDataBackup,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>
) { ) {
try { try {
context.contentResolver.openOutputStream(uri)?.use { outputStream -> context.contentResolver.openOutputStream(uri)?.use { outputStream ->
@@ -131,10 +129,11 @@ object ZipExportImportUtils {
zipOut.closeEntry() zipOut.closeEntry()
// Add JSON data file // Add JSON data file
val json = Json { val json =
prettyPrint = true Json {
ignoreUnknownKeys = true prettyPrint = true
} ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(exportData) val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME) val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
@@ -158,28 +157,26 @@ object ZipExportImportUtils {
successfulImages++ successfulImages++
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e( AppLogger.e("ZipExportImportUtils", e) {
"ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}"
"Failed to add image $imagePath: ${e.message}" }
)
} }
} }
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils", "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included"
"Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included" }
)
} }
} }
?: throw IOException("Could not open output stream") ?: throw IOException("Could not open output stream")
} catch (e: Exception) { } catch (e: Exception) {
throw IOException("Failed to create export ZIP to URI: ${e.message}") throw IOException("Failed to create export ZIP to URI: ${e.message}")
} }
} }
private fun createMetadata( private fun createMetadata(
exportData: ClimbDataBackup, exportData: ClimbDataBackup,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>
): String { ): String {
return buildString { return buildString {
appendLine("Ascently Export Metadata") appendLine("Ascently Export Metadata")
@@ -197,8 +194,8 @@ object ZipExportImportUtils {
/** Data class to hold extraction results */ /** Data class to hold extraction results */
data class ImportResult( data class ImportResult(
val jsonContent: String, val jsonContent: String,
val importedImagePaths: Map<String, String> // original filename -> new relative path val importedImagePaths: Map<String, String> // original filename -> new relative path
) )
/** Extracts a ZIP file and returns the JSON content and imported image paths */ /** Extracts a ZIP file and returns the JSON content and imported image paths */
@@ -217,16 +214,17 @@ object ZipExportImportUtils {
// Read metadata for validation // Read metadata for validation
val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8) val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("metadata") foundRequiredFiles.add("metadata")
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils", "Found metadata: ${metadataContent.lines().take(3).joinToString()}"
"Found metadata: ${metadataContent.lines().take(3).joinToString()}" }
)
} }
entry.name == DATA_JSON_FILENAME -> { entry.name == DATA_JSON_FILENAME -> {
// Read JSON data // Read JSON data
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8) jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("data") foundRequiredFiles.add("data")
} }
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> { entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
// Extract image file // Extract image file
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/") val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
@@ -234,11 +232,11 @@ object ZipExportImportUtils {
try { try {
// Create temporary file to hold the extracted image // Create temporary file to hold the extracted image
val tempFile = val tempFile =
File.createTempFile( File.createTempFile(
"import_image_", "import_image_",
"_$originalFilename", "_$originalFilename",
context.cacheDir context.cacheDir
) )
FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) } FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) }
@@ -248,37 +246,33 @@ object ZipExportImportUtils {
val newPath = ImageUtils.importImageFile(context, tempFile) val newPath = ImageUtils.importImageFile(context, tempFile)
if (newPath != null) { if (newPath != null) {
importedImagePaths[originalFilename] = newPath importedImagePaths[originalFilename] = newPath
android.util.Log.d( AppLogger.d("ZipExportImportUtils") {
"ZipExportImportUtils", "Successfully imported image: $originalFilename -> $newPath"
"Successfully imported image: $originalFilename -> $newPath" }
)
} else { } else {
android.util.Log.w( AppLogger.w("ZipExportImportUtils") {
"ZipExportImportUtils", "Failed to import image: $originalFilename"
"Failed to import image: $originalFilename" }
)
} }
} else { } else {
android.util.Log.w( AppLogger.w("ZipExportImportUtils") {
"ZipExportImportUtils", "Extracted image is empty: $originalFilename"
"Extracted image is empty: $originalFilename" }
)
} }
// Clean up temp file // Clean up temp file
tempFile.delete() tempFile.delete()
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e( AppLogger.e("ZipExportImportUtils", e) {
"ZipExportImportUtils", "Failed to process image $originalFilename: ${e.message}"
"Failed to process image $originalFilename: ${e.message}" }
)
} }
} }
else -> { else -> {
android.util.Log.d( AppLogger.d("ZipExportImportUtils") {
"ZipExportImportUtils", "Skipping ZIP entry: ${entry.name}"
"Skipping ZIP entry: ${entry.name}" }
)
} }
} }
@@ -296,10 +290,9 @@ object ZipExportImportUtils {
throw IOException("Invalid ZIP file: data.json is empty") throw IOException("Invalid ZIP file: data.json is empty")
} }
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils", "Import extraction completed: ${importedImagePaths.size} images processed"
"Import extraction completed: ${importedImagePaths.size} images processed" }
)
return ImportResult(jsonContent, importedImagePaths) return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) { } catch (e: Exception) {
@@ -312,16 +305,16 @@ object ZipExportImportUtils {
* the new ones after import * the new ones after import
*/ */
fun updateProblemImagePaths( fun updateProblemImagePaths(
problems: List<BackupProblem>, problems: List<BackupProblem>,
imagePathMapping: Map<String, String> imagePathMapping: Map<String, String>
): List<BackupProblem> { ): List<BackupProblem> {
return problems.map { problem -> return problems.map { problem ->
val updatedImagePaths = val updatedImagePaths =
(problem.imagePaths ?: emptyList()).mapNotNull { oldPath -> (problem.imagePaths ?: emptyList()).mapNotNull { oldPath ->
// Extract filename from the old path // Extract filename from the old path
val filename = oldPath.substringAfterLast("/") val filename = oldPath.substringAfterLast("/")
imagePathMapping[filename] imagePathMapping[filename]
} }
problem.withUpdatedImagePaths(updatedImagePaths) problem.withUpdatedImagePaths(updatedImagePaths)
} }
} }

View File

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

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:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path android:fillColor="#FFC107" android:pathData="M24.000,78.545 L41.851,38.380 L59.702,78.545 Z" />
<group <path android:fillColor="#F44336" android:pathData="M39.372,78.545 L61.686,29.455 L84.000,78.545 Z" />
android:scaleX="0.7"
android:scaleY="0.7"
android:translateX="16.2"
android:translateY="20">
<!-- Left mountain (yellow/amber) -->
<path
android:fillColor="#FFC107"
android:strokeColor="#1C1C1C"
android:strokeWidth="3"
android:strokeLineJoin="round"
android:pathData="M15,70 L35,25 L55,70 Z" />
<!-- Right mountain (red) -->
<path
android:fillColor="#F44336"
android:strokeColor="#1C1C1C"
android:strokeWidth="3"
android:strokeLineJoin="round"
android:pathData="M40,70 L65,15 L90,70 Z" />
</group>
</vector> </vector>

View File

@@ -4,29 +4,6 @@
android:height="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path android:fillColor="#FFC107" android:pathData="M2.000,20.182 L7.950,6.793 L13.901,20.182 Z" />
<!-- Left mountain (yellow/amber) --> <path android:fillColor="#F44336" android:pathData="M7.124,20.182 L14.562,3.818 L22.000,20.182 Z" />
<path
android:fillColor="#FFC107"
android:pathData="M3,18 L8,9 L13,18 Z" />
<!-- Right mountain (red) -->
<path
android:fillColor="#F44336"
android:pathData="M11,18 L16,7 L21,18 Z" />
<!-- Black outlines -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#1C1C1C"
android:strokeWidth="1"
android:strokeLineJoin="round"
android:pathData="M3,18 L8,9 L13,18" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#1C1C1C"
android:strokeWidth="1"
android:strokeLineJoin="round"
android:pathData="M11,18 L16,7 L21,18" />
</vector> </vector>

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:layout_height="match_parent"
android:background="@drawable/widget_background" android:background="@drawable/widget_background"
android:orientation="vertical" android:orientation="vertical"
android:padding="12dp"> android:padding="12dp"
android:gravity="center">
<!-- Header --> <!-- Header with icon and "Weekly" text -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp"> android:layout_marginBottom="12dp">
<ImageView <ImageView
android:layout_width="24dp" android:layout_width="28dp"
android:layout_height="24dp" android:layout_height="28dp"
android:src="@drawable/ic_mountains" android:src="@drawable/ic_mountains"
android:tint="@color/widget_primary" android:tint="@color/widget_primary"
android:layout_marginEnd="8dp" /> android:layout_marginEnd="8dp"
android:contentDescription="@string/ascently_icon" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Ascently"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Climbing Stats" android:text="@string/weekly"
android:textSize="12sp" android:textSize="18sp"
android:textColor="@color/widget_text_secondary" /> android:textColor="@color/widget_text_primary" />
</LinearLayout> </LinearLayout>
<!-- Stats Grid --> <!-- Attempts Row -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="wrap_content"
android:layout_weight="1" android:orientation="horizontal"
android:orientation="vertical" android:gravity="center_vertical"
android:gravity="center"> android:layout_marginBottom="12dp">
<!-- Top Row --> <ImageView
<LinearLayout android:layout_width="32dp"
android:layout_width="match_parent" android:layout_height="32dp"
android:layout_height="0dp" android:src="@drawable/ic_circle_filled"
android:layout_weight="1" android:tint="@color/widget_primary"
android:orientation="horizontal" android:layout_marginEnd="12dp"
android:layout_marginBottom="8dp"> android:contentDescription="Attempts icon" />
<!-- Sessions Card --> <TextView
<LinearLayout android:id="@+id/widget_attempts_value"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" android:text="0"
android:orientation="vertical" android:textSize="40sp"
android:gravity="center" android:textStyle="bold"
android:background="@drawable/widget_stat_card_background" android:textColor="@color/widget_text_primary" />
android:layout_marginEnd="4dp"
android:padding="12dp">
<TextView </LinearLayout>
android:id="@+id/widget_total_sessions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_primary" />
<TextView <!-- Sessions Row -->
android:layout_width="wrap_content" <LinearLayout
android:layout_height="wrap_content" android:layout_width="match_parent"
android:text="Sessions" android:layout_height="wrap_content"
android:textSize="12sp" android:orientation="horizontal"
android:textColor="@color/widget_text_secondary" android:gravity="center_vertical">
android:layout_marginTop="2dp" />
</LinearLayout> <ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_play_arrow_24"
android:tint="@color/widget_primary"
android:layout_marginEnd="12dp"
android:contentDescription="@string/sessions_icon" />
<!-- Problems Card --> <TextView
<LinearLayout android:id="@+id/widget_sessions_value"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" android:text="@string/_0"
android:orientation="vertical" android:textSize="40sp"
android:gravity="center" android:textStyle="bold"
android:background="@drawable/widget_stat_card_background" android:textColor="@color/widget_text_primary" />
android:layout_marginStart="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_problems_completed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Completed"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
<!-- Bottom Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<!-- Success Rate Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginEnd="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_total_problems"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_secondary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Problems"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
<!-- Favorite Gym Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginStart="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_favorite_gym"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="No gyms"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="@color/widget_accent"
android:gravity="center"
android:maxLines="2"
android:ellipsize="end" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Favorite"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout> </LinearLayout>

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> <string name="shortcut_end_session_disabled">No active session to end</string>
<!-- Widget --> <!-- Widget -->
<string name="widget_description">View your climbing stats at a glance</string> <string name="widget_description">View your weekly climbing stats</string>
<string name="ascently_icon">Ascently icon</string>
<string name="weekly">Weekly</string>
<string name="sessions_icon">Sessions icon</string>
<string name="_0">0</string>
</resources> </resources>

View File

@@ -4,7 +4,7 @@
<style name="Theme.Ascently.Splash" parent="Theme.Ascently"> <style name="Theme.Ascently.Splash" parent="Theme.Ascently">
<item name="android:windowSplashScreenBackground">@color/splash_background</item> <item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
<item name="android:windowSplashScreenAnimationDuration">200</item> <item name="android:windowSplashScreenAnimationDuration">200</item>
</style> </style>
</resources> </resources>

View File

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

View File

@@ -457,10 +457,6 @@ class SyncMergeLogicTest {
@Test @Test
fun `test active sessions excluded from sync`() { fun `test active sessions excluded from sync`() {
// Test scenario: Active sessions should not be included in sync data
// This tests the new behavior where active sessions are excluded from sync
// until they are completed
val allLocalSessions = val allLocalSessions =
listOf( listOf(
BackupClimbSession( BackupClimbSession(

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

@@ -40,12 +40,9 @@ export default defineConfig({
items: [ items: [
{ label: "Overview", slug: "sync/overview" }, { label: "Overview", slug: "sync/overview" },
{ label: "Quick Start", slug: "sync/quick-start" }, { label: "Quick Start", slug: "sync/quick-start" },
{ label: "API Reference", slug: "sync/api-reference" },
], ],
}, },
{
label: "Reference",
autogenerate: { directory: "reference" },
},
{ {
label: "Privacy", label: "Privacy",
link: "/privacy/", link: "/privacy/",

View File

@@ -25,9 +25,13 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^9.5.0", "@astrojs/node": "^9.5.1",
"@astrojs/starlight": "^0.36.0", "@astrojs/starlight": "^0.36.2",
"astro": "^5.14.5", "astro": "^5.16.0",
"sharp": "^0.34.4" "qrcode": "^1.5.4",
"sharp": "^0.34.5"
},
"devDependencies": {
"@types/qrcode": "^1.5.6"
} }
} }

1369
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
onlyBuiltDependencies:
- esbuild
- sharp

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

Before

Width:  |  Height:  |  Size: 475 B

After

Width:  |  Height:  |  Size: 244 B

View File

@@ -1,15 +1,4 @@
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"> <svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<!-- Left mountain (amber/yellow) --> <polygon points="24.000,213.091 85.884,73.851 147.769,213.091" fill="#FFC107"/>
<polygon points="48,192 96,64 144,192" <polygon points="77.289,213.091 154.645,42.909 232.000,213.091" fill="#F44336"/>
fill="#FFC107"
stroke="#1C1C1C"
stroke-width="4"
stroke-linejoin="round"/>
<!-- Right mountain (red) -->
<polygon points="112,192 176,32 240,192"
fill="#F44336"
stroke="#1C1C1C"
stroke-width="4"
stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 490 B

After

Width:  |  Height:  |  Size: 259 B

View File

@@ -1,15 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"> <svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- Left mountain (amber/yellow) --> <polygon points="3.000,26.636 10.736,9.231 18.471,26.636" fill="#FFC107"/>
<polygon points="6,24 12,8 18,24" <polygon points="9.661,26.636 19.331,5.364 29.000,26.636" fill="#F44336"/>
fill="#FFC107"
stroke="#1C1C1C"
stroke-width="1"
stroke-linejoin="round"/>
<!-- Right mountain (red) -->
<polygon points="14,24 22,4 30,24"
fill="#F44336"
stroke="#1C1C1C"
stroke-width="1"
stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 475 B

After

Width:  |  Height:  |  Size: 244 B

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/releases",
obtainium:
"https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://git.atri.dad/atridad/Ascently/releases",
playStore: "",
},
ios: {
appStore: "https://apps.apple.com/ca/app/ascently/id6753959144",
testFlight: "https://testflight.apple.com/join/E2DYRGH8",
},
} as const;

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
### TestFlight Beta
Join the TestFlight beta: [https://testflight.apple.com/join/E2DYRGH8](https://testflight.apple.com/join/E2DYRGH8)
### App Store
App Store release coming soon.
## 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

@@ -40,21 +40,6 @@ Ascently is an **offline-first FOSS** app designed to help climbers track their
</Card> </Card>
</CardGrid> </CardGrid>
## Requirements
- **Android:** Version 12+
- **iOS:** Version 17+
## Download
**Android:**
- Download the latest APK from the [Releases page](https://git.atri.dad/atridad/Ascently/releases)
- Use [Obtainium](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) for automatic updates
**iOS:**
- Join the [TestFlight Beta](https://testflight.apple.com/join/E2DYRGH8)
- App Store release coming soon
--- ---
*Built with ❤️ by Atridad Lahiji* *Built with ❤️ by Atridad Lahiji*

View File

@@ -5,7 +5,7 @@ description: Ascently's Privacy Policy
**Last updated: September 29, 2025** **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 ## 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. 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: If you have any questions about this Privacy Policy, you can contact me:

View File

@@ -1,51 +0,0 @@
---
title: Sync Server API
description: API endpoints for the Ascently sync server
---
The sync server provides a minimal REST API for data synchronization.
## Authentication
All endpoints require an `Authorization: Bearer <your-auth-token>` header.
## Endpoints
### Data Sync
**GET /sync**
- Download `ascently.json` file
- Returns: JSON data file or 404 if no data exists
**POST /sync**
- Upload `ascently.json` file
- Body: JSON data
- Returns: Success confirmation
### Images
**GET /images/{imageName}**
- Download an image file
- Returns: Image file or 404 if not found
**POST /images/{imageName}**
- Upload an image file
- Body: Image data
- Returns: Success confirmation
## Example Usage
```bash
# Download data
curl -H "Authorization: Bearer your-token" \
http://localhost:8080/sync
# Upload data
curl -X POST \
-H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
-d @ascently.json \
http://localhost:8080/sync
```
See `main.go` in the sync directory for implementation details.

View File

@@ -0,0 +1,152 @@
---
title: API Reference
description: Complete API documentation for the Ascently sync server
---
Complete reference for all sync server endpoints.
## Authentication
All endpoints require a bearer token in the `Authorization` header:
```
Authorization: Bearer your-auth-token
```
Unauthorized requests return `401 Unauthorized`.
## Endpoints
### Health Check
**`GET /health`**
Check if the server is running.
**Response:**
```json
{
"status": "ok",
"version": "2.0.0"
}
```
### Full Sync - Download
**`GET /sync`**
Download the entire dataset from the server.
**Response:**
```json
{
"exportedAt": "2024-01-15T10:30:00.000Z",
"version": "2.0",
"formatVersion": "2.0",
"gyms": [...],
"problems": [...],
"sessions": [...],
"attempts": [...],
"deletedItems": [...]
}
```
Returns `200 OK` with the backup data, or `404 Not Found` if no data exists.
### Full Sync - Upload
**`POST /sync`**
Upload your entire dataset to the server. This overwrites all server data.
**Request Body:**
```json
{
"exportedAt": "2024-01-15T10:30:00.000Z",
"version": "2.0",
"formatVersion": "2.0",
"gyms": [...],
"problems": [...],
"sessions": [...],
"attempts": [...],
"deletedItems": [...]
}
```
**Response:**
```
200 OK
```
### Delta Sync
**`POST /sync/delta`**
Sync only changed data since your last sync. Much faster than full sync.
**Request Body:**
```json
{
"lastSyncTime": "2024-01-15T10:00:00.000Z",
"gyms": [...],
"problems": [...],
"sessions": [...],
"attempts": [...],
"deletedItems": [...]
}
```
Include only items modified after `lastSyncTime`. The server merges your changes with its data using last-write-wins based on `updatedAt` timestamps.
**Response:**
```json
{
"serverTime": "2024-01-15T10:30:00.000Z",
"gyms": [...],
"problems": [...],
"sessions": [...],
"attempts": [...],
"deletedItems": [...]
}
```
Returns only server items modified after your `lastSyncTime`. Save `serverTime` as your new `lastSyncTime` for the next delta sync.
### Image Upload
**`POST /images/upload?filename={name}`**
Upload an image file.
**Query Parameters:**
- `filename`: Image filename (e.g., `problem_abc123_0.jpg`)
**Request Body:**
Binary image data (JPEG, PNG, GIF, or WebP)
**Response:**
```
200 OK
```
### Image Download
**`GET /images/download?filename={name}`**
Download an image file.
**Query Parameters:**
- `filename`: Image filename
**Response:**
Binary image data with appropriate `Content-Type` header.
Returns `404 Not Found` if the image doesn't exist.
## Notes
- All timestamps are ISO 8601 format with milliseconds
- Active sessions (status `active`) are excluded from sync
- Images are stored separately and referenced by filename
- The server stores everything in a single `ascently.json` file
- No versioning or history - last write wins

View File

@@ -3,28 +3,49 @@ title: Self-Hosted Sync Overview
description: Learn about Ascently's optional sync server for cross-device data synchronization description: Learn about Ascently's optional sync server for cross-device data synchronization
--- ---
You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up using Docker. Run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up with Docker.
## How It Works ## How It Works
This server uses a single `ascently.json` file for your data and a directory for images. The last client to upload wins, overwriting the old data. Authentication is just a static bearer token. The server stores your data in a single `ascently.json` file and images in a directory. It's simple: last write wins. Authentication is a static bearer token you set.
## API ## Features
All endpoints require an `Authorization: Bearer <your-auth-token>` header. - **Delta sync**: Only syncs changed data
- **Image sync**: Automatically syncs problem images
- **Conflict resolution**: Last-write-wins based on timestamps
- **Cross-platform**: Works with iOS and Android clients
- **Privacy**: Your data, your server, no analytics
- `GET /sync`: Download `ascently.json` ## API Endpoints
- `POST /sync`: Upload `ascently.json`
- `GET /images/{imageName}`: Download an image - `GET /health` - Health check
- `POST /images/{imageName}`: Upload an image - `GET /sync` - Download full dataset
- `POST /sync` - Upload full dataset
- `POST /sync/delta` - Sync only changes (recommended)
- `POST /images/upload?filename={name}` - Upload image
- `GET /images/download?filename={name}` - Download image
All endpoints require `Authorization: Bearer <your-token>` header.
See the [API Reference](/sync/api-reference/) for complete documentation.
## Getting Started ## Getting Started
The easiest way to get started is with the [Quick Start guide](/sync/quick-start/) using Docker Compose. Check out the [Quick Start guide](/sync/quick-start/) to get your server running with Docker Compose.
You'll need: You'll need:
- Docker and Docker Compose - Docker and Docker Compose
- A secure authentication token - A secure authentication token
- A place to store your data - A place to store your data
The server will be available at `http://localhost:8080` by default. Configure your clients with your server URL and auth token to start syncing. The server will be available at `http://localhost:8080` by default. Configure your Ascently apps with your server URL and auth token to start syncing.
## How Sync Works
1. **First sync**: Client uploads or downloads full dataset
2. **Subsequent syncs**: Client uses delta sync to only transfer changed data
3. **Conflicts**: Resolved automatically using timestamps (newer wins)
4. **Images**: Synced automatically with problem data
Active sessions are excluded from sync until completed.

View File

@@ -3,7 +3,7 @@ title: Quick Start
description: Get your Ascently sync server running with Docker Compose description: Get your Ascently sync server running with Docker Compose
--- ---
Get your Ascently sync server up and running using Docker Compose. Get your sync server running in minutes with Docker Compose.
## Prerequisites ## Prerequisites
@@ -12,50 +12,158 @@ Get your Ascently sync server up and running using Docker Compose.
## Setup ## Setup
1. Create a `.env` file with your configuration: 1. Create a `docker-compose.yml` file:
```env ```yaml
IMAGE=git.atri.dad/atridad/ascently-sync:latest version: '3.8'
APP_PORT=8080
AUTH_TOKEN=your-super-secret-token services:
DATA_FILE=/data/ascently.json ascently-sync:
IMAGES_DIR=/data/images image: git.atri.dad/atridad/ascently-sync:latest
ROOT_DIR=./ascently-data ports:
- "8080:8080"
environment:
- AUTH_TOKEN=${AUTH_TOKEN}
- DATA_FILE=/data/ascently.json
- IMAGES_DIR=/data/images
volumes:
- ./ascently-data:/data
restart: unless-stopped
``` ```
Set `AUTH_TOKEN` to a long, random string. `ROOT_DIR` is where the server will store its data on your machine. 2. Create a `.env` file in the same directory:
2. Use the provided `docker-compose.yml` in the `sync/` directory: ```env
AUTH_TOKEN=your-super-secret-token-here
```
Replace `your-super-secret-token-here` with a secure random token (see below).
3. Start the server:
```bash ```bash
cd sync/
docker-compose up -d docker-compose up -d
``` ```
The server will be available at `http://localhost:8080`. The server will be available at `http://localhost:8080`.
## Configure Your Clients ## Generate a Secure Token
Configure your Ascently apps with: Use this command to generate a secure authentication token:
- **Server URL**: `http://your-server-ip:8080` (or your domain)
- **Auth Token**: The token from your `.env` file
Enable sync and perform your first sync to start synchronizing data across devices.
## Generating a Secure Token
Generate a secure authentication token:
```bash ```bash
# On Linux/macOS
openssl rand -base64 32 openssl rand -base64 32
``` ```
Keep this token secure and don't share it publicly. Copy the output and paste it into your `.env` file as the `AUTH_TOKEN`.
## Accessing Remotely Keep this token secret and don't commit it to version control.
For remote access, you'll need to: ## Configure Your Apps
- Set up port forwarding on your router (port 8080)
- Use your public IP address or set up a domain name Open Ascently on your iOS or Android device:
- Consider using HTTPS with a reverse proxy for security
1. Go to **Settings**
2. Scroll to **Sync Configuration**
3. Enter your **Server URL**: `http://your-server-ip:8080`
4. Enter your **Auth Token**: (the token from your `.env` file)
5. Tap **Test Connection** to verify it works
6. Enable **Auto Sync**
7. Tap **Sync Now** to perform your first sync
Repeat this on all your devices to keep them in sync.
## Verify It's Working
Check the server logs:
```bash
docker-compose logs -f ascently-sync
```
You should see logs like:
```
Delta sync from 192.168.1.100: lastSyncTime=2024-01-15T10:00:00.000Z, gyms=1, problems=5, sessions=2, attempts=10, deletedItems=0
```
## Remote Access
To access your server remotely:
### Option 1: Port Forwarding
1. Forward port 8080 on your router to your server
2. Find your public IP address
3. Use `http://your-public-ip:8080` as the server URL
### Option 2: Domain Name (Recommended)
1. Get a domain name and point it to your server
2. Set up a reverse proxy (nginx, Caddy, Traefik)
3. Enable HTTPS with Let's Encrypt
4. Use `https://sync.yourdomain.com` as the server URL
Example nginx config with HTTPS:
```nginx
server {
listen 443 ssl http2;
server_name sync.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/sync.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sync.yourdomain.com/privkey.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
## Updating
Pull the latest image and restart:
```bash
docker-compose pull
docker-compose up -d
```
Your data is stored in `./ascently-data` and persists across updates.
## Troubleshooting
### Connection Failed
- Check the server is running: `docker-compose ps`
- Verify the auth token matches on server and client
- Check firewall settings and port forwarding
- Test locally first with `http://localhost:8080`
### Sync Errors
- Check server logs: `docker-compose logs ascently-sync`
- Verify your device has internet connection
- Try disabling and re-enabling sync
- Perform a manual sync from Settings
### Data Location
All data is stored in `./ascently-data/`:
```
ascently-data/
├── ascently.json # Your climb data
└── images/ # Problem images
```
You can back this up or move it to another server.
## Next Steps
- Read the [API Reference](/sync/api-reference/) for advanced usage
- Set up automated backups of your `ascently-data` directory
- Configure HTTPS for secure remote access
- Monitor server logs for sync activity

View File

@@ -25,7 +25,7 @@ final class LiveActivityManager {
pushType: nil pushType: nil
) )
} catch { } 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_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -487,7 +487,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.0.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -513,7 +513,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -535,7 +535,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.0.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -602,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -613,7 +613,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.0.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26; CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -643,7 +643,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.0.0; MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@@ -0,0 +1,33 @@
import AppIntents
/// Provides a curated list of the most useful Ascently shortcuts for Siri and the Shortcuts app.
/// Surfaces intents that users can trigger hands-free to manage their climbing sessions.
struct AscentlyShortcuts: AppShortcutsProvider {
static var shortcutTileColor: ShortcutTileColor {
.teal
}
static var appShortcuts: [AppShortcut] {
return [
AppShortcut(
intent: StartLastGymSessionIntent(),
phrases: [
"Start my climb in \(.applicationName)",
"Begin my last gym session in \(.applicationName)",
],
shortTitle: "Start Climb",
systemImageName: "figure.climbing"
),
AppShortcut(
intent: EndActiveSessionIntent(),
phrases: [
"Finish my climb in \(.applicationName)",
"End my session in \(.applicationName)",
],
shortTitle: "End Climb",
systemImageName: "flag.checkered"
),
]
}
}

View File

@@ -0,0 +1,40 @@
import AppIntents
import Foundation
/// Ends the currently active climbing session so logging stays in sync across devices.
/// Exposed to Shortcuts so users can wrap up a session without opening the app.
struct EndActiveSessionIntent: AppIntent {
static var title: LocalizedStringResource {
"End Active Session"
}
static var description: IntentDescription {
IntentDescription(
"Stop the active climbing session and save its progress in Ascently."
)
}
static var openAppWhenRun: Bool {
false
}
func perform() async throws -> some IntentResult & ProvidesDialog {
do {
let summary = try await SessionIntentController().endActiveSession()
let dialog = IntentDialog("Session at \(summary.gymName) ended. Nice work!")
return .result(dialog: dialog)
} catch SessionIntentError.noActiveSession {
// No active session is fine - just return a friendly message
let dialog = IntentDialog("No active session to end.")
return .result(dialog: dialog)
} catch {
// Re-throw other errors
throw error
}
}
static var parameterSummary: some ParameterSummary {
Summary("End my current climbing session")
}
}

View File

@@ -0,0 +1,95 @@
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
}
/// Central controller that exposes the minimal climbing session operations used by App Intents and shortcuts.
@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 {
// Give a moment for data to be ready if app just launched
if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
}
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) {
// Logging from intent context - errors are visible to user via dialog
print("SessionIntentError: \(error). Context: \(context)")
}
}

View File

@@ -0,0 +1,43 @@
import AppIntents
import Foundation
/// Starts a climbing session at the most recently visited gym.
/// Exposed to Shortcuts so users can begin logging without opening the app.
struct StartLastGymSessionIntent: AppIntent {
static var title: LocalizedStringResource {
"Start Last Gym Session"
}
static var description: IntentDescription {
IntentDescription(
"Begin a new climbing session using the most recent gym you visited in Ascently."
)
}
static var openAppWhenRun: Bool {
true
}
func perform() async throws -> some IntentResult & ProvidesDialog {
// Delay to ensure app has time to fully initialize if just launched
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
let summary = try await SessionIntentController().startSessionWithLastUsedGym()
// Give Live Activity extra time to start
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
return .result(
dialog: Self.successDialog(for: summary.gymName)
)
}
private static func successDialog(for gymName: String) -> IntentDialog {
IntentDialog("Session started at \(gymName). Have an awesome climb!")
}
static var parameterSummary: some ParameterSummary {
Summary("Start a session at my last gym")
}
}

View File

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

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"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg"> <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<!-- Dark background with rounded corners for iOS -->
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/> <rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
<!-- Transform to match Android layout exactly -->
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)"> <g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain (yellow/amber) - matches Android coordinates with white border --> <polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
<polygon points="15,70 35,25 55,70" <polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
fill="#FFC107"
stroke="#FFFFFF"
stroke-width="3"
stroke-linejoin="round"/>
<!-- Right mountain (red) - matches Android coordinates with white border -->
<polygon points="40,70 65,15 90,70"
fill="#F44336"
stroke="#FFFFFF"
stroke-width="3"
stroke-linejoin="round"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 913 B

After

Width:  |  Height:  |  Size: 411 B

View File

@@ -1,22 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg"> <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<!-- White background with rounded corners for iOS -->
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/> <rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
<!-- Transform to match Android layout exactly -->
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)"> <g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain (yellow/amber) - matches Android coordinates --> <polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
<polygon points="15,70 35,25 55,70" <polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
fill="#FFC107"
stroke="#1C1C1C"
stroke-width="3"
stroke-linejoin="round"/>
<!-- Right mountain (red) - matches Android coordinates -->
<polygon points="40,70 65,15 90,70"
fill="#F44336"
stroke="#1C1C1C"
stroke-width="3"
stroke-linejoin="round"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 878 B

After

Width:  |  Height:  |  Size: 411 B

View File

@@ -1,24 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg"> <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<!-- Transparent background with rounded corners for iOS tinted mode -->
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/> <rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
<!-- Transform to match Android layout exactly -->
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)"> <g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain - matches Android coordinates, black fill for tinting --> <polygon points="8,75 35,14.25 62,75" fill="#000000" opacity="0.8"/>
<polygon points="15,70 35,25 55,70" <polygon points="31.25,75 65,0.75 98.75,75" fill="#000000" opacity="0.9"/>
fill="#000000"
stroke="#000000"
stroke-width="3"
stroke-linejoin="round"
opacity="0.8"/>
<!-- Right mountain - matches Android coordinates, black fill for tinting -->
<polygon points="40,70 65,15 90,70"
fill="#000000"
stroke="#000000"
stroke-width="3"
stroke-linejoin="round"
opacity="0.9"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 981 B

After

Width:  |  Height:  |  Size: 443 B

View File

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

View File

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

View File

@@ -55,7 +55,6 @@ struct BackupGym: Codable {
let createdAt: String let createdAt: String
let updatedAt: String let updatedAt: String
/// Initialize from native iOS Gym model
init(from gym: Gym) { init(from gym: Gym) {
self.id = gym.id.uuidString self.id = gym.id.uuidString
self.name = gym.name self.name = gym.name
@@ -71,7 +70,6 @@ struct BackupGym: Codable {
self.updatedAt = formatter.string(from: gym.updatedAt) self.updatedAt = formatter.string(from: gym.updatedAt)
} }
/// Initialize with explicit parameters for import
init( init(
id: String, id: String,
name: String, name: String,
@@ -94,7 +92,6 @@ struct BackupGym: Codable {
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
/// Convert to native iOS Gym model
func toGym() throws -> Gym { func toGym() throws -> Gym {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
@@ -137,7 +134,6 @@ struct BackupProblem: Codable {
let createdAt: String let createdAt: String
let updatedAt: String let updatedAt: String
/// Initialize from native iOS Problem model
init(from problem: Problem) { init(from problem: Problem) {
self.id = problem.id.uuidString self.id = problem.id.uuidString
self.gymId = problem.gymId.uuidString self.gymId = problem.gymId.uuidString
@@ -158,7 +154,6 @@ struct BackupProblem: Codable {
self.updatedAt = formatter.string(from: problem.updatedAt) self.updatedAt = formatter.string(from: problem.updatedAt)
} }
/// Initialize with explicit parameters for import
init( init(
id: String, id: String,
gymId: String, gymId: String,
@@ -191,7 +186,6 @@ struct BackupProblem: Codable {
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
/// Convert to native iOS Problem model
func toProblem() throws -> Problem { func toProblem() throws -> Problem {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
@@ -224,7 +218,6 @@ struct BackupProblem: Codable {
) )
} }
/// Create a copy with updated image paths for import processing
func withUpdatedImagePaths(_ newImagePaths: [String]) -> BackupProblem { func withUpdatedImagePaths(_ newImagePaths: [String]) -> BackupProblem {
return BackupProblem( return BackupProblem(
id: self.id, id: self.id,
@@ -258,7 +251,6 @@ struct BackupClimbSession: Codable {
let createdAt: String let createdAt: String
let updatedAt: String let updatedAt: String
/// Initialize from native iOS ClimbSession model
init(from session: ClimbSession) { init(from session: ClimbSession) {
self.id = session.id.uuidString self.id = session.id.uuidString
self.gymId = session.gymId.uuidString self.gymId = session.gymId.uuidString
@@ -275,7 +267,6 @@ struct BackupClimbSession: Codable {
self.updatedAt = formatter.string(from: session.updatedAt) self.updatedAt = formatter.string(from: session.updatedAt)
} }
/// Initialize with explicit parameters for import
init( init(
id: String, id: String,
gymId: String, gymId: String,
@@ -300,7 +291,6 @@ struct BackupClimbSession: Codable {
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
/// Convert to native iOS ClimbSession model
func toClimbSession() throws -> ClimbSession { func toClimbSession() throws -> ClimbSession {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
@@ -347,7 +337,6 @@ struct BackupAttempt: Codable {
let createdAt: String let createdAt: String
let updatedAt: String? let updatedAt: String?
/// Initialize from native iOS Attempt model
init(from attempt: Attempt) { init(from attempt: Attempt) {
self.id = attempt.id.uuidString self.id = attempt.id.uuidString
self.sessionId = attempt.sessionId.uuidString self.sessionId = attempt.sessionId.uuidString
@@ -365,7 +354,6 @@ struct BackupAttempt: Codable {
self.updatedAt = formatter.string(from: attempt.updatedAt) self.updatedAt = formatter.string(from: attempt.updatedAt)
} }
/// Initialize with explicit parameters for import
init( init(
id: String, id: String,
sessionId: String, sessionId: String,
@@ -392,7 +380,6 @@ struct BackupAttempt: Codable {
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
/// Convert to native iOS Attempt model
func toAttempt() throws -> Attempt { func toAttempt() throws -> Attempt {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]

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