Compare commits

...

12 Commits

Author SHA1 Message Date
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
01d85a4add [Android] 2.0.1 - Refactoring & Minor Optimizations 2025-10-14 23:59:27 -06:00
ef1cf3583a [Android] 2.0.1 - Refactoring & Minor Optimizations 2025-10-14 23:57:46 -06:00
3c7290f7e7 [Android] 2.0.1 - Refactoring & Minor Optimizations 2025-10-14 23:47:02 -06:00
5a49b9f0b2 [Android] 2.0.1 - Refactoring & Minor Optimizations 2025-10-14 23:35:15 -06:00
7601e7bb03 Docs updates
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m43s
Ascently - Docs Deploy / build-and-push (push) Successful in 3m34s
2025-10-14 14:26:56 -06:00
082cb79630 Colour changes
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m29s
2025-10-14 12:06:06 -06:00
79b87a088d Fixed T_T
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m53s
2025-10-14 11:53:51 -06:00
1a8f41ecde Added deploy for Docs
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m56s
Ascently - Docs Deploy / build-and-push (push) Successful in 4m45s
2025-10-14 11:42:40 -06:00
175dad8342 Re-wordin and favicon 2025-10-14 08:48:40 -06:00
4559d5e2f2 Added docs 2025-10-14 01:35:13 -06:00
d57149d29c New testflight details! 2025-10-13 15:44:34 -06:00
77 changed files with 6822 additions and 2076 deletions

38
.github/workflows/deploy_docs.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Ascently - Docs Deploy
on:
push:
branches: [main]
paths: ["docs/**"]
pull_request:
branches: [main]
paths: ["docs/**"]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REPO_HOST }}
username: ${{ github.repository_owner }}
password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./docs
platforms: linux/amd64
push: true
tags: |
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-docs:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-docs:latest

View File

@@ -1,4 +1,4 @@
name: Ascently Docker Deploy name: Ascently - Sync Deploy
on: on:
push: push:
branches: [main] branches: [main]
@@ -14,7 +14,7 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v5
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
@@ -27,7 +27,7 @@ jobs:
password: ${{ secrets.DEPLOY_TOKEN }} password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push sync-server - name: Build and push sync-server
uses: docker/build-push-action@v4 uses: docker/build-push-action@v6
with: with:
context: ./sync context: ./sync
file: ./sync/Dockerfile file: ./sync/Dockerfile

View File

@@ -1,21 +0,0 @@
# Privacy Policy
**Last updated: September 29, 2025**
This Privacy Policy describes our policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
## No Data Collection
I do not collect any personal information, analytics, or data of any kind. This software is designed to be self-hosted or run entirely offline.
All data generated by or used with this software remains on your local machine or self-hosted environment under your control. I have no access to it.
## No Tracking or Analytics
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
If you have any questions about this Privacy Policy, you can contact me:
* **By email:** me@atri.dad

View File

@@ -2,43 +2,11 @@
_Formerly OpenClimb_ _Formerly OpenClimb_
This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-first, with an optional sync server and integrations with Apple Health and Health Connect. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS. Ascently is an **offline-first FOSS** app designed to help climbers track their sessions, routes/problems, and overall progress. There is an optional self-hosted sync server and integrations with Apple Health and Health Connect. There are no analytics or tracking baked into any part of this project. I am committed to maintaining a transparent and open-source solution for climbers, ensuring that you have full control over your data and privacy.
## Download ## Documentation
For Android do one of the following: Documentation can be found at [https://ascently.atri.dad](https://ascently.atri.dad)!
1. Download the latest APK from the Releases page
2. [<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)
For iOS:
Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
For development builds, sign up for the TestFlight [here](https://testflight.apple.com/join/88RtxV4J)!
## Self-Hosted Sync Server
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.
### Quick Start with Docker Compose
1. Create a `.env` file with your configuration:
```
IMAGE=git.atri.dad/atridad/ascently-sync:latest
APP_PORT=8080
AUTH_TOKEN=your-secure-auth-token-here
DATA_FILE=/data/ascently.json
IMAGES_DIR=/data/images
ROOT_DIR=./ascently-data
```
2. Use the provided `docker-compose.yml` in the `sync/` directory:
```bash
cd sync/
docker-compose up -d
```
The server will be available at `http://localhost:8080`. Configure your clients with your server URL and auth token to start syncing.
## Requirements ## Requirements

View File

@@ -18,5 +18,3 @@ This is a standard Android Gradle project. The main code lives in `app/src/main/
- `navigation/`: Navigation graph and routes using Jetpack Navigation. - `navigation/`: Navigation graph and routes using Jetpack Navigation.
- `service/`: Background service for tracking climbing sessions. - `service/`: Background service for tracking climbing sessions.
- `utils/`: Helpers for things like date formatting and image handling. - `utils/`: Helpers for things like date formatting and image handling.
The app is built to be offline-first. All data is stored locally on your device and works without an internet connection.

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.ascently" applicationId = "com.atridad.ascently"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 40 versionCode = 42
versionName = "2.0.0" versionName = "2.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -28,8 +28,8 @@ abstract class AscentlyDatabase : RoomDatabase() {
val MIGRATION_4_5 = val MIGRATION_4_5 =
object : Migration(4, 5) { object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
val cursor = database.query("PRAGMA table_info(climb_sessions)") val cursor = db.query("PRAGMA table_info(climb_sessions)")
val existingColumns = mutableSetOf<String>() val existingColumns = mutableSetOf<String>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
@@ -39,21 +39,21 @@ abstract class AscentlyDatabase : RoomDatabase() {
cursor.close() cursor.close()
if (!existingColumns.contains("startTime")) { if (!existingColumns.contains("startTime")) {
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT") db.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT")
} }
if (!existingColumns.contains("endTime")) { if (!existingColumns.contains("endTime")) {
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT") db.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT")
} }
if (!existingColumns.contains("status")) { if (!existingColumns.contains("status")) {
database.execSQL( db.execSQL(
"ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'" "ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'"
) )
} }
database.execSQL( db.execSQL(
"UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL" "UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL"
) )
database.execSQL( db.execSQL(
"UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''" "UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''"
) )
} }
@@ -61,9 +61,9 @@ abstract class AscentlyDatabase : RoomDatabase() {
val MIGRATION_5_6 = val MIGRATION_5_6 =
object : Migration(5, 6) { object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
// Add updatedAt column to attempts table // Add updatedAt column to attempts table
val cursor = database.query("PRAGMA table_info(attempts)") val cursor = db.query("PRAGMA table_info(attempts)")
val existingColumns = mutableSetOf<String>() val existingColumns = mutableSetOf<String>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
@@ -73,11 +73,11 @@ abstract class AscentlyDatabase : RoomDatabase() {
cursor.close() cursor.close()
if (!existingColumns.contains("updatedAt")) { if (!existingColumns.contains("updatedAt")) {
database.execSQL( db.execSQL(
"ALTER TABLE attempts ADD COLUMN updatedAt TEXT NOT NULL DEFAULT ''" "ALTER TABLE attempts ADD COLUMN updatedAt TEXT NOT NULL DEFAULT ''"
) )
// Set updatedAt to createdAt for existing records // Set updatedAt to createdAt for existing records
database.execSQL( db.execSQL(
"UPDATE attempts SET updatedAt = createdAt WHERE updatedAt = ''" "UPDATE attempts SET updatedAt = createdAt WHERE updatedAt = ''"
) )
} }
@@ -95,7 +95,7 @@ abstract class AscentlyDatabase : RoomDatabase() {
) )
.addMigrations(MIGRATION_4_5, MIGRATION_5_6) .addMigrations(MIGRATION_4_5, MIGRATION_5_6)
.enableMultiInstanceInvalidation() .enableMultiInstanceInvalidation()
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration(false)
.build() .build()
INSTANCE = instance INSTANCE = instance
instance instance

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

@@ -40,7 +40,6 @@ class HealthConnectManager(private val context: Context) {
val isEnabled: Flow<Boolean> = _isEnabled.asStateFlow() val isEnabled: Flow<Boolean> = _isEnabled.asStateFlow()
val hasPermissions: Flow<Boolean> = _hasPermissions.asStateFlow() val hasPermissions: Flow<Boolean> = _hasPermissions.asStateFlow()
val autoSyncEnabled: Flow<Boolean> = _autoSync.asStateFlow()
val isCompatible: Flow<Boolean> = _isCompatible.asStateFlow() val isCompatible: Flow<Boolean> = _isCompatible.asStateFlow()
companion object { companion object {
@@ -67,7 +66,6 @@ class HealthConnectManager(private val context: Context) {
} }
} }
/** 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) {
@@ -83,29 +81,30 @@ class HealthConnectManager(private val context: Context) {
} }
} }
/** Enable or disable Health Connect integration */ suspend fun setEnabled(enabled: Boolean) {
fun setEnabled(enabled: Boolean) {
preferences.edit().putBoolean("enabled", enabled).apply() preferences.edit().putBoolean("enabled", enabled).apply()
_isEnabled.value = enabled _isEnabled.value = enabled
if (!enabled) { if (enabled && _isCompatible.value) {
// Automatically request permissions when enabling
try {
val alreadyHasPermissions = hasAllPermissions()
if (!alreadyHasPermissions) {
Log.d(TAG, "Health Connect enabled - permissions will be requested by UI")
}
} catch (e: Exception) {
Log.w(TAG, "Error checking permissions when enabling Health Connect", e)
}
} 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
} }
/** Enable or disable auto-sync */
fun setAutoSyncEnabled(enabled: Boolean) {
preferences.edit().putBoolean("auto_sync", enabled).apply()
_autoSync.value = enabled
}
/** 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) {
@@ -126,7 +125,6 @@ class HealthConnectManager(private val context: Context) {
} }
} }
/** 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)
@@ -142,88 +140,30 @@ class HealthConnectManager(private val context: Context) {
} }
} }
/** Get permission request contract */
fun getPermissionRequestContract(): ActivityResultContract<Set<String>, Set<String>> { fun getPermissionRequestContract(): ActivityResultContract<Set<String>, Set<String>> {
return PermissionController.createRequestPermissionResultContract() return PermissionController.createRequestPermissionResultContract()
} }
/** Test Health Connect functionality */
fun testHealthConnectSync(): String {
val results = mutableListOf<String>()
results.add("=== Health Connect Debug Test ===")
try {
// Check availability synchronously
val packageManager = context.packageManager
val healthConnectPackages =
listOf(
"com.google.android.apps.healthdata",
"com.android.health.connect",
"androidx.health.connect"
)
val available =
healthConnectPackages.any { packageName ->
try {
packageManager.getPackageInfo(packageName, 0)
true
} catch (e: Exception) {
false
}
}
results.add("Available: $available")
// Check enabled state
results.add("Enabled in settings: ${_isEnabled.value}")
// Check permissions (simplified)
val hasPerms = _hasPermissions.value
results.add("Has permissions: $hasPerms")
// Check compatibility
results.add("API Compatible: ${_isCompatible.value}")
val ready = _isEnabled.value && _isCompatible.value && available && hasPerms
results.add("Ready to sync: $ready")
if (ready) {
results.add("Health Connect is connected!")
} else {
results.add("❌ Health Connect not ready")
if (!available) results.add("- Health Connect not available on device")
if (!_isEnabled.value) results.add("- Not enabled in Ascently settings")
if (!hasPerms) results.add("- Permissions not granted")
if (!_isCompatible.value) results.add("- API compatibility issues")
}
} catch (e: Exception) {
results.add("Test failed with error: ${e.message}")
Log.e(TAG, "Health Connect test failed", e)
}
return results.joinToString("\n")
}
/** Get required permissions as strings */
fun getRequiredPermissions(): Set<String> { fun getRequiredPermissions(): Set<String> {
return try { return try {
REQUIRED_PERMISSIONS.map { it.toString() }.toSet() REQUIRED_PERMISSIONS.map { it }.toSet()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error getting required permissions", e) Log.e(TAG, "Error getting required permissions", e)
emptySet() emptySet()
} }
} }
/** Sync a completed climbing session to Health Connect */
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
suspend fun syncClimbingSession( 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()) { if (!isReady() || !_autoSync.value) {
return Result.failure(IllegalStateException("Health Connect not ready")) return Result.failure(
IllegalStateException("Health Connect not ready or auto-sync disabled")
)
} }
if (session.status != SessionStatus.COMPLETED) { if (session.status != SessionStatus.COMPLETED) {
@@ -320,18 +260,18 @@ class HealthConnectManager(private val context: Context) {
} }
} }
/** Auto-sync a session if enabled */ suspend fun autoSyncCompletedSession(
suspend fun autoSyncSession(
session: ClimbSession, session: ClimbSession,
gymName: String, gymName: String,
attemptCount: Int = 0 attemptCount: Int = 0
): Result<Unit> { ): Result<Unit> {
return if (_autoSync.value && isReady()) { return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
Log.d(TAG, "Auto-syncing session '${session.id}' to Health Connect...") Log.d(TAG, "Auto-syncing completed session '${session.id}' to Health Connect...")
syncClimbingSession(session, gymName, attemptCount) syncCompletedSession(session, gymName, attemptCount)
} else { } else {
val reason = val reason =
when { when {
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"
@@ -341,7 +281,6 @@ class HealthConnectManager(private val context: Context) {
} }
} }
/** 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 =
@@ -353,7 +292,6 @@ class HealthConnectManager(private val context: Context) {
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,
@@ -395,32 +333,7 @@ class HealthConnectManager(private val context: Context) {
} }
} }
/** Reset all preferences */
fun reset() {
preferences.edit().clear().apply()
_isEnabled.value = false
_hasPermissions.value = false
_autoSync.value = true
}
/** Check if ready for use */
fun isReadySync(): Boolean { fun isReadySync(): Boolean {
return _isEnabled.value && _hasPermissions.value return _isEnabled.value && _hasPermissions.value
} }
/** Get last successful sync timestamp */
fun getLastSyncSuccess(): String? {
return preferences.getString("last_sync_success", null)
}
/** Get detailed status */
fun getDetailedStatus(): Map<String, String> {
return mapOf(
"enabled" to _isEnabled.value.toString(),
"hasPermissions" to _hasPermissions.value.toString(),
"autoSync" to _autoSync.value.toString(),
"compatible" to _isCompatible.value.toString(),
"lastSyncSuccess" to (getLastSyncSuccess() ?: "never")
)
}
} }

View File

@@ -12,7 +12,7 @@ enum class AttemptResult {
SUCCESS, SUCCESS,
FALL, FALL,
NO_PROGRESS, NO_PROGRESS,
FLASH, FLASH
} }
@Entity( @Entity(
@@ -74,5 +74,4 @@ data class Attempt(
) )
} }
} }
} }

View File

@@ -7,10 +7,9 @@ enum class ClimbType {
ROPE, ROPE,
BOULDER; BOULDER;
/** val displayName: String
* Get the display name get() =
*/ when (this) {
fun getDisplayName(): String = when (this) {
ROPE -> "Rope" ROPE -> "Rope"
BOULDER -> "Bouldering" BOULDER -> "Bouldering"
} }

View File

@@ -12,8 +12,8 @@ enum class DifficultySystem {
YDS, YDS,
CUSTOM; CUSTOM;
/** Get the display name for the UI */ val displayName: String
fun getDisplayName(): String = get() =
when (this) { when (this) {
V_SCALE -> "V Scale" V_SCALE -> "V Scale"
FONT -> "Font Scale" FONT -> "Font Scale"
@@ -21,24 +21,24 @@ enum class DifficultySystem {
CUSTOM -> "Custom" CUSTOM -> "Custom"
} }
/** Check if this system is for bouldering */ val isBoulderingSystem: Boolean
fun isBoulderingSystem(): Boolean = get() =
when (this) { when (this) {
V_SCALE, FONT -> true V_SCALE, FONT -> true
YDS -> false YDS -> false
CUSTOM -> true CUSTOM -> true
} }
/** Check if this system is for rope climbing */ val isRopeSystem: Boolean
fun isRopeSystem(): Boolean = get() =
when (this) { when (this) {
YDS -> true YDS -> true
V_SCALE, FONT -> false V_SCALE, FONT -> false
CUSTOM -> true CUSTOM -> true
} }
/** Get available grades for this system */ val availableGrades: List<String>
fun getAvailableGrades(): List<String> = get() =
when (this) { when (this) {
V_SCALE -> V_SCALE ->
listOf( listOf(
@@ -131,11 +131,10 @@ enum class DifficultySystem {
} }
companion object { companion object {
/** Get all difficulty systems based on type */ fun systemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
when (climbType) { when (climbType) {
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() } ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem }
ClimbType.ROPE -> entries.filter { it.isRopeSystem() } ClimbType.ROPE -> entries.filter { it.isRopeSystem }
} }
} }
} }
@@ -154,38 +153,78 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
DifficultySystem.V_SCALE -> { DifficultySystem.V_SCALE -> {
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0 if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
} }
DifficultySystem.YDS -> {
when {
grade.startsWith("5.10") ->
10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
grade.startsWith("5.11") ->
14 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
grade.startsWith("5.12") ->
18 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
grade.startsWith("5.13") ->
22 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
grade.startsWith("5.14") ->
26 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
grade.startsWith("5.15") ->
30 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
else -> grade.removePrefix("5.").toIntOrNull() ?: 0
}
}
DifficultySystem.FONT -> { DifficultySystem.FONT -> {
when { val fontMapping: Map<String, Int> =
grade.startsWith("6A") -> 6 mapOf(
grade.startsWith("6B") -> 7 "3" to 3,
grade.startsWith("6C") -> 8 "4A" to 4,
grade.startsWith("7A") -> 9 "4B" to 5,
grade.startsWith("7B") -> 10 "4C" to 6,
grade.startsWith("7C") -> 11 "5A" to 7,
grade.startsWith("8A") -> 12 "5B" to 8,
grade.startsWith("8B") -> 13 "5C" to 9,
grade.startsWith("8C") -> 14 "6A" to 10,
else -> grade.toIntOrNull() ?: 0 "6A+" to 11,
"6B" to 12,
"6B+" to 13,
"6C" to 14,
"6C+" to 15,
"7A" to 16,
"7A+" to 17,
"7B" to 18,
"7B+" to 19,
"7C" to 20,
"7C+" to 21,
"8A" to 22,
"8A+" to 23,
"8B" to 24,
"8B+" to 25,
"8C" to 26,
"8C+" to 27
)
fontMapping[grade] ?: 0
} }
DifficultySystem.YDS -> {
val ydsMapping: Map<String, Int> =
mapOf(
"5.0" to 50,
"5.1" to 51,
"5.2" to 52,
"5.3" to 53,
"5.4" to 54,
"5.5" to 55,
"5.6" to 56,
"5.7" to 57,
"5.8" to 58,
"5.9" to 59,
"5.10a" to 60,
"5.10b" to 61,
"5.10c" to 62,
"5.10d" to 63,
"5.11a" to 64,
"5.11b" to 65,
"5.11c" to 66,
"5.11d" to 67,
"5.12a" to 68,
"5.12b" to 69,
"5.12c" to 70,
"5.12d" to 71,
"5.13a" to 72,
"5.13b" to 73,
"5.13c" to 74,
"5.13d" to 75,
"5.14a" to 76,
"5.14b" to 77,
"5.14c" to 78,
"5.14d" to 79,
"5.15a" to 80,
"5.15b" to 81,
"5.15c" to 82,
"5.15d" to 83
)
ydsMapping[grade] ?: 0
} }
DifficultySystem.CUSTOM -> grade.hashCode().rem(100) DifficultySystem.CUSTOM -> grade.toIntOrNull() ?: 0
} }
} }
} }

View File

@@ -251,23 +251,15 @@ 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())

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

@@ -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
@@ -63,6 +66,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
prettyPrint = true prettyPrint = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
explicitNulls = false explicitNulls = false
coerceInputValues = true
} }
// State // State
@@ -130,17 +134,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) } sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
} }
// Legacy accessor expected by some UI code (kept for compatibility)
@Deprecated(
message = "Use serverUrl (kebab case) instead",
replaceWith = ReplaceWith("serverUrl")
)
var serverURL: String
get() = serverUrl
set(value) {
serverUrl = value
}
var authToken: String var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: "" get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
set(value) { set(value) {
@@ -206,6 +199,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
serverBackup.sessions.isNotEmpty() || serverBackup.sessions.isNotEmpty() ||
serverBackup.attempts.isNotEmpty() serverBackup.attempts.isNotEmpty()
// If both client and server have been synced before, use delta sync
val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
if (hasLocalData && hasServerData && lastSyncTimeStr != null) {
Log.d(TAG, "Using delta sync for incremental updates")
performDeltaSync(lastSyncTimeStr)
} else {
when { when {
!hasLocalData && hasServerData -> { !hasLocalData && hasServerData -> {
Log.d(TAG, "No local data found, performing full restore from server") Log.d(TAG, "No local data found, performing full restore from server")
@@ -228,6 +227,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
Log.d(TAG, "No data to sync") Log.d(TAG, "No data to sync")
} }
} }
}
val now = DateFormatUtils.nowISO8601() val now = DateFormatUtils.nowISO8601()
_lastSyncTime.value = now _lastSyncTime.value = now
@@ -241,6 +241,265 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
private suspend fun performDeltaSync(lastSyncTimeStr: String) {
Log.d(TAG, "Starting delta sync with lastSyncTime=$lastSyncTimeStr")
// Parse last sync time to filter modified items
val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0)
// Collect items modified since last sync
val allGyms = repository.getAllGyms().first()
val modifiedGyms =
allGyms
.filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true }
.map { BackupGym.fromGym(it) }
val allProblems = repository.getAllProblems().first()
val modifiedProblems =
allProblems
.filter { problem ->
parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true
}
.map { problem ->
val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(problem.id, index)
}
if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
}
val allSessions = repository.getAllSessions().first()
val modifiedSessions =
allSessions
.filter { session ->
parseISO8601(session.updatedAt)?.after(lastSyncDate) == true
}
.map { BackupClimbSession.fromClimbSession(it) }
val allAttempts = repository.getAllAttempts().first()
val modifiedAttempts =
allAttempts
.filter { attempt ->
parseISO8601(attempt.createdAt)?.after(lastSyncDate) == true
}
.map { BackupAttempt.fromAttempt(it) }
val allDeletions = repository.getDeletedItems()
val modifiedDeletions =
allDeletions.filter { item ->
parseISO8601(item.deletedAt)?.after(lastSyncDate) == true
}
Log.d(
TAG,
"Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}"
)
// Create delta request
val deltaRequest =
DeltaSyncRequest(
lastSyncTime = lastSyncTimeStr,
gyms = modifiedGyms,
problems = modifiedProblems,
sessions = modifiedSessions,
attempts = modifiedAttempts,
deletedItems = modifiedDeletions
)
val requestBody =
json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest)
.toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync/delta")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
val deltaResponse =
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(DeltaSyncResponse.serializer(), body)
} else {
throw SyncException.InvalidResponse("Empty response body")
}
} else {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
Log.d(
TAG,
"Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}"
)
// Apply server changes to local data
applyDeltaResponse(deltaResponse)
// Sync only modified problem images
syncModifiedImages(modifiedProblems)
}
private fun parseISO8601(dateString: String): Date? {
return try {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
format.parse(dateString)
} catch (e: Exception) {
null
}
}
private suspend fun applyDeltaResponse(response: DeltaSyncResponse) {
// Temporarily disable auto-sync to prevent recursive sync triggers
repository.setAutoSyncCallback(null)
try {
// Download images for new/modified problems from server
val imagePathMapping = mutableMapOf<String, String>()
for (problem in response.problems) {
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
val localImagePath = downloadImage(serverFilename)
if (localImagePath != null) {
imagePathMapping[imagePath] = localImagePath
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
}
}
}
// Merge gyms - check if exists and compare timestamps
val existingGyms = repository.getAllGyms().first()
for (backupGym in response.gyms) {
val existing = existingGyms.find { it.id == backupGym.id }
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
val gym = backupGym.toGym()
if (existing != null) {
repository.updateGym(gym)
} else {
repository.insertGym(gym)
}
}
}
// Merge problems
val existingProblems = repository.getAllProblems().first()
for (backupProblem in response.problems) {
val updatedImagePaths =
backupProblem.imagePaths?.map { oldPath ->
imagePathMapping[oldPath] ?: oldPath
}
val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths)
val problem = problemToMerge.toProblem()
val existing = existingProblems.find { it.id == backupProblem.id }
if (existing == null || backupProblem.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateProblem(problem)
} else {
repository.insertProblem(problem)
}
}
}
// Merge sessions
val existingSessions = repository.getAllSessions().first()
for (backupSession in response.sessions) {
val session = backupSession.toClimbSession()
val existing = existingSessions.find { it.id == backupSession.id }
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateSession(session)
} else {
repository.insertSession(session)
}
}
}
// Merge attempts
val existingAttempts = repository.getAllAttempts().first()
for (backupAttempt in response.attempts) {
val attempt = backupAttempt.toAttempt()
val existing = existingAttempts.find { it.id == backupAttempt.id }
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
if (existing != null) {
repository.updateAttempt(attempt)
} else {
repository.insertAttempt(attempt)
}
}
}
// Apply deletions
applyDeletions(response.deletedItems)
// Update deletion records
val allDeletions = repository.getDeletedItems() + response.deletedItems
repository.clearDeletedItems()
allDeletions.distinctBy { "${it.type}:${it.id}" }.forEach {
repository.trackDeletion(it.id, it.type)
}
} finally {
// Re-enable auto-sync
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
}
}
private suspend fun applyDeletions(
deletions: List<com.atridad.ascently.data.format.DeletedItem>
) {
val existingGyms = repository.getAllGyms().first()
val existingProblems = repository.getAllProblems().first()
val existingSessions = repository.getAllSessions().first()
val existingAttempts = repository.getAllAttempts().first()
for (item in deletions) {
when (item.type) {
"gym" -> {
existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) }
}
"problem" -> {
existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) }
}
"session" -> {
existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) }
}
"attempt" -> {
existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) }
}
}
}
}
private suspend fun syncModifiedImages(modifiedProblems: List<BackupProblem>) {
if (modifiedProblems.isEmpty()) return
Log.d(TAG, "Syncing images for ${modifiedProblems.size} modified problems")
for (backupProblem in modifiedProblems) {
backupProblem.imagePaths?.forEach { imagePath ->
val filename = imagePath.substringAfterLast('/')
uploadImage(imagePath, filename)
}
}
}
private suspend fun downloadData(): ClimbDataBackup { private suspend fun downloadData(): ClimbDataBackup {
val request = val request =
Request.Builder() Request.Builder()
@@ -277,8 +536,7 @@ 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(ClimbDataBackup.serializer(), backup) json.encodeToString(backup).toRequestBody("application/json".toMediaType())
.toRequestBody("application/json".toMediaType())
val request = val request =
Request.Builder() Request.Builder()
@@ -440,9 +698,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
backup.problems.map { backupProblem -> backup.problems.map { backupProblem ->
val imagePaths = backupProblem.imagePaths val imagePaths = backupProblem.imagePaths
val updatedImagePaths = val updatedImagePaths =
imagePaths?.map { oldPath -> imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: 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() }
@@ -544,26 +800,16 @@ 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.")
@JvmStatic private fun readResolve(): Any = NotConfigured
}
object NotConnected : SyncException("Not connected to server. Please test connection first.") { object NotConnected : SyncException("Not connected to server. Please test connection first.")
@JvmStatic private fun readResolve(): Any = NotConnected
}
object Unauthorized : SyncException("Unauthorized. Please check your auth token.") { object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
@JvmStatic private fun readResolve(): Any = Unauthorized
}
object ImageNotFound : SyncException("Image not found on server") { object ImageNotFound : SyncException("Image not found on server")
@JvmStatic private fun readResolve(): Any = ImageNotFound
}
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 DecodingError(val details: String) :
SyncException("Failed to decode 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

@@ -1,18 +1,19 @@
package com.atridad.ascently.ui.components package com.atridad.ascently.ui.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
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.filled.Close import androidx.compose.material.icons.automirrored.filled.ArrowBack
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
@@ -20,7 +21,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
@@ -29,25 +29,29 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) { fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
val context = LocalContext.current
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size }) val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
val thumbnailListState = rememberLazyListState() val thumbnailListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
// Handle back button press
BackHandler { onDismiss() }
// Auto-scroll thumbnail list to center current image // Auto-scroll thumbnail list to center current image
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200) if (imagePaths.size > 1) {
thumbnailListState.animateScrollToItem(
index = pagerState.currentPage,
scrollOffset = -200
)
}
} }
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = properties =
DialogProperties( DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true)
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) { ) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { Box(modifier = Modifier.fillMaxSize().background(Color.Black).systemBarsPadding()) {
// Main image pager // Main image pager
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
OrientationAwareImage( OrientationAwareImage(
@@ -58,76 +62,96 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
) )
} }
// Close button // Top bar with back button and counter
IconButton( Surface(
onClick = onDismiss, modifier = Modifier.fillMaxWidth().align(Alignment.TopStart),
color = Color.Black.copy(alpha = 0.6f)
) {
Row(
modifier = modifier =
Modifier.align(Alignment.TopEnd) Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp),
.padding(16.dp) verticalAlignment = Alignment.CenterVertically
.background(Color.Black.copy(alpha = 0.5f), CircleShape) ) {
) { Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) } // Back button
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close",
tint = Color.White
)
}
Spacer(modifier = Modifier.weight(1f))
// Image counter // Image counter
if (imagePaths.size > 1) { if (imagePaths.size > 1) {
Card(
modifier = Modifier.align(Alignment.TopCenter).padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.7f)
)
) {
Text( Text(
text = "${pagerState.currentPage + 1} / ${imagePaths.size}", text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
color = Color.White, color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) style = MaterialTheme.typography.bodyMedium
) )
} }
Spacer(modifier = Modifier.width(16.dp))
}
} }
// Thumbnail strip (if multiple images) // Thumbnail strip at bottom (if multiple images)
if (imagePaths.size > 1) { if (imagePaths.size > 1) {
Card( Surface(
modifier = modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
Modifier.align(Alignment.BottomCenter) color = Color.Black.copy(alpha = 0.6f)
.fillMaxWidth()
.padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.7f)
)
) { ) {
LazyRow( LazyRow(
state = thumbnailListState, state = thumbnailListState,
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 8.dp) contentPadding = PaddingValues(horizontal = 16.dp)
) { ) {
itemsIndexed(imagePaths) { index, imagePath -> itemsIndexed(imagePaths) { index, imagePath ->
val isSelected = index == pagerState.currentPage val isSelected = index == pagerState.currentPage
OrientationAwareImage( Box(
imagePath = imagePath,
contentDescription = "Thumbnail ${index + 1}",
modifier = modifier =
Modifier.size(60.dp) Modifier.size(48.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { .clickable {
coroutineScope.launch { coroutineScope.launch {
pagerState.animateScrollToPage(index) pagerState.animateScrollToPage(index)
} }
} }
.then( ) {
if (isSelected) { OrientationAwareImage(
Modifier.background( imagePath = imagePath,
Color.White.copy( contentDescription = "Thumbnail ${index + 1}",
alpha = 0.3f modifier = Modifier.fillMaxSize(),
),
RoundedCornerShape(8.dp)
)
} else Modifier
),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
// Selection indicator
if (isSelected) {
Box(
modifier =
Modifier.fillMaxSize()
.background(
Color.White.copy(alpha = 0.3f),
RoundedCornerShape(8.dp)
)
)
Box(
modifier =
Modifier.fillMaxSize()
.background(
Color.Transparent,
RoundedCornerShape(8.dp)
)
.clip(RoundedCornerShape(8.dp))
.background(
Color.White.copy(alpha = 0.2f)
)
)
}
}
} }
} }
} }

View File

@@ -20,7 +20,7 @@ fun ImageDisplay(
imageSize: Int = 120, imageSize: Int = 120,
onImageClick: ((Int) -> Unit)? = null onImageClick: ((Int) -> Unit)? = null
) { ) {
val context = LocalContext.current LocalContext.current
if (imagePaths.isNotEmpty()) { if (imagePaths.isNotEmpty()) {
LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {

View File

@@ -255,7 +255,7 @@ private fun createImageFile(context: android.content.Context): File {
@Composable @Composable
private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) { private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) {
val context = LocalContext.current val context = LocalContext.current
val imageFile = ImageUtils.getImageFile(context, imagePath) ImageUtils.getImageFile(context, imagePath)
Box(modifier = modifier.size(80.dp)) { Box(modifier = modifier.size(80.dp)) {
OrientationAwareImage( OrientationAwareImage(

View File

@@ -5,12 +5,15 @@ import android.graphics.Matrix
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.atridad.ascently.utils.ImageUtils import com.atridad.ascently.utils.ImageUtils
import java.io.File import java.io.File
@@ -20,8 +23,8 @@ import kotlinx.coroutines.withContext
@Composable @Composable
fun OrientationAwareImage( fun OrientationAwareImage(
imagePath: String, imagePath: String,
contentDescription: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit contentScale: ContentScale = ContentScale.Fit
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -42,7 +45,7 @@ fun OrientationAwareImage(
?: return@withContext null ?: return@withContext null
val correctedBitmap = correctImageOrientation(imageFile, originalBitmap) val correctedBitmap = correctImageOrientation(imageFile, originalBitmap)
correctedBitmap.asImageBitmap() correctedBitmap.asImageBitmap()
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
} }
@@ -52,7 +55,7 @@ fun OrientationAwareImage(
Box(modifier = modifier) { Box(modifier = modifier) {
if (isLoading) { if (isLoading) {
CircularProgressIndicator(modifier = Modifier.fillMaxSize()) CircularProgressIndicator(modifier = Modifier.size(32.dp).align(Alignment.Center))
} else { } else {
imageBitmap?.let { bitmap -> imageBitmap?.let { bitmap ->
Image( Image(
@@ -113,15 +116,7 @@ private fun correctImageOrientation(
needsTransform = true needsTransform = true
} }
else -> { else -> {
if (orientation == ExifInterface.ORIENTATION_UNDEFINED || orientation == 0) { // Default case - no transformation needed
if (imageFile.name.startsWith("problem_") &&
imageFile.name.contains("_") &&
imageFile.name.endsWith(".jpg")
) {
matrix.postRotate(90f)
needsTransform = true
}
}
} }
} }
@@ -143,7 +138,7 @@ private fun correctImageOrientation(
} }
rotatedBitmap rotatedBitmap
} }
} catch (e: Exception) { } catch (_: Exception) {
bitmap bitmap
} }
} }

View File

@@ -31,14 +31,14 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
// Collect flows // Collect flows
val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false) val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false)
val hasPermissions by healthConnectManager.hasPermissions.collectAsState(initial = false) val hasPermissions by healthConnectManager.hasPermissions.collectAsState(initial = false)
val autoSyncEnabled by healthConnectManager.autoSyncEnabled.collectAsState(initial = true)
val isCompatible by healthConnectManager.isCompatible.collectAsState(initial = true) val isCompatible by healthConnectManager.isCompatible.collectAsState(initial = true)
// Permission launcher // Permission launcher
val permissionLauncher = val permissionLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = healthConnectManager.getPermissionRequestContract() contract = healthConnectManager.getPermissionRequestContract()
) { grantedPermissions -> ) { _ ->
coroutineScope.launch { coroutineScope.launch {
val allGranted = healthConnectManager.hasAllPermissions() val allGranted = healthConnectManager.hasAllPermissions()
if (!allGranted) { if (!allGranted) {
@@ -128,12 +128,15 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
MaterialTheme.colorScheme.onSurfaceVariant.copy( MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f alpha = 0.7f
) )
!isCompatible -> MaterialTheme.colorScheme.error !isCompatible -> MaterialTheme.colorScheme.error
!isHealthConnectAvailable -> MaterialTheme.colorScheme.error !isHealthConnectAvailable -> MaterialTheme.colorScheme.error
isEnabled && hasPermissions -> isEnabled && hasPermissions ->
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
isEnabled && !hasPermissions -> isEnabled && !hasPermissions ->
MaterialTheme.colorScheme.tertiary MaterialTheme.colorScheme.tertiary
else -> else ->
MaterialTheme.colorScheme.onSurfaceVariant.copy( MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f alpha = 0.7f
@@ -146,9 +149,9 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
Switch( Switch(
checked = isEnabled, checked = isEnabled,
onCheckedChange = { enabled -> onCheckedChange = { enabled ->
coroutineScope.launch {
if (enabled && isHealthConnectAvailable) { if (enabled && isHealthConnectAvailable) {
healthConnectManager.setEnabled(true) healthConnectManager.setEnabled(true)
coroutineScope.launch {
try { try {
val permissionSet = val permissionSet =
healthConnectManager.getRequiredPermissions() healthConnectManager.getRequiredPermissions()
@@ -158,11 +161,11 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
} catch (e: Exception) { } catch (e: Exception) {
errorMessage = "Error requesting permissions: ${e.message}" errorMessage = "Error requesting permissions: ${e.message}"
} }
}
} else { } else {
healthConnectManager.setEnabled(false) healthConnectManager.setEnabled(false)
errorMessage = null errorMessage = null
} }
}
}, },
enabled = isHealthConnectAvailable && !isLoading && isCompatible enabled = isHealthConnectAvailable && !isLoading && isCompatible
) )
@@ -170,51 +173,46 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
if (isEnabled) { if (isEnabled) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Climbing sessions will be automatically added to Health Connect when completed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
if (!hasPermissions) {
Spacer(modifier = Modifier.height(12.dp))
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
if (hasPermissions) {
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.3f
)
} else {
MaterialTheme.colorScheme.errorContainer.copy( MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f alpha = 0.3f
) )
}
) )
) { ) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = imageVector = Icons.Default.Warning,
if (hasPermissions) Icons.Default.CheckCircle
else Icons.Default.Warning,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = tint = MaterialTheme.colorScheme.error
if (hasPermissions) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = text = "Permissions needed",
if (hasPermissions) "Ready to sync"
else "Permissions needed",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
if (!hasPermissions) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
@@ -249,50 +247,6 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
) { Text("Grant Permissions") } ) { Text("Grant Permissions") }
} }
} }
}
if (hasPermissions) {
Spacer(modifier = Modifier.height(12.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Auto-sync sessions",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Automatically sync completed climbing sessions",
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
)
}
Switch(
checked = autoSyncEnabled,
onCheckedChange = { enabled ->
healthConnectManager.setAutoSyncEnabled(enabled)
}
)
}
}
}
} else { } else {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
@@ -337,103 +291,6 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
} }
} }
} }
if (isEnabled) {
Spacer(modifier = Modifier.height(12.dp))
var testResult by remember { mutableStateOf<String?>(null) }
var isTestRunning by remember { mutableStateOf(false) }
OutlinedButton(
onClick = {
isTestRunning = true
coroutineScope.launch {
try {
testResult = healthConnectManager.testHealthConnectSync()
} catch (e: Exception) {
testResult = "Test failed: ${e.message}"
} finally {
isTestRunning = false
}
}
},
enabled = !isTestRunning,
modifier = Modifier.fillMaxWidth()
) {
if (isTestRunning) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(if (isTestRunning) "Testing..." else "Test Connection")
}
testResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.5f
)
)
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = "Debug Results:",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = result,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
}
}
}
}
}
}
@Composable
fun HealthConnectStatusBanner(isConnected: Boolean, modifier: Modifier = Modifier) {
if (isConnected) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.5f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.CloudDone,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Health Connect active - sessions will sync automatically",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
} }
} }
} }

View File

@@ -40,7 +40,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
emptyList() emptyList()
} else { } else {
selectedClimbTypes selectedClimbTypes
.flatMap { climbType -> DifficultySystem.getSystemsForClimbType(climbType) } .flatMap { climbType -> DifficultySystem.systemsForClimbType(climbType) }
.distinct() .distinct()
} }
@@ -89,7 +89,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
) )
if (isEditing) { if (isEditing) {
viewModel.updateGym(gym.copy(id = gymId!!)) viewModel.updateGym(gym.copy(id = gymId))
} else { } else {
viewModel.addGym(gym) viewModel.addGym(gym)
} }
@@ -164,7 +164,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
onCheckedChange = null onCheckedChange = null
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text(climbType.getDisplayName()) Text(climbType.displayName)
} }
} }
} }
@@ -219,7 +219,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
onCheckedChange = null onCheckedChange = null
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text(system.getDisplayName()) Text(system.displayName)
} }
} }
} }
@@ -248,7 +248,6 @@ fun AddEditProblemScreen(
) { ) {
val isEditing = problemId != null val isEditing = problemId != null
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val context = LocalContext.current
// Problem form state // Problem form state
var selectedGym by remember { var selectedGym by remember {
@@ -295,7 +294,7 @@ fun AddEditProblemScreen(
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList() val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
val availableDifficultySystems = val availableDifficultySystems =
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
selectedGym?.difficultySystems?.contains(system) != false selectedGym?.difficultySystems?.contains(system) != false
} }
@@ -324,7 +323,7 @@ fun AddEditProblemScreen(
// Reset grade when difficulty system changes (unless it's a valid grade for the new system) // Reset grade when difficulty system changes (unless it's a valid grade for the new system)
LaunchedEffect(selectedDifficultySystem) { LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades() val availableGrades = selectedDifficultySystem.availableGrades
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) { if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
difficultyGrade = "" difficultyGrade = ""
} }
@@ -387,12 +386,11 @@ fun AddEditProblemScreen(
) )
if (isEditing) { if (isEditing) {
viewModel.updateProblem( problemId.let { id ->
problem.copy(id = problemId), viewModel.updateProblem(problem.copy(id = id))
context }
)
} else { } else {
viewModel.addProblem(problem, context) viewModel.addProblem(problem)
} }
onNavigateBack() onNavigateBack()
} }
@@ -505,7 +503,7 @@ fun AddEditProblemScreen(
availableClimbTypes.forEach { climbType -> availableClimbTypes.forEach { climbType ->
FilterChip( FilterChip(
onClick = { selectedClimbType = climbType }, onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) }, label = { Text(climbType.displayName) },
selected = selectedClimbType == climbType selected = selectedClimbType == climbType
) )
} }
@@ -538,7 +536,7 @@ fun AddEditProblemScreen(
items(availableDifficultySystems) { system -> items(availableDifficultySystems) { system ->
FilterChip( FilterChip(
onClick = { selectedDifficultySystem = system }, onClick = { selectedDifficultySystem = system },
label = { Text(system.getDisplayName()) }, label = { Text(system.displayName) },
selected = selectedDifficultySystem == system selected = selectedDifficultySystem == system
) )
} }
@@ -570,7 +568,7 @@ fun AddEditProblemScreen(
) )
} else { } else {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val availableGrades = selectedDifficultySystem.getAvailableGrades() val availableGrades = selectedDifficultySystem.availableGrades
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded, expanded = expanded,
@@ -592,8 +590,7 @@ fun AddEditProblemScreen(
.outlinedTextFieldColors(), .outlinedTextFieldColors(),
modifier = modifier =
Modifier.menuAnchor( Modifier.menuAnchor(
androidx.compose.material3 ExposedDropdownMenuAnchorType
.MenuAnchorType
.PrimaryNotEditable, .PrimaryNotEditable,
enabled = true enabled = true
) )
@@ -765,9 +762,9 @@ fun AddEditSessionScreen(
null null
} }
) )
viewModel.updateSession( sessionId.let { id ->
session.copy(id = sessionId!!) viewModel.updateSession(session.copy(id = id))
) }
} else { } else {
viewModel.startSession( viewModel.startSession(
context, context,

View File

@@ -17,8 +17,8 @@ import com.atridad.ascently.ui.components.BarChart
import com.atridad.ascently.ui.components.BarChartDataPoint import com.atridad.ascently.ui.components.BarChartDataPoint
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 java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Composable @Composable
fun AnalyticsScreen(viewModel: ClimbViewModel) { fun AnalyticsScreen(viewModel: ClimbViewModel) {
@@ -253,11 +253,8 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
systemFiltered.filter { dataPoint -> systemFiltered.filter { dataPoint ->
try { try {
val attemptDate = val attemptDate =
LocalDateTime.parse( DateFormatUtils.parseToLocalDateTime(dataPoint.date)
dataPoint.date, attemptDate?.isAfter(sevenDaysAgo) == true
DateTimeFormatter.ISO_LOCAL_DATE_TIME
)
attemptDate.isAfter(sevenDaysAgo)
} catch (_: Exception) { } catch (_: Exception) {
// If date parsing fails, include the data point // If date parsing fails, include the data point
true true

View File

@@ -16,10 +16,8 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Share
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
@@ -30,16 +28,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewModelScope
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.theme.CustomIcons import com.atridad.ascently.ui.theme.CustomIcons
import com.atridad.ascently.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime import com.atridad.ascently.utils.DateFormatUtils
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -221,7 +216,6 @@ fun SessionDetailScreen(
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
var isGeneratingShare by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showAddAttemptDialog by remember { mutableStateOf(false) } var showAddAttemptDialog by remember { mutableStateOf(false) }
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) } var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) }
@@ -234,7 +228,7 @@ fun SessionDetailScreen(
val successfulAttempts = val successfulAttempts =
attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) } attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
val uniqueProblems = attempts.map { it.problemId }.distinct() val uniqueProblems = attempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in uniqueProblems }
val completedProblems = successfulAttempts.map { it.problemId }.distinct() val completedProblems = successfulAttempts.map { it.problemId }.distinct()
val attemptsWithProblems = val attemptsWithProblems =
@@ -261,64 +255,8 @@ fun SessionDetailScreen(
} }
}, },
actions = { actions = {
if (session?.duration != null) { // No manual actions needed - Health Connect syncs automatically when
val healthConnectManager = viewModel.getHealthConnectManager() // sessions complete
val isHealthConnectEnabled by
healthConnectManager.isEnabled.collectAsState(
initial = false
)
val hasPermissions by
healthConnectManager.hasPermissions.collectAsState(
initial = false
)
if (isHealthConnectEnabled && hasPermissions) {
IconButton(
onClick = {
viewModel.manualSyncToHealthConnect(sessionId)
}
) {
Icon(
imageVector = Icons.Default.HealthAndSafety,
contentDescription = "Sync to Health Connect",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
// Share button
if (session?.duration != null) { // Only show for completed sessions
IconButton(
onClick = {
isGeneratingShare = true
viewModel.viewModelScope.launch {
val shareFile =
viewModel.generateSessionShareCard(
context,
sessionId
)
isGeneratingShare = false
shareFile?.let { file ->
viewModel.shareSessionCard(context, file)
}
}
},
enabled = !isGeneratingShare
) {
if (isGeneratingShare) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share Session"
)
}
}
}
// Show stop icon for active sessions, delete icon for completed // Show stop icon for active sessions, delete icon for completed
// sessions // sessions
@@ -564,7 +502,7 @@ fun SessionDetailScreen(
viewModel.addAttempt(attempt) viewModel.addAttempt(attempt)
showAddAttemptDialog = false showAddAttemptDialog = false
}, },
onProblemCreated = { problem -> viewModel.addProblem(problem, context) } onProblemCreated = { problem -> viewModel.addProblem(problem) }
) )
} }
@@ -590,7 +528,7 @@ fun ProblemDetailScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToEdit: (String) -> Unit onNavigateToEdit: (String) -> Unit
) { ) {
val context = LocalContext.current
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showImageViewer by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) }
var selectedImageIndex by remember { mutableIntStateOf(0) } var selectedImageIndex by remember { mutableIntStateOf(0) }
@@ -665,7 +603,7 @@ fun ProblemDetailScreen(
problem?.let { p -> problem?.let { p ->
Text( Text(
text = text =
"${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}", "${p.difficulty.system.displayName}: ${p.difficulty.grade}",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@@ -674,7 +612,7 @@ fun ProblemDetailScreen(
problem?.let { p -> problem?.let { p ->
Text( Text(
text = p.climbType.getDisplayName(), text = p.climbType.displayName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -854,7 +792,7 @@ fun ProblemDetailScreen(
TextButton( TextButton(
onClick = { onClick = {
problem?.let { p -> problem?.let { p ->
viewModel.deleteProblem(p, context) viewModel.deleteProblem(p)
onNavigateBack() onNavigateBack()
} }
showDeleteDialog = false showDeleteDialog = false
@@ -1236,19 +1174,10 @@ fun GymDetailScreen(
} }
}, },
supportingContent = { supportingContent = {
val dateTime =
try {
LocalDateTime.parse(session.date)
} catch (_: Exception) {
null
}
val formattedDate = val formattedDate =
dateTime?.format( DateFormatUtils.formatDateForDisplay(
DateTimeFormatter.ofPattern( session.date
"MMM dd, yyyy"
) )
)
?: session.date
Text( Text(
"$formattedDate${sessionAttempts.size} attempts" "$formattedDate${sessionAttempts.size} attempts"
@@ -1463,7 +1392,7 @@ fun SessionAttemptCard(
Text( Text(
text = text =
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", "${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -1538,14 +1467,7 @@ fun SessionAttemptCard(
} }
private fun formatDate(dateString: String): String { private fun formatDate(dateString: String): String {
return try { return DateFormatUtils.formatDateForDisplay(dateString)
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val date = LocalDateTime.parse(dateString, formatter)
val displayFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy")
date.format(displayFormatter)
} catch (_: Exception) {
dateString.take(10) // Fallback to just the date part
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -1584,7 +1506,7 @@ fun EnhancedAddAttemptDialog(
// Auto-select difficulty system if there's only one available for the selected climb type // Auto-select difficulty system if there's only one available for the selected climb type
LaunchedEffect(selectedClimbType, gym.difficultySystems) { LaunchedEffect(selectedClimbType, gym.difficultySystems) {
val availableSystems = val availableSystems =
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
gym.difficultySystems.contains(system) gym.difficultySystems.contains(system)
} }
@@ -1604,7 +1526,7 @@ fun EnhancedAddAttemptDialog(
// Reset grade when difficulty system changes // Reset grade when difficulty system changes
LaunchedEffect(selectedDifficultySystem) { LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades() val availableGrades = selectedDifficultySystem.availableGrades
if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) { if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) {
newProblemGrade = "" newProblemGrade = ""
} }
@@ -1721,7 +1643,7 @@ fun EnhancedAddAttemptDialog(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = text =
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", "${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
style = style =
MaterialTheme.typography MaterialTheme.typography
.bodyMedium, .bodyMedium,
@@ -1730,7 +1652,7 @@ fun EnhancedAddAttemptDialog(
MaterialTheme MaterialTheme
.colorScheme .colorScheme
.onSurface.copy( .onSurface.copy(
alpha = 0.8f alpha = 0.9f
) )
else else
MaterialTheme MaterialTheme
@@ -1807,7 +1729,7 @@ fun EnhancedAddAttemptDialog(
onClick = { selectedClimbType = climbType }, onClick = { selectedClimbType = climbType },
label = { label = {
Text( Text(
climbType.getDisplayName(), climbType.displayName,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
}, },
@@ -1838,7 +1760,7 @@ fun EnhancedAddAttemptDialog(
) )
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val availableSystems = val availableSystems =
DifficultySystem.getSystemsForClimbType( DifficultySystem.systemsForClimbType(
selectedClimbType selectedClimbType
) )
.filter { system -> .filter { system ->
@@ -1849,7 +1771,7 @@ fun EnhancedAddAttemptDialog(
onClick = { selectedDifficultySystem = system }, onClick = { selectedDifficultySystem = system },
label = { label = {
Text( Text(
system.getDisplayName(), system.displayName,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
}, },
@@ -1926,8 +1848,7 @@ fun EnhancedAddAttemptDialog(
) )
} else { } else {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val availableGrades = val availableGrades = selectedDifficultySystem.availableGrades
selectedDifficultySystem.getAvailableGrades()
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded, expanded = expanded,
@@ -1949,8 +1870,7 @@ fun EnhancedAddAttemptDialog(
.outlinedTextFieldColors(), .outlinedTextFieldColors(),
modifier = modifier =
Modifier.menuAnchor( Modifier.menuAnchor(
androidx.compose.material3 ExposedDropdownMenuAnchorType
.MenuAnchorType
.PrimaryNotEditable, .PrimaryNotEditable,
enabled = true enabled = true
) )

View File

@@ -87,7 +87,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
gym.supportedClimbTypes.forEach { climbType -> gym.supportedClimbTypes.forEach { climbType ->
AssistChip( AssistChip(
onClick = {}, onClick = {},
label = { Text(climbType.getDisplayName()) }, label = { Text(climbType.displayName) },
modifier = Modifier.padding(end = 4.dp) modifier = Modifier.padding(end = 4.dp)
) )
} }
@@ -97,7 +97,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = text =
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}", "Systems: ${gym.difficultySystems.joinToString(", ") { it.displayName }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )

View File

@@ -11,7 +11,6 @@ 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.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
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -30,7 +29,6 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val attempts by viewModel.attempts.collectAsState() val attempts by viewModel.attempts.collectAsState()
val context = LocalContext.current
// Filter state // Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) } var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
@@ -104,7 +102,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
items(ClimbType.entries) { climbType -> items(ClimbType.entries) { climbType ->
FilterChip( FilterChip(
onClick = { selectedClimbType = climbType }, onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) }, label = { Text(climbType.displayName) },
selected = selectedClimbType == climbType selected = selectedClimbType == climbType
) )
} }
@@ -183,7 +181,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
onClick = { onNavigateToProblemDetail(problem.id) }, onClick = { onNavigateToProblemDetail(problem.id) },
onToggleActive = { onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive) val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem, context) viewModel.updateProblem(updatedProblem)
} }
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -268,7 +266,7 @@ fun ProblemCard(
} }
Text( Text(
text = problem.climbType.getDisplayName(), text = problem.climbType.displayName,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )

View File

@@ -22,8 +22,7 @@ import com.atridad.ascently.data.model.SessionStatus
import com.atridad.ascently.ui.components.ActiveSessionBanner 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 java.time.LocalDateTime import com.atridad.ascently.utils.DateFormatUtils
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -247,10 +246,5 @@ fun EmptyStateMessage(
} }
private fun formatDate(dateString: String): String { private fun formatDate(dateString: String): String {
return try { return DateFormatUtils.formatDateForDisplay(dateString)
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
} catch (_: Exception) {
dateString
}
} }

View File

@@ -50,15 +50,15 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
var isDeletingImages by remember { mutableStateOf(false) } var isDeletingImages by remember { mutableStateOf(false) }
// Sync configuration state // Sync configuration state
var serverUrl by remember { mutableStateOf(syncService.serverURL) } var serverUrl by remember { mutableStateOf(syncService.serverUrl) }
var authToken by remember { mutableStateOf(syncService.authToken) } var authToken by remember { mutableStateOf(syncService.authToken) }
val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) } val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) }
val appVersion = packageInfo.versionName val appVersion = packageInfo.versionName
// Update local state when sync service configuration changes // Update local state when sync service configuration changes
LaunchedEffect(syncService.serverURL, syncService.authToken) { LaunchedEffect(syncService.serverUrl, syncService.authToken) {
serverUrl = syncService.serverURL serverUrl = syncService.serverUrl
authToken = syncService.authToken authToken = syncService.authToken
} }
@@ -183,7 +183,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
}, },
supportingContent = { supportingContent = {
Column { Column {
Text("Server: ${syncService.serverURL}") Text("Server: ${syncService.serverUrl}")
lastSyncTime?.let { time -> lastSyncTime?.let { time ->
Text( Text(
"Last sync: ${ "Last sync: ${
@@ -863,7 +863,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
onClick = { onClick = {
coroutineScope.launch { coroutineScope.launch {
try { try {
syncService.serverURL = serverUrl.trim() syncService.serverUrl = serverUrl.trim()
syncService.authToken = authToken.trim() syncService.authToken = authToken.trim()
viewModel.testSyncConnection() viewModel.testSyncConnection()
while (syncService.isTesting.value) { while (syncService.isTesting.value) {
@@ -905,7 +905,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
onClick = { onClick = {
coroutineScope.launch { coroutineScope.launch {
try { try {
syncService.serverURL = serverUrl.trim() syncService.serverUrl = serverUrl.trim()
syncService.authToken = authToken.trim() syncService.authToken = authToken.trim()
viewModel.testSyncConnection() viewModel.testSyncConnection()
while (syncService.isTesting.value) { while (syncService.isTesting.value) {
@@ -932,7 +932,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
dismissButton = { dismissButton = {
TextButton( TextButton(
onClick = { onClick = {
serverUrl = syncService.serverURL serverUrl = syncService.serverUrl
authToken = syncService.authToken authToken = syncService.authToken
showSyncConfigDialog = false showSyncConfigDialog = false
} }
@@ -981,7 +981,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
isDeletingImages = true isDeletingImages = true
showDeleteImagesDialog = false showDeleteImagesDialog = false
coroutineScope.launch { coroutineScope.launch {
viewModel.deleteAllImages(context) viewModel.deleteAllImages()
isDeletingImages = false isDeletingImages = false
viewModel.setMessage("All images deleted successfully!") viewModel.setMessage("All images deleted successfully!")
} }

View File

@@ -8,15 +8,11 @@ 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.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils import com.atridad.ascently.utils.ImageUtils
import com.atridad.ascently.utils.SessionShareUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ClimbViewModel( class ClimbViewModel(
private val repository: ClimbRepository, private val repository: ClimbRepository,
@@ -78,64 +74,57 @@ class ClimbViewModel(
) )
// Gym operations // Gym operations
fun addGym(gym: Gym) { fun addGym(gym: Gym, updateWidgets: Boolean = true) {
viewModelScope.launch { repository.insertGym(gym) }
}
fun addGym(gym: Gym, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertGym(gym) repository.insertGym(gym)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun updateGym(gym: Gym) {
viewModelScope.launch { repository.updateGym(gym) }
} }
fun updateGym(gym: Gym, context: Context) { fun updateGym(gym: Gym, updateWidgets: Boolean = true) {
viewModelScope.launch { viewModelScope.launch {
repository.updateGym(gym) repository.updateGym(gym)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun deleteGym(gym: Gym) {
viewModelScope.launch { repository.deleteGym(gym) }
} }
fun deleteGym(gym: Gym, context: Context) { fun deleteGym(gym: Gym, updateWidgets: Boolean = true) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteGym(gym) repository.deleteGym(gym)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
}
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) } fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
// Problem operations // Problem operations
fun addProblem(problem: Problem, context: Context) { fun addProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch { viewModelScope.launch {
val finalProblem = renameTemporaryImages(problem, context) val finalProblem = renameTemporaryImages(problem)
repository.insertProblem(finalProblem) repository.insertProblem(finalProblem)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback }
} }
} }
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem { private fun renameTemporaryImages(problem: Problem): Problem {
if (problem.imagePaths.isEmpty()) { if (problem.imagePaths.isEmpty()) {
return problem return problem
} }
val appContext = context ?: return problem
val finalImagePaths = mutableListOf<String>() val finalImagePaths = mutableListOf<String>()
problem.imagePaths.forEachIndexed { index, tempPath -> problem.imagePaths.forEachIndexed { index, tempPath ->
if (tempPath.startsWith("temp_")) { if (tempPath.startsWith("temp_")) {
val deterministicName = ImageNamingUtils.generateImageFilename(problem.id, index)
val finalPath = val finalPath =
ImageUtils.renameTemporaryImage(appContext, 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)
@@ -145,34 +134,34 @@ class ClimbViewModel(
return problem.copy(imagePaths = finalImagePaths) return problem.copy(imagePaths = finalImagePaths)
} }
fun updateProblem(problem: Problem, context: Context) { fun updateProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch { viewModelScope.launch {
val finalProblem = renameTemporaryImages(problem, context) val finalProblem = renameTemporaryImages(problem)
repository.updateProblem(finalProblem) repository.updateProblem(finalProblem)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
}
fun deleteProblem(problem: Problem, context: Context) { fun deleteProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch { viewModelScope.launch {
// Delete associated images
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) } problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
repository.deleteProblem(problem) repository.deleteProblem(problem)
cleanupOrphanedImages()
cleanupOrphanedImages(context) if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
}
private suspend fun cleanupOrphanedImages(context: Context) { private suspend fun cleanupOrphanedImages() {
val allProblems = repository.getAllProblems().first() val allProblems = repository.getAllProblems().first()
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths) ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
} }
fun deleteAllImages(context: Context) { fun deleteAllImages() {
viewModelScope.launch { viewModelScope.launch {
val imagesDir = ImageUtils.getImagesDirectory(context) val imagesDir = ImageUtils.getImagesDirectory(context)
var deletedCount = 0 var deletedCount = 0
@@ -212,38 +201,24 @@ class ClimbViewModel(
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId) fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
// Session operations // Session operations
fun addSession(session: ClimbSession) {
viewModelScope.launch { repository.insertSession(session) }
}
fun addSession(session: ClimbSession, context: Context) { fun updateSession(session: ClimbSession, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.insertSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
fun updateSession(session: ClimbSession) {
viewModelScope.launch { repository.updateSession(session) }
}
fun updateSession(session: ClimbSession, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.updateSession(session) repository.updateSession(session)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun deleteSession(session: ClimbSession) {
viewModelScope.launch { repository.deleteSession(session) }
} }
fun deleteSession(session: ClimbSession, context: Context) { fun deleteSession(session: ClimbSession, updateWidgets: Boolean = true) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteSession(session) repository.deleteSession(session)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
}
fun getSessionById(id: String): Flow<ClimbSession?> = flow { fun getSessionById(id: String): Flow<ClimbSession?> = flow {
emit(repository.getSessionById(id)) emit(repository.getSessionById(id))
@@ -345,38 +320,32 @@ class ClimbViewModel(
} }
// Attempt operations // Attempt operations
fun addAttempt(attempt: Attempt) { fun addAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
viewModelScope.launch { repository.insertAttempt(attempt) }
}
fun addAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertAttempt(attempt) repository.insertAttempt(attempt)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun deleteAttempt(attempt: Attempt) {
viewModelScope.launch { repository.deleteAttempt(attempt) }
} }
fun deleteAttempt(attempt: Attempt, context: Context) { fun deleteAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteAttempt(attempt) repository.deleteAttempt(attempt)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun updateAttempt(attempt: Attempt) {
viewModelScope.launch { repository.updateAttempt(attempt) }
} }
fun updateAttempt(attempt: Attempt, context: Context) { fun updateAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
viewModelScope.launch { viewModelScope.launch {
repository.updateAttempt(attempt) repository.updateAttempt(attempt)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context) ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
}
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId) repository.getAttemptsBySession(sessionId)
@@ -499,106 +468,27 @@ class ClimbViewModel(
val attempts = repository.getAttemptsBySession(session.id).first() val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size val attemptCount = attempts.size
val result = healthConnectManager.autoSyncSession(session, gymName, attemptCount)
result
.onSuccess {
_uiState.value =
_uiState.value.copy(
message =
"Session synced to Health Connect successfully!"
)
}
.onFailure { error ->
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(
error =
"Failed to sync to Health Connect: ${error.message}"
)
}
}
} catch (e: Exception) {
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
}
}
}
}
fun manualSyncToHealthConnect(sessionId: String) {
viewModelScope.launch {
try {
val session = repository.getSessionById(sessionId)
if (session == null) {
_uiState.value = _uiState.value.copy(error = "Session not found")
return@launch
}
if (session.status != SessionStatus.COMPLETED) {
_uiState.value =
_uiState.value.copy(error = "Only completed sessions can be synced")
return@launch
}
val gym = repository.getGymById(session.gymId)
val gymName = gym?.name ?: "Unknown Gym"
val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size
val result = val result =
healthConnectManager.syncClimbingSession(session, gymName, attemptCount) healthConnectManager.autoSyncCompletedSession(
session,
gymName,
attemptCount
)
result result.onFailure { error ->
.onSuccess { if (healthConnectManager.isReadySync()) {
_uiState.value = android.util.Log.w(
_uiState.value.copy( "ClimbViewModel",
message = "Health Connect sync failed: ${error.message}"
"Session synced to Health Connect successfully!"
) )
} }
.onFailure { error ->
_uiState.value =
_uiState.value.copy(
error =
"Failed to sync to Health Connect: ${error.message}"
)
} }
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = if (healthConnectManager.isReadySync()) {
_uiState.value.copy(error = "Health Connect sync error: ${e.message}") android.util.Log.w("ClimbViewModel", "Health Connect sync error: ${e.message}")
} }
} }
} }
fun getHealthConnectManager(): HealthConnectManager = healthConnectManager
// Share operations
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
withContext(Dispatchers.IO) {
try {
val session = repository.getSessionById(sessionId) ?: return@withContext null
val attempts = repository.getAttemptsBySession(sessionId).first()
val problems =
repository.getAllProblems().first().filter { problem ->
attempts.any { it.problemId == problem.id }
}
val gym = repository.getGymById(session.gymId) ?: return@withContext null
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
SessionShareUtils.generateShareCard(context, session, gym, stats)
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(
error = "Failed to generate share card: ${e.message}"
)
null
}
}
fun shareSessionCard(context: Context, imageFile: File) {
SessionShareUtils.shareSessionCard(context, imageFile)
} }
} }

View File

@@ -1,6 +1,8 @@
package com.atridad.ascently.utils package com.atridad.ascently.utils
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -15,32 +17,47 @@ object DateFormatUtils {
return ISO_FORMATTER.format(Instant.now()) return ISO_FORMATTER.format(Instant.now())
} }
/** Format an Instant to iOS-compatible ISO 8601 format */
fun formatISO8601(instant: Instant): String {
return ISO_FORMATTER.format(instant)
}
/** Parse an iOS-compatible ISO 8601 date string back to Instant */ /** Parse an iOS-compatible ISO 8601 date string back to Instant */
fun parseISO8601(dateString: String): Instant? { fun parseISO8601(dateString: String): Instant? {
return try { return try {
Instant.from(ISO_FORMATTER.parse(dateString)) Instant.from(ISO_FORMATTER.parse(dateString))
} catch (e: Exception) { } catch (_: Exception) {
try { try {
Instant.parse(dateString) Instant.parse(dateString)
} catch (e2: Exception) { } catch (_: Exception) {
null null
} }
} }
} }
/** Validate that a date string matches the expected iOS format */ /**
fun isValidISO8601(dateString: String): Boolean { * Format a UTC ISO 8601 date string for display in local timezone This fixes the timezone
return parseISO8601(dateString) != null * display issue where UTC dates were shown as local dates
*/
fun formatDateForDisplay(dateString: String): String {
val pattern = "MMM dd, yyyy"
return try {
val instant = parseISO8601(dateString)
if (instant != null) {
val localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault())
localDateTime.format(DateTimeFormatter.ofPattern(pattern))
} else {
// Fallback for malformed dates
dateString.take(10)
}
} catch (_: Exception) {
dateString.take(10)
}
} }
/** Convert milliseconds timestamp to iOS-compatible ISO 8601 format */ /** Parse a UTC ISO 8601 date string to LocalDateTime in system timezone */
fun millisToISO8601(millis: Long): String { fun parseToLocalDateTime(dateString: String): LocalDateTime? {
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis)) return try {
val instant = parseISO8601(dateString)
instant?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) }
} catch (_: Exception) {
null
}
} }
} }

View File

@@ -1,7 +1,6 @@
package com.atridad.ascently.utils package com.atridad.ascently.utils
import java.security.MessageDigest import java.security.MessageDigest
import java.util.*
/** /**
* Utility for creating consistent image filenames across iOS and Android platforms. Uses * Utility for creating consistent image filenames across iOS and Android platforms. Uses
@@ -21,51 +20,11 @@ object ImageNamingUtils {
} }
/** Legacy method for backward compatibility */ /** Legacy method for backward compatibility */
@Suppress("UNUSED_PARAMETER")
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String { fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
return generateImageFilename(problemId, imageIndex) return generateImageFilename(problemId, imageIndex)
} }
/** Extracts problem ID from an image filename */
fun extractProblemIdFromFilename(filename: String): String? {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return null
}
// Format: problem_{hash}_{index}.jpg
val nameWithoutExtension = filename.substring(0, filename.length - IMAGE_EXTENSION.length)
val parts = nameWithoutExtension.split("_")
if (parts.size != 3 || parts[0] != "problem") {
return null
}
return parts[1]
}
/** Validates if a filename follows our naming convention */
fun isValidImageFilename(filename: String): Boolean {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return false
}
val nameWithoutExtension = filename.substring(0, filename.length - IMAGE_EXTENSION.length)
val parts = nameWithoutExtension.split("_")
return parts.size == 3 &&
parts[0] == "problem" &&
parts[1].length == HASH_LENGTH &&
parts[2].toIntOrNull() != null
}
/** Migrates an existing filename to our naming convention */
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
if (isValidImageFilename(oldFilename)) {
return oldFilename
}
return generateImageFilename(problemId, imageIndex)
}
/** Creates a deterministic hash from input string */ /** Creates a deterministic hash from input string */
private fun createHash(input: String): String { private fun createHash(input: String): String {
val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
@@ -74,53 +33,5 @@ object ImageNamingUtils {
return hashHex.take(HASH_LENGTH) return hashHex.take(HASH_LENGTH)
} }
/** Batch renames images for a problem to use our naming convention */
fun batchRenameForProblem(
problemId: String,
existingFilenames: List<String>
): Map<String, String> {
val renameMap = mutableMapOf<String, String>()
existingFilenames.forEachIndexed { index, oldFilename ->
val newFilename = generateImageFilename(problemId, index)
if (newFilename != oldFilename) {
renameMap[oldFilename] = newFilename
}
}
return renameMap
}
/** Generates the canonical filename for a problem image */
fun getCanonicalImageFilename(problemId: String, imageIndex: Int): String {
return generateImageFilename(problemId, imageIndex)
}
/** Creates a mapping of existing server filenames to canonical filenames */ /** Creates a mapping of existing server filenames to canonical filenames */
fun createServerMigrationMap(
problemId: String,
serverImageFilenames: List<String>,
localImageCount: Int
): Map<String, String> {
val migrationMap = mutableMapOf<String, String>()
for (imageIndex in 0 until localImageCount) {
val canonicalName = getCanonicalImageFilename(problemId, imageIndex)
if (serverImageFilenames.contains(canonicalName)) {
continue
}
for (serverFilename in serverImageFilenames) {
if (isValidImageFilename(serverFilename) &&
!migrationMap.values.contains(serverFilename)
) {
migrationMap[serverFilename] = canonicalName
break
}
}
}
return migrationMap
}
} }

View File

@@ -4,8 +4,8 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.core.graphics.scale import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
@@ -78,101 +78,6 @@ object ImageUtils {
} }
} }
/** Saves an image from a URI with compression */
fun saveImageFromUri(
context: Context,
imageUri: Uri,
problemId: String? = null,
imageIndex: Int? = null
): String? {
return try {
val originalBitmap =
context.contentResolver.openInputStream(imageUri)?.use { input ->
BitmapFactory.decodeStream(input)
}
?: return null
// Always require deterministic naming
require(problemId != null && imageIndex != null) {
"Problem ID and image index are required for deterministic image naming"
}
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val imageFile = File(getImagesDirectory(context), filename)
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
originalBitmap.recycle()
if (!success) return null
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/** Corrects image orientation based on EXIF data */
private fun correctImageOrientation(context: Context, imageUri: Uri, bitmap: Bitmap): Bitmap {
return try {
val inputStream = context.contentResolver.openInputStream(imageUri)
inputStream?.use { input ->
val exif = androidx.exifinterface.media.ExifInterface(input)
val orientation =
exif.getAttributeInt(
androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL
)
val matrix = android.graphics.Matrix()
when (orientation) {
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
}
}
if (matrix.isIdentity) {
bitmap
} else {
android.graphics.Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true
)
}
}
?: bitmap
} catch (e: Exception) {
e.printStackTrace()
bitmap
}
}
/** Compresses and resizes an image bitmap */ /** Compresses and resizes an image bitmap */
@SuppressLint("UseKtx") @SuppressLint("UseKtx")
private fun compressImage(original: Bitmap): Bitmap { private fun compressImage(original: Bitmap): Bitmap {
@@ -260,9 +165,8 @@ object ImageUtils {
/** Temporarily saves an image during selection process */ /** Temporarily saves an image during selection process */
fun saveTemporaryImageFromUri(context: Context, imageUri: Uri): String? { fun saveTemporaryImageFromUri(context: Context, imageUri: Uri): String? {
return try { return try {
val originalBitmap = val source = ImageDecoder.createSource(context.contentResolver, imageUri)
MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri) val originalBitmap = ImageDecoder.decodeBitmap(source)
?: return null
val tempFilename = "temp_${UUID.randomUUID()}.jpg" val tempFilename = "temp_${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), tempFilename) val imageFile = File(getImagesDirectory(context), tempFilename)
@@ -313,34 +217,6 @@ object ImageUtils {
} }
} }
/** Saves an image from byte array to app's private storage */
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
return try {
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap)
// Generate unique filename
val filename = "${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), filename)
// Save compressed image
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Clean up bitmaps
bitmap.recycle()
compressedBitmap.recycle()
// Return relative path
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/** Saves image data with a specific filename */ /** Saves image data with a specific filename */
fun saveImageFromBytesWithFilename( fun saveImageFromBytesWithFilename(
context: Context, context: Context,
@@ -391,52 +267,6 @@ object ImageUtils {
} }
} }
/** Migrates existing images to use consistent naming convention */
fun migrateImageNaming(
context: Context,
problemId: String,
currentImagePaths: List<String>
): Map<String, String> {
val migrationMap = mutableMapOf<String, String>()
currentImagePaths.forEachIndexed { index, oldPath ->
val oldFilename = oldPath.substringAfterLast('/')
val newFilename = ImageNamingUtils.migrateFilename(oldFilename, problemId, index)
if (oldFilename != newFilename) {
try {
val oldFile = getImageFile(context, oldPath)
val newFile = File(getImagesDirectory(context), newFilename)
if (oldFile.exists() && oldFile.renameTo(newFile)) {
val newPath = "$IMAGES_DIR/$newFilename"
migrationMap[oldPath] = newPath
}
} catch (e: Exception) {
// Log error but continue with other images
e.printStackTrace()
}
}
}
return migrationMap
}
/** Batch migrates all images in the system to use consistent naming */
fun batchMigrateAllImages(
context: Context,
problemImageMap: Map<String, List<String>>
): Map<String, String> {
val allMigrations = mutableMapOf<String, String>()
problemImageMap.forEach { (problemId, imagePaths) ->
val migrations = migrateImageNaming(context, problemId, imagePaths)
allMigrations.putAll(migrations)
}
return allMigrations
}
/** Cleans up orphaned images that are not referenced by any problems */ /** Cleans up orphaned images that are not referenced by any problems */
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) { fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
try { try {

View File

@@ -5,10 +5,6 @@ import android.content.SharedPreferences
import android.util.Log 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 {

View File

@@ -1,543 +0,0 @@
package com.atridad.ascently.utils
import android.content.Context
import android.content.Intent
import android.graphics.*
import android.graphics.drawable.GradientDrawable
import androidx.core.content.FileProvider
import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt
import com.atridad.ascently.data.model.*
import java.io.File
import java.io.FileOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
object SessionShareUtils {
data class SessionStats(
val totalAttempts: Int,
val successfulAttempts: Int,
val problems: List<Problem>,
val uniqueProblemsAttempted: Int,
val uniqueProblemsCompleted: Int,
val averageGrade: String?,
val sessionDuration: String,
val topResult: AttemptResult?,
val topGrade: String?
)
fun calculateSessionStats(
session: ClimbSession,
attempts: List<Attempt>,
problems: List<Problem>
): SessionStats {
val successfulResults = listOf(AttemptResult.SUCCESS, AttemptResult.FLASH)
val successfulAttempts = attempts.filter { it.result in successfulResults }
val uniqueProblems = attempts.map { it.problemId }.distinct()
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in uniqueProblems }
// Calculate separate averages for different climbing types and difficulty systems
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder")
val ropeAverage = calculateAverageGrade(ropeProblems, "Rope")
// Combine averages for display
val averageGrade =
when {
boulderAverage != null && ropeAverage != null ->
"$boulderAverage / $ropeAverage"
boulderAverage != null -> boulderAverage
ropeAverage != null -> ropeAverage
else -> null
}
// Determine highest achieved grade (only from completed problems: SUCCESS or FLASH)
val completedProblems = problems.filter { it.id in uniqueCompletedProblems }
val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER }
val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE }
val topBoulder = highestGradeForProblems(completedBoulder)
val topRope = highestGradeForProblems(completedRope)
val topGrade =
when {
topBoulder != null && topRope != null -> "$topBoulder / $topRope"
topBoulder != null -> topBoulder
topRope != null -> topRope
else -> null
}
val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
val topResult =
attempts
.maxByOrNull {
when (it.result) {
AttemptResult.FLASH -> 3
AttemptResult.SUCCESS -> 2
AttemptResult.FALL -> 1
else -> 0
}
}
?.result
return SessionStats(
totalAttempts = attempts.size,
successfulAttempts = successfulAttempts.size,
problems = attemptedProblems,
uniqueProblemsAttempted = uniqueProblems.size,
uniqueProblemsCompleted = uniqueCompletedProblems.size,
averageGrade = averageGrade,
sessionDuration = duration,
topResult = topResult,
topGrade = topGrade
)
}
/**
* Calculate average grade for a specific set of problems, respecting their difficulty systems
*/
private fun calculateAverageGrade(problems: List<Problem>, climbingType: String): String? {
if (problems.isEmpty()) return null
// Group problems by difficulty system
val problemsBySystem = problems.groupBy { it.difficulty.system }
val averages = mutableListOf<String>()
problemsBySystem.forEach { (system, systemProblems) ->
when (system) {
DifficultySystem.V_SCALE -> {
val gradeValues =
systemProblems.mapNotNull { problem ->
when {
problem.difficulty.grade == "VB" -> 0
else -> problem.difficulty.grade.removePrefix("V").toIntOrNull()
}
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt()
averages.add(if (avg == 0) "VB" else "V$avg")
}
}
DifficultySystem.FONT -> {
val gradeValues =
systemProblems.mapNotNull { problem ->
// Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" ->
// 7)
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt()
averages.add("$avg")
}
}
DifficultySystem.YDS -> {
val gradeValues =
systemProblems.mapNotNull { problem ->
// Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10)
val grade = problem.difficulty.grade
if (grade.startsWith("5.")) {
grade.substring(2).toDoubleOrNull()
} else null
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average()
averages.add("5.${String.format("%.1f", avg)}")
}
}
DifficultySystem.CUSTOM -> {
// For custom systems, try to extract numeric values
val gradeValues =
systemProblems.mapNotNull { problem ->
problem.difficulty
.grade
.filter { it.isDigit() || it == '.' || it == '-' }
.toDoubleOrNull()
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average()
averages.add(String.format("%.1f", avg))
}
}
}
}
return if (averages.isNotEmpty()) {
if (averages.size == 1) {
averages.first()
} else {
averages.joinToString(" / ")
}
} else null
}
fun generateShareCard(
context: Context,
session: ClimbSession,
gym: Gym,
stats: SessionStats
): File? {
return try {
val width = 1242 // 3:4 aspect at higher resolution for better fit
val height = 1656
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val gradientDrawable =
GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf("#667eea".toColorInt(), "#764ba2".toColorInt())
)
gradientDrawable.setBounds(0, 0, width, height)
gradientDrawable.draw(canvas)
// Setup paint objects
val titlePaint =
Paint().apply {
color = Color.WHITE
textSize = 72f
typeface = Typeface.DEFAULT_BOLD
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val subtitlePaint =
Paint().apply {
color = "#E8E8E8".toColorInt()
textSize = 48f
typeface = Typeface.DEFAULT
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val statLabelPaint =
Paint().apply {
color = "#B8B8B8".toColorInt()
textSize = 36f
typeface = Typeface.DEFAULT
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val statValuePaint =
Paint().apply {
color = Color.WHITE
textSize = 64f
typeface = Typeface.DEFAULT_BOLD
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val cardPaint =
Paint().apply {
color = "#40FFFFFF".toColorInt()
isAntiAlias = true
}
// Draw main card background
val cardRect = RectF(60f, 200f, width - 60f, height - 120f)
canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
// Draw content
var yPosition = 300f
// Title
canvas.drawText("Climbing Session", width / 2f, yPosition, titlePaint)
yPosition += 80f
// Gym and date
canvas.drawText(gym.name, width / 2f, yPosition, subtitlePaint)
yPosition += 60f
val dateText = formatSessionDate(session.date)
canvas.drawText(dateText, width / 2f, yPosition, subtitlePaint)
yPosition += 120f
// Stats grid
val statsStartY = yPosition
val columnWidth = width / 2f
val columnMaxTextWidth = columnWidth - 120f
// Left column stats
var leftY = statsStartY
drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Attempts",
stats.totalAttempts.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
leftY += 120f
drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Problems",
stats.uniqueProblemsAttempted.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
leftY += 120f
drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Duration",
stats.sessionDuration,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
// Right column stats
var rightY = statsStartY
drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rightY,
"Completed",
stats.uniqueProblemsCompleted.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
rightY += 120f
var rightYAfter = rightY
stats.topGrade?.let { grade ->
drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rightY,
"Top Grade",
grade,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
rightYAfter += 120f
}
// Grade range(s)
val boulderRange =
gradeRangeForProblems(
stats.problems.filter { it.climbType == ClimbType.BOULDER }
)
val ropeRange =
gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE })
val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f
if (boulderRange != null && ropeRange != null) {
// Two evenly spaced items
drawStatItemFitting(
canvas,
columnWidth / 2f,
rangesY,
"Boulder Range",
boulderRange,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rangesY,
"Rope Range",
ropeRange,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
} else if (boulderRange != null || ropeRange != null) {
// Single centered item
val singleRange = boulderRange ?: ropeRange ?: ""
drawStatItemFitting(
canvas,
width / 2f,
rangesY,
"Grade Range",
singleRange,
statLabelPaint,
statValuePaint,
width - 200f
)
}
// App branding
val brandingPaint =
Paint().apply {
color = "#80FFFFFF".toColorInt()
textSize = 32f
typeface = Typeface.DEFAULT
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
canvas.drawText("Ascently", width / 2f, height - 40f, brandingPaint)
// Save to file
val shareDir = File(context.cacheDir, "shares")
if (!shareDir.exists()) {
shareDir.mkdirs()
}
val filename = "session_${session.id}_${System.currentTimeMillis()}.png"
val file = File(shareDir, filename)
val outputStream = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
outputStream.flush()
outputStream.close()
bitmap.recycle()
file
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun drawStatItem(
canvas: Canvas,
x: Float,
y: Float,
label: String,
value: String,
labelPaint: Paint,
valuePaint: Paint
) {
canvas.drawText(value, x, y, valuePaint)
canvas.drawText(label, x, y + 50f, labelPaint)
}
/**
* Draws a stat item while fitting the value text to a max width by reducing text size if
* needed.
*/
private fun drawStatItemFitting(
canvas: Canvas,
x: Float,
y: Float,
label: String,
value: String,
labelPaint: Paint,
valuePaint: Paint,
maxTextWidth: Float
) {
val tempPaint = Paint(valuePaint)
var textSize = tempPaint.textSize
var textWidth = tempPaint.measureText(value)
while (textWidth > maxTextWidth && textSize > 36f) {
textSize -= 2f
tempPaint.textSize = textSize
textWidth = tempPaint.measureText(value)
}
canvas.drawText(value, x, y, tempPaint)
canvas.drawText(label, x, y + 50f, labelPaint)
}
/**
* Returns a range string like "X - Y" for the given problems, based on their difficulty grades.
*/
private fun gradeRangeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
val grades = problems.map { it.difficulty }
val sorted = grades.sortedWith { a, b -> a.compareTo(b) }
return "${sorted.first().grade} - ${sorted.last().grade}"
}
private fun formatSessionDate(dateString: String): String {
return try {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val date = LocalDateTime.parse(dateString, formatter)
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
date.format(displayFormatter)
} catch (_: Exception) {
dateString.take(10)
}
}
fun shareSessionCard(context: Context, imageFile: File) {
try {
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
imageFile
)
val shareIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! #Ascently")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(shareIntent, "Share Session")
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* Returns the highest grade string among the given problems, respecting their difficulty
* system.
*/
private fun highestGradeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
return problems
.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }
?.difficulty
?.grade
}
/** Produces a comparable numeric rank for grades across supported systems. */
private fun gradeRank(system: DifficultySystem, grade: String): Double {
return when (system) {
DifficultySystem.V_SCALE -> {
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
}
DifficultySystem.FONT -> {
val list = DifficultySystem.FONT.getAvailableGrades()
val idx = list.indexOf(grade.uppercase())
if (idx >= 0) idx.toDouble()
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
}
DifficultySystem.YDS -> {
// Parse 5.X with optional letter a-d
val s = grade.lowercase()
if (!s.startsWith("5.")) return -1.0
val tail = s.removePrefix("5.")
val numberPart = tail.takeWhile { it.isDigit() || it == '.' }
val letterPart = tail.drop(numberPart.length).firstOrNull()
val base = numberPart.toDoubleOrNull() ?: return -1.0
val letterWeight =
when (letterPart) {
'a' -> 0.0
'b' -> 0.1
'c' -> 0.2
'd' -> 0.3
else -> 0.0
}
base + letterWeight
}
DifficultySystem.CUSTOM -> {
grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() ?: -1.0
}
}
}
}

View File

@@ -53,7 +53,6 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
val problems = repository.getAllProblems().first() val problems = repository.getAllProblems().first()
val attempts = repository.getAllAttempts().first() val attempts = repository.getAllAttempts().first()
val gyms = repository.getAllGyms().first() val gyms = repository.getAllGyms().first()
val activeSession = repository.getActiveSession()
// Calculate stats // Calculate stats
val completedSessions = sessions.filter { it.endTime != null } val completedSessions = sessions.filter { it.endTime != null }
@@ -108,7 +107,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
appWidgetManager.updateAppWidget(appWidgetId, views) appWidgetManager.updateAppWidget(appWidgetId, views)
} }
} catch (e: 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_total_sessions, "0")

View File

@@ -18,8 +18,8 @@ class DataModelTests {
@Test @Test
fun testClimbTypeDisplayNames() { fun testClimbTypeDisplayNames() {
assertEquals("Rope", ClimbType.ROPE.getDisplayName()) assertEquals("Rope", ClimbType.ROPE.displayName)
assertEquals("Bouldering", ClimbType.BOULDER.getDisplayName()) assertEquals("Bouldering", ClimbType.BOULDER.displayName)
} }
@Test @Test
@@ -34,58 +34,58 @@ class DataModelTests {
@Test @Test
fun testDifficultySystemDisplayNames() { fun testDifficultySystemDisplayNames() {
assertEquals("V Scale", DifficultySystem.V_SCALE.getDisplayName()) assertEquals("V Scale", DifficultySystem.V_SCALE.displayName)
assertEquals("YDS (Yosemite)", DifficultySystem.YDS.getDisplayName()) assertEquals("YDS (Yosemite)", DifficultySystem.YDS.displayName)
assertEquals("Font Scale", DifficultySystem.FONT.getDisplayName()) assertEquals("Font Scale", DifficultySystem.FONT.displayName)
assertEquals("Custom", DifficultySystem.CUSTOM.getDisplayName()) assertEquals("Custom", DifficultySystem.CUSTOM.displayName)
} }
@Test @Test
fun testDifficultySystemClimbTypeCompatibility() { fun testDifficultySystemClimbTypeCompatibility() {
// Test bouldering systems // Test bouldering systems
assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem()) assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem)
assertTrue(DifficultySystem.FONT.isBoulderingSystem()) assertTrue(DifficultySystem.FONT.isBoulderingSystem)
assertFalse(DifficultySystem.YDS.isBoulderingSystem()) assertFalse(DifficultySystem.YDS.isBoulderingSystem)
assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem()) assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem)
// Test rope systems // Test rope systems
assertTrue(DifficultySystem.YDS.isRopeSystem()) assertTrue(DifficultySystem.YDS.isRopeSystem)
assertFalse(DifficultySystem.V_SCALE.isRopeSystem()) assertFalse(DifficultySystem.V_SCALE.isRopeSystem)
assertFalse(DifficultySystem.FONT.isRopeSystem()) assertFalse(DifficultySystem.FONT.isRopeSystem)
assertTrue(DifficultySystem.CUSTOM.isRopeSystem()) assertTrue(DifficultySystem.CUSTOM.isRopeSystem)
} }
@Test @Test
fun testDifficultySystemAvailableGrades() { fun testDifficultySystemAvailableGrades() {
val vScaleGrades = DifficultySystem.V_SCALE.getAvailableGrades() val vScaleGrades = DifficultySystem.V_SCALE.availableGrades
assertTrue(vScaleGrades.contains("VB")) assertTrue(vScaleGrades.contains("VB"))
assertTrue(vScaleGrades.contains("V0")) assertTrue(vScaleGrades.contains("V0"))
assertTrue(vScaleGrades.contains("V17")) assertTrue(vScaleGrades.contains("V17"))
assertEquals("VB", vScaleGrades.first()) assertEquals("VB", vScaleGrades.first())
val ydsGrades = DifficultySystem.YDS.getAvailableGrades() val ydsGrades = DifficultySystem.YDS.availableGrades
assertTrue(ydsGrades.contains("5.0")) assertTrue(ydsGrades.contains("5.0"))
assertTrue(ydsGrades.contains("5.15d")) assertTrue(ydsGrades.contains("5.15d"))
assertTrue(ydsGrades.contains("5.10a")) assertTrue(ydsGrades.contains("5.10a"))
val fontGrades = DifficultySystem.FONT.getAvailableGrades() val fontGrades = DifficultySystem.FONT.availableGrades
assertTrue(fontGrades.contains("3")) assertTrue(fontGrades.contains("3"))
assertTrue(fontGrades.contains("8C+")) assertTrue(fontGrades.contains("8C+"))
assertTrue(fontGrades.contains("6A")) assertTrue(fontGrades.contains("6A"))
val customGrades = DifficultySystem.CUSTOM.getAvailableGrades() val customGrades = DifficultySystem.CUSTOM.availableGrades
assertTrue(customGrades.isEmpty()) assertTrue(customGrades.isEmpty())
} }
@Test @Test
fun testDifficultySystemsForClimbType() { fun testDifficultySystemsForClimbType() {
val boulderSystems = DifficultySystem.getSystemsForClimbType(ClimbType.BOULDER) val boulderSystems = DifficultySystem.systemsForClimbType(ClimbType.BOULDER)
assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE)) assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE))
assertTrue(boulderSystems.contains(DifficultySystem.FONT)) assertTrue(boulderSystems.contains(DifficultySystem.FONT))
assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM)) assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM))
assertFalse(boulderSystems.contains(DifficultySystem.YDS)) assertFalse(boulderSystems.contains(DifficultySystem.YDS))
val ropeSystems = DifficultySystem.getSystemsForClimbType(ClimbType.ROPE) val ropeSystems = DifficultySystem.systemsForClimbType(ClimbType.ROPE)
assertTrue(ropeSystems.contains(DifficultySystem.YDS)) assertTrue(ropeSystems.contains(DifficultySystem.YDS))
assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM)) assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM))
assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE)) assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE))
@@ -387,7 +387,7 @@ class DataModelTests {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
assertTrue(currentTime > 0) assertTrue(currentTime > 0)
val timeString = java.time.Instant.ofEpochMilli(currentTime).toString() val timeString = Instant.ofEpochMilli(currentTime).toString()
assertTrue(timeString.isNotEmpty()) assertTrue(timeString.isNotEmpty())
assertTrue(timeString.contains("T")) assertTrue(timeString.contains("T"))
assertTrue(timeString.endsWith("Z")) assertTrue(timeString.endsWith("Z"))

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(

79
docs/.dockerignore Normal file
View File

@@ -0,0 +1,79 @@
# Node modules
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Build output
dist
.astro
# IDE files
.vscode
.idea
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Documentation
README.md
*.md
# Cache directories
.cache
.parcel-cache
# Logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Temporary folders
tmp/
temp/

21
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

80
docs/Dockerfile Normal file
View File

@@ -0,0 +1,80 @@
FROM node:24-alpine AS base
# Install system dependencies
RUN apk add --no-cache \
python3 \
make \
g++ \
libc6-compat \
vips-dev \
curl
# Install pnpm globally
RUN npm install -g pnpm
# Configure pnpm
RUN pnpm config set store-dir /.pnpm-store
RUN pnpm config set network-timeout 300000
RUN pnpm config set fetch-retries 10
RUN pnpm config set fetch-retry-factor 2
FROM base AS deps
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install dependencies with retry logic
RUN --mount=type=cache,id=pnpm,target=/.pnpm-store \
pnpm install --frozen-lockfile || \
(sleep 5 && pnpm install --frozen-lockfile) || \
(sleep 10 && pnpm install --frozen-lockfile --no-frozen-lockfile)
FROM base AS build-deps
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install all dependencies including dev dependencies
RUN --mount=type=cache,id=pnpm,target=/.pnpm-store \
pnpm install --frozen-lockfile || \
(sleep 5 && pnpm install --frozen-lockfile) || \
(sleep 10 && pnpm install --frozen-lockfile --no-frozen-lockfile)
FROM build-deps AS builder
# Copy source code
COPY . .
# Build the application
RUN pnpm run build
FROM node:20-alpine AS runtime
# Install pnpm for runtime
RUN npm install -g pnpm
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
# Copy production dependencies
COPY --from=deps /app/node_modules ./node_modules
# Copy package.json for any runtime needs
COPY package.json ./
# Set environment variables
ENV HOST=0.0.0.0 \
PORT=4321 \
NODE_ENV=production
# Expose port
EXPOSE 4321
# Start the application
CMD ["node", "./dist/server/entry.mjs"]

12
docs/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Ascently Documentation
Documentation site for Ascently.
This was built with [Astro Starlight](https://starlight.astro.build/).
### Setup
```bash
cd Ascently/docs
pnpm install
pnpm run dev
```

62
docs/astro.config.mjs Normal file
View File

@@ -0,0 +1,62 @@
// @ts-check
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
import node from "@astrojs/node";
// https://astro.build/config
export default defineConfig({
site: "https://docs.ascently.app",
integrations: [
starlight({
title: "Ascently",
description:
"An offline-first FOSS climb tracking app with an optional sync server.",
logo: {
light: "./src/assets/logo.svg",
dark: "./src/assets/logo-dark.svg",
},
favicon: "/favicon.png",
social: [
{
icon: "seti:git",
label: "Gitea",
href: "https://git.atri.dad/atridad/Ascently",
},
{
icon: "email",
label: "Contact",
href: "mailto:me@atri.dad",
},
],
sidebar: [
{
label: "Download",
link: "/download/",
},
{
label: "Self-Hosted Sync",
items: [
{ label: "Overview", slug: "sync/overview" },
{ label: "Quick Start", slug: "sync/quick-start" },
{ label: "API Reference", slug: "sync/api-reference" },
],
},
{
label: "Reference",
autogenerate: { directory: "reference" },
},
{
label: "Privacy",
link: "/privacy/",
},
],
customCss: ["./src/styles/custom.css"],
}),
],
adapter: node({
mode: "standalone",
}),
});

8
docs/docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
app:
image: ${IMAGE}
ports:
- "${APP_PORT}:4321"
environment:
NODE_ENV: production
restart: unless-stopped

33
docs/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "ascently-docs",
"type": "module",
"version": "1.0.0",
"description": "Documentation site for Ascently - FOSS climbing tracking app",
"repository": {
"type": "git",
"url": "https://git.atri.dad/atridad/Ascently.git",
"directory": "docs"
},
"author": "atridad <me@atri.dad>",
"license": "MIT",
"keywords": [
"climbing",
"tracking",
"documentation",
"astro",
"starlight"
],
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^9.5.0",
"@astrojs/starlight": "^0.36.1",
"astro": "^5.14.5",
"sharp": "^0.34.4"
}
}

4172
docs/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
docs/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
docs/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

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

After

Width:  |  Height:  |  Size: 475 B

View File

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

After

Width:  |  Height:  |  Size: 490 B

15
docs/src/assets/logo.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 475 B

View File

@@ -0,0 +1,7 @@
import { defineCollection } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@@ -0,0 +1,26 @@
---
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,62 @@
---
title: Ascently
description: An offline-first FOSS climb tracking app with an optional sync server.
template: splash
hero:
tagline: Track your climbing sessions, routes, and progress.
image:
file: ../../assets/logo-highres.svg
alt: "Ascently app icon"
actions:
- text: Download
link: /download/
icon: right-arrow
- text: Docs
link: /sync/overview/
icon: right-arrow
variant: minimal
---
import { Card, CardGrid } from '@astrojs/starlight/components';
## About Ascently
_Formerly OpenClimb_
Ascently is an **offline-first FOSS** app designed to help climbers track their sessions, routes/problems, and overall progress. There is an optional self-hosted sync server and integrations with Apple Health and Health Connect. There are no analytics or tracking baked into any part of this project. I am committed to maintaining a transparent and open-source solution for climbers, ensuring that you have full control over your data and privacy.
<CardGrid stagger>
<Card title="Offline-First" icon="laptop">
Your data stays on your device. No internet connection required to track your climbing sessions.
</Card>
<Card title="Cross-Platform" icon="rocket">
Built using Jetpack Compose with Material You support on Android and SwiftUI on iOS.
</Card>
<Card title="Health Integration" icon="heart">
Integrates with Apple Health and Health Connect to track your fitness data.
</Card>
<Card title="Optional Sync" icon="cloud-download">
Run your own lightweight sync server to keep data synchronized across devices.
</Card>
</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*
*Contact: me@atri.dad*

View File

@@ -0,0 +1,43 @@
---
title: Privacy Policy
description: Ascently's Privacy Policy
---
**Last updated: September 29, 2025**
This Privacy Policy describes our policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
## No Data Collection
I do not collect any personal information, analytics, or data of any kind. This software is designed to be self-hosted or run entirely offline.
All data generated by or used with this software remains on your local machine or self-hosted environment under your control. I have no access to it.
## Optional Sync Server
You may optionally configure a sync server to synchronize your data across multiple devices. When using a sync server:
- Your data is transmitted only to the server you specify
- You are responsible for the server's security and privacy practices
- I have no access to or control over your chosen sync server
- All data remains encrypted during transmission
## Optional Health Data Integration
You may optionally integrate with Apple Health or Android Health Connect to import health and fitness data. When enabled:
- Health data is accessed only on your local device
- No health data is transmitted to me, but Apple or Google may receive data according to their respective privacy policies
- You control which health metrics are imported
- You can revoke access at any time through your device's health app settings
- Please refer to Apple's Privacy Policy or Google's Privacy Policy for information about their data practices
## No Tracking or Analytics
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
If you have any questions about this Privacy Policy, you can contact me:
* **By email:** me@atri.dad

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

@@ -0,0 +1,51 @@
---
title: Self-Hosted Sync Overview
description: Learn about Ascently's optional sync server for cross-device data synchronization
---
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
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.
## Features
- **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
## API Endpoints
- `GET /health` - Health check
- `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
Check out the [Quick Start guide](/sync/quick-start/) to get your server running with Docker Compose.
You'll need:
- Docker and Docker Compose
- A secure authentication token
- A place to store your data
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

@@ -0,0 +1,169 @@
---
title: Quick Start
description: Get your Ascently sync server running with Docker Compose
---
Get your sync server running in minutes with Docker Compose.
## Prerequisites
- Docker and Docker Compose installed
- A server or computer to host the sync service
## Setup
1. Create a `docker-compose.yml` file:
```yaml
version: '3.8'
services:
ascently-sync:
image: git.atri.dad/atridad/ascently-sync:latest
ports:
- "8080:8080"
environment:
- AUTH_TOKEN=${AUTH_TOKEN}
- DATA_FILE=/data/ascently.json
- IMAGES_DIR=/data/images
volumes:
- ./ascently-data:/data
restart: unless-stopped
```
2. Create a `.env` file in the same 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
docker-compose up -d
```
The server will be available at `http://localhost:8080`.
## Generate a Secure Token
Use this command to generate a secure authentication token:
```bash
openssl rand -base64 32
```
Copy the output and paste it into your `.env` file as the `AUTH_TOKEN`.
Keep this token secret and don't commit it to version control.
## Configure Your Apps
Open Ascently on your iOS or Android device:
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

@@ -0,0 +1,13 @@
:root {
--sl-color-accent: hsl(4, 90%, 58%);
--sl-color-accent-low: hsl(4, 90%, 96%);
--sl-color-accent-high: hsl(4, 90%, 30%);
--climbing-amber: hsl(45, 100%, 50%);
--climbing-red: hsl(4, 90%, 58%);
}
[data-theme="dark"] {
--sl-color-accent: hsl(45, 100%, 50%);
--sl-color-accent-low: hsl(45, 100%, 10%);
--sl-color-accent-high: hsl(45, 100%, 70%);
}

5
docs/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

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 = 27;
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.1.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 = 27;
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.1.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 = 27;
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.1.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 = 27;
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.1.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

@@ -111,7 +111,6 @@ struct ContentView: View {
Task { Task {
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() await dataManager.onAppBecomeActive()
// Ensure health integration is verified
await dataManager.healthKitService.verifyAndRestoreIntegration() await dataManager.healthKitService.verifyAndRestoreIntegration()
} }
} }

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]

View File

@@ -0,0 +1,26 @@
//
// DeltaSyncFormat.swift
// Ascently
//
// Delta sync structures for incremental data synchronization
//
import Foundation
struct DeltaSyncRequest: Codable {
let lastSyncTime: String
let gyms: [BackupGym]
let problems: [BackupProblem]
let sessions: [BackupClimbSession]
let attempts: [BackupAttempt]
let deletedItems: [DeletedItem]
}
struct DeltaSyncResponse: Codable {
let serverTime: String
let gyms: [BackupGym]
let problems: [BackupProblem]
let sessions: [BackupClimbSession]
let attempts: [BackupAttempt]
let deletedItems: [DeletedItem]
}

View File

@@ -31,7 +31,6 @@ class HealthKitService: ObservableObject {
} }
} }
/// Restore active workout state
private func restoreActiveWorkout() { private func restoreActiveWorkout() {
if let startDate = userDefaults.object(forKey: workoutStartDateKey) as? Date, if let startDate = userDefaults.object(forKey: workoutStartDateKey) as? Date,
let sessionIdString = userDefaults.string(forKey: workoutSessionIdKey), let sessionIdString = userDefaults.string(forKey: workoutSessionIdKey),
@@ -43,7 +42,6 @@ class HealthKitService: ObservableObject {
} }
} }
/// Persist active workout state
private func persistActiveWorkout() { private func persistActiveWorkout() {
if let startDate = currentWorkoutStartDate, let sessionId = currentWorkoutSessionId { if let startDate = currentWorkoutStartDate, let sessionId = currentWorkoutSessionId {
userDefaults.set(startDate, forKey: workoutStartDateKey) userDefaults.set(startDate, forKey: workoutStartDateKey)
@@ -54,7 +52,6 @@ class HealthKitService: ObservableObject {
} }
} }
/// Verify and restore health integration
func verifyAndRestoreIntegration() async { func verifyAndRestoreIntegration() async {
guard isEnabled else { return } guard isEnabled else { return }

View File

@@ -136,6 +136,314 @@ class SyncService: ObservableObject {
} }
} }
func performDeltaSync(dataManager: ClimbingDataManager) async throws {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/sync/delta") else {
throw SyncError.invalidURL
}
// Get last sync time, or use epoch if never synced
let lastSync = lastSyncTime ?? Date(timeIntervalSince1970: 0)
let formatter = ISO8601DateFormatter()
let lastSyncString = formatter.string(from: lastSync)
// Collect items modified since last sync
let modifiedGyms = dataManager.gyms.filter { gym in
gym.updatedAt > lastSync
}.map { BackupGym(from: $0) }
let modifiedProblems = dataManager.problems.filter { problem in
problem.updatedAt > lastSync
}.map { problem -> BackupProblem in
var backupProblem = BackupProblem(from: problem)
if !problem.imagePaths.isEmpty {
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
}
return BackupProblem(
id: backupProblem.id,
gymId: backupProblem.gymId,
name: backupProblem.name,
description: backupProblem.description,
climbType: backupProblem.climbType,
difficulty: backupProblem.difficulty,
tags: backupProblem.tags,
location: backupProblem.location,
imagePaths: normalizedPaths,
isActive: backupProblem.isActive,
dateSet: backupProblem.dateSet,
notes: backupProblem.notes,
createdAt: backupProblem.createdAt,
updatedAt: backupProblem.updatedAt
)
}
return backupProblem
}
let modifiedSessions = dataManager.sessions.filter { session in
session.status != .active && session.updatedAt > lastSync
}.map { BackupClimbSession(from: $0) }
let activeSessionIds = Set(
dataManager.sessions.filter { $0.status == .active }.map { $0.id })
let modifiedAttempts = dataManager.attempts.filter { attempt in
!activeSessionIds.contains(attempt.sessionId) && attempt.createdAt > lastSync
}.map { BackupAttempt(from: $0) }
let modifiedDeletions = dataManager.getDeletedItems().filter { item in
if let deletedDate = formatter.date(from: item.deletedAt) {
return deletedDate > lastSync
}
return false
}
print(
"iOS DELTA SYNC: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)"
)
// Create delta request
let deltaRequest = DeltaSyncRequest(
lastSyncTime: lastSyncString,
gyms: modifiedGyms,
problems: modifiedProblems,
sessions: modifiedSessions,
attempts: modifiedAttempts,
deletedItems: modifiedDeletions
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let jsonData = try encoder.encode(deltaRequest)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = jsonData
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
break
case 401:
throw SyncError.unauthorized
default:
throw SyncError.serverError(httpResponse.statusCode)
}
let decoder = JSONDecoder()
let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data)
print(
"iOS DELTA SYNC: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count), deletions=\(deltaResponse.deletedItems.count)"
)
// Apply server changes to local data
try await applyDeltaResponse(deltaResponse, dataManager: dataManager)
// Sync only modified problem images
try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager)
// Update last sync time to server time
if let serverTime = formatter.date(from: deltaResponse.serverTime) {
lastSyncTime = serverTime
userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime)
}
}
private func applyDeltaResponse(_ response: DeltaSyncResponse, dataManager: ClimbingDataManager)
async throws
{
let formatter = ISO8601DateFormatter()
// Download images for new/modified problems from server
var imagePathMapping: [String: String] = [:]
for problem in response.problems {
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
for (index, imagePath) in imagePaths.enumerated() {
let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent
do {
let imageData = try await downloadImage(filename: serverFilename)
let consistentFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id, imageIndex: index)
let imageManager = ImageManager.shared
_ = try imageManager.saveImportedImage(imageData, filename: consistentFilename)
imagePathMapping[serverFilename] = consistentFilename
} catch SyncError.imageNotFound {
print("Image not found on server: \(serverFilename)")
continue
} catch {
print("Failed to download image \(serverFilename): \(error)")
continue
}
}
}
// Merge gyms
for backupGym in response.gyms {
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id })
{
let existing = dataManager.gyms[index]
if backupGym.updatedAt >= formatter.string(from: existing.updatedAt) {
dataManager.gyms[index] = try backupGym.toGym()
}
} else {
dataManager.gyms.append(try backupGym.toGym())
}
}
// Merge problems
for backupProblem in response.problems {
var problemToMerge = backupProblem
if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths {
let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 }
problemToMerge = BackupProblem(
id: backupProblem.id,
gymId: backupProblem.gymId,
name: backupProblem.name,
description: backupProblem.description,
climbType: backupProblem.climbType,
difficulty: backupProblem.difficulty,
tags: backupProblem.tags,
location: backupProblem.location,
imagePaths: updatedPaths,
isActive: backupProblem.isActive,
dateSet: backupProblem.dateSet,
notes: backupProblem.notes,
createdAt: backupProblem.createdAt,
updatedAt: backupProblem.updatedAt
)
}
if let index = dataManager.problems.firstIndex(where: {
$0.id.uuidString == problemToMerge.id
}) {
let existing = dataManager.problems[index]
if problemToMerge.updatedAt >= formatter.string(from: existing.updatedAt) {
dataManager.problems[index] = try problemToMerge.toProblem()
}
} else {
dataManager.problems.append(try problemToMerge.toProblem())
}
}
// Merge sessions
for backupSession in response.sessions {
if let index = dataManager.sessions.firstIndex(where: {
$0.id.uuidString == backupSession.id
}) {
let existing = dataManager.sessions[index]
if backupSession.updatedAt >= formatter.string(from: existing.updatedAt) {
dataManager.sessions[index] = try backupSession.toClimbSession()
}
} else {
dataManager.sessions.append(try backupSession.toClimbSession())
}
}
// Merge attempts
for backupAttempt in response.attempts {
if let index = dataManager.attempts.firstIndex(where: {
$0.id.uuidString == backupAttempt.id
}) {
let existing = dataManager.attempts[index]
if backupAttempt.createdAt >= formatter.string(from: existing.createdAt) {
dataManager.attempts[index] = try backupAttempt.toAttempt()
}
} else {
dataManager.attempts.append(try backupAttempt.toAttempt())
}
}
// Apply deletions
let allDeletions = dataManager.getDeletedItems() + response.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
// Save all changes
dataManager.saveGyms()
dataManager.saveProblems()
dataManager.saveSessions()
dataManager.saveAttempts()
// Update deletion records
dataManager.clearDeletedItems()
if let data = try? JSONEncoder().encode(uniqueDeletions) {
UserDefaults.standard.set(data, forKey: "ascently_deleted_items")
}
DataStateManager.shared.updateDataState()
}
private func applyDeletionsToDataManager(
deletions: [DeletedItem], dataManager: ClimbingDataManager
) {
let deletedGymIds = Set(deletions.filter { $0.type == "gym" }.map { $0.id })
let deletedProblemIds = Set(deletions.filter { $0.type == "problem" }.map { $0.id })
let deletedSessionIds = Set(deletions.filter { $0.type == "session" }.map { $0.id })
let deletedAttemptIds = Set(deletions.filter { $0.type == "attempt" }.map { $0.id })
dataManager.gyms.removeAll { deletedGymIds.contains($0.id.uuidString) }
dataManager.problems.removeAll { deletedProblemIds.contains($0.id.uuidString) }
dataManager.sessions.removeAll { deletedSessionIds.contains($0.id.uuidString) }
dataManager.attempts.removeAll { deletedAttemptIds.contains($0.id.uuidString) }
}
private func syncModifiedImages(
modifiedProblems: [BackupProblem], dataManager: ClimbingDataManager
) async throws {
guard !modifiedProblems.isEmpty else { return }
print("iOS DELTA SYNC: Syncing images for \(modifiedProblems.count) modified problems")
for backupProblem in modifiedProblems {
guard
let problem = dataManager.problems.first(where: {
$0.id.uuidString == backupProblem.id
})
else {
continue
}
for (index, imagePath) in problem.imagePaths.enumerated() {
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
let consistentFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
let imageManager = ImageManager.shared
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
do {
if filename != consistentFilename {
let newPath = imageManager.imagesDirectory.appendingPathComponent(
consistentFilename
).path
try? FileManager.default.moveItem(atPath: fullPath, toPath: newPath)
}
try await uploadImage(filename: consistentFilename, imageData: imageData)
print("Uploaded modified problem image: \(consistentFilename)")
} catch {
print("Failed to upload image \(consistentFilename): \(error)")
}
}
}
}
}
func uploadImage(filename: String, imageData: Data) async throws { func uploadImage(filename: String, imageData: Data) async throws {
guard isConfigured else { guard isConfigured else {
throw SyncError.notConfigured throw SyncError.notConfigured
@@ -246,6 +554,17 @@ class SyncService: ObservableObject {
!serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty !serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty
|| !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty || !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty
// If both client and server have been synced before, use delta sync
if hasLocalData && hasServerData && lastSyncTime != nil {
print("iOS SYNC: Using delta sync for incremental updates")
try await performDeltaSync(dataManager: dataManager)
// Update last sync time
lastSyncTime = Date()
userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime)
return
}
if !hasLocalData && hasServerData { if !hasLocalData && hasServerData {
// Case 1: No local data - do full restore from server // Case 1: No local data - do full restore from server
print("iOS SYNC: Case 1 - No local data, performing full restore from server") print("iOS SYNC: Case 1 - No local data, performing full restore from server")
@@ -286,7 +605,6 @@ class SyncService: ObservableObject {
} }
} }
/// Parses ISO8601 timestamp to milliseconds for comparison
private func parseISO8601ToMillis(timestamp: String) -> Int64 { private func parseISO8601ToMillis(timestamp: String) -> Int64 {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: timestamp) { if let date = formatter.date(from: timestamp) {
@@ -1150,7 +1468,6 @@ class SyncService: ObservableObject {
// Get active session IDs to protect their attempts // Get active session IDs to protect their attempts
let activeSessionIds = Set( let activeSessionIds = Set(
local.compactMap { attempt in local.compactMap { attempt in
// This is a simplified check - in a real implementation you'd want to cross-reference with sessions
return attempt.sessionId return attempt.sessionId
}.filter { sessionId in }.filter { sessionId in
// Check if this session ID belongs to an active session // Check if this session ID belongs to an active session

View File

@@ -37,46 +37,36 @@ class DataStateManager {
print("iOS Data state updated to: \(now)") print("iOS Data state updated to: \(now)")
} }
/// Gets the current data state timestamp. This represents when any data was last modified
/// locally.
func getLastModified() -> String { func getLastModified() -> String {
if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) { if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) {
print("iOS DataStateManager returning stored timestamp: \(storedTimestamp)") print("iOS DataStateManager returning stored timestamp: \(storedTimestamp)")
return storedTimestamp return storedTimestamp
} }
// If no timestamp is stored, return epoch time to indicate very old data
// This ensures server data will be considered newer than uninitialized local data
let epochTime = "1970-01-01T00:00:00.000Z" let epochTime = "1970-01-01T00:00:00.000Z"
print("WARNING: No data state timestamp found - returning epoch time: \(epochTime)") print("No data state timestamp found - returning epoch time: \(epochTime)")
return epochTime return epochTime
} }
/// Sets the data state timestamp to a specific value. Used when importing data from server to
/// sync the state.
func setLastModified(_ timestamp: String) { func setLastModified(_ timestamp: String) {
userDefaults.set(timestamp, forKey: Keys.lastModified) userDefaults.set(timestamp, forKey: Keys.lastModified)
print("Data state set to: \(timestamp)") print("Data state set to: \(timestamp)")
} }
/// Resets the data state (for testing or complete data wipe).
func reset() { func reset() {
userDefaults.removeObject(forKey: Keys.lastModified) userDefaults.removeObject(forKey: Keys.lastModified)
userDefaults.removeObject(forKey: Keys.initialized) userDefaults.removeObject(forKey: Keys.initialized)
print("Data state reset") print("Data state reset")
} }
/// Checks if the data state has been initialized.
private func isInitialized() -> Bool { private func isInitialized() -> Bool {
return userDefaults.bool(forKey: Keys.initialized) return userDefaults.bool(forKey: Keys.initialized)
} }
/// Marks the data state as initialized.
private func markAsInitialized() { private func markAsInitialized() {
userDefaults.set(true, forKey: Keys.initialized) userDefaults.set(true, forKey: Keys.initialized)
} }
/// Gets debug information about the current state.
func getDebugInfo() -> String { func getDebugInfo() -> String {
return "DataState(lastModified=\(getLastModified()), initialized=\(isInitialized()))" return "DataState(lastModified=\(getLastModified()), initialized=\(isInitialized()))"
} }

View File

@@ -690,7 +690,6 @@ class ImageManager {
} }
private func cleanupOrphanedFiles() { private func cleanupOrphanedFiles() {
// This would need access to the data manager to check which files are actually referenced
print("Cleanup would require coordination with data manager") print("Cleanup would require coordination with data manager")
} }

View File

@@ -108,7 +108,6 @@ class ImageNamingUtils {
) )
} }
/// Generates the canonical filename that should be used for a problem image
static func getCanonicalImageFilename(problemId: String, imageIndex: Int) -> String { static func getCanonicalImageFilename(problemId: String, imageIndex: Int) -> String {
return generateImageFilename(problemId: problemId, imageIndex: imageIndex) return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
} }

View File

@@ -18,6 +18,7 @@ struct ZipUtils {
var fileEntries: [(name: String, data: Data, offset: UInt32)] = [] var fileEntries: [(name: String, data: Data, offset: UInt32)] = []
var currentOffset: UInt32 = 0 var currentOffset: UInt32 = 0
// Add metadata
let metadata = createMetadata( let metadata = createMetadata(
exportData: exportData, referencedImagePaths: referencedImagePaths) exportData: exportData, referencedImagePaths: referencedImagePaths)
let metadataData = metadata.data(using: .utf8) ?? Data() let metadataData = metadata.data(using: .utf8) ?? Data()
@@ -29,6 +30,7 @@ struct ZipUtils {
currentOffset: &currentOffset currentOffset: &currentOffset
) )
// Encode JSON data
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted encoder.outputFormatting = .prettyPrinted
encoder.dateEncodingStrategy = .custom { date, encoder in encoder.dateEncodingStrategy = .custom { date, encoder in
@@ -46,20 +48,29 @@ struct ZipUtils {
currentOffset: &currentOffset currentOffset: &currentOffset
) )
print("Processing \(referencedImagePaths.count) referenced image paths") // Process images in batches for better performance
print("Processing \(referencedImagePaths.count) images for export")
var successfulImages = 0 var successfulImages = 0
let batchSize = 10
let sortedPaths = Array(referencedImagePaths).sorted()
// Pre-allocate capacity for better memory performance
zipData.reserveCapacity(zipData.count + (referencedImagePaths.count * 200_000)) // Estimate 200KB per image
for (index, imagePath) in sortedPaths.enumerated() {
if index % batchSize == 0 {
print("Processing images \(index)/\(sortedPaths.count)")
}
for imagePath in referencedImagePaths {
print("Processing image path: \(imagePath)")
let imageURL = URL(fileURLWithPath: imagePath) let imageURL = URL(fileURLWithPath: imagePath)
let imageName = imageURL.lastPathComponent let imageName = imageURL.lastPathComponent
print("Image name: \(imageName)")
if FileManager.default.fileExists(atPath: imagePath) { guard FileManager.default.fileExists(atPath: imagePath) else {
print("Image file exists at: \(imagePath)") continue
}
do { do {
let imageData = try Data(contentsOf: imageURL) let imageData = try Data(contentsOf: imageURL)
print("Image data size: \(imageData.count) bytes")
if imageData.count > 0 { if imageData.count > 0 {
let imageEntryName = "\(IMAGES_DIR_NAME)/\(imageName)" let imageEntryName = "\(IMAGES_DIR_NAME)/\(imageName)"
try addFileToZip( try addFileToZip(
@@ -70,20 +81,16 @@ struct ZipUtils {
currentOffset: &currentOffset currentOffset: &currentOffset
) )
successfulImages += 1 successfulImages += 1
print("Successfully added image to ZIP: \(imageEntryName)")
} else {
print("Image data is empty for: \(imagePath)")
} }
} catch { } catch {
print("Failed to read image data for \(imagePath): \(error)") print("Failed to read image: \(imageName)")
}
} else {
print("Image file does not exist at: \(imagePath)")
} }
} }
print("Export completed: \(successfulImages)/\(referencedImagePaths.count) images included") print("Export: included \(successfulImages)/\(referencedImagePaths.count) images")
// Build central directory
centralDirectory.reserveCapacity(fileEntries.count * 100) // Estimate 100 bytes per entry
for entry in fileEntries { for entry in fileEntries {
let centralDirEntry = createCentralDirectoryEntry( let centralDirEntry = createCentralDirectoryEntry(
filename: entry.name, filename: entry.name,
@@ -372,12 +379,12 @@ struct ZipUtils {
return data return data
} }
private static func calculateCRC32(data: Data) -> UInt32 { // CRC32 lookup table for faster calculation
private static let crc32Table: [UInt32] = {
let polynomial: UInt32 = 0xEDB8_8320 let polynomial: UInt32 = 0xEDB8_8320
var crc: UInt32 = 0xFFFF_FFFF var table = [UInt32](repeating: 0, count: 256)
for i in 0..<256 {
for byte in data { var crc = UInt32(i)
crc ^= UInt32(byte)
for _ in 0..<8 { for _ in 0..<8 {
if crc & 1 != 0 { if crc & 1 != 0 {
crc = (crc >> 1) ^ polynomial crc = (crc >> 1) ^ polynomial
@@ -385,6 +392,19 @@ struct ZipUtils {
crc >>= 1 crc >>= 1
} }
} }
table[i] = crc
}
return table
}()
private static func calculateCRC32(data: Data) -> UInt32 {
var crc: UInt32 = 0xFFFF_FFFF
data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in
for byte in bytes {
let index = Int((crc ^ UInt32(byte)) & 0xFF)
crc = (crc >> 8) ^ crc32Table[index]
}
} }
return ~crc return ~crc

View File

@@ -653,9 +653,6 @@ class ClimbingDataManager: ObservableObject {
return gym(withId: mostUsedGymId) return gym(withId: mostUsedGymId)
} }
/// Clean up orphaned data - removes attempts that reference non-existent sessions
/// and removes duplicate attempts. This ensures data integrity and prevents
/// orphaned attempts from appearing in widgets
private func cleanupOrphanedData() { private func cleanupOrphanedData() {
let validSessionIds = Set(sessions.map { $0.id }) let validSessionIds = Set(sessions.map { $0.id })
let validProblemIds = Set(problems.map { $0.id }) let validProblemIds = Set(problems.map { $0.id })
@@ -761,8 +758,6 @@ class ClimbingDataManager: ObservableObject {
} }
} }
/// Validate data integrity and return a report
/// This can be called manually to check for issues
func validateDataIntegrity() -> String { func validateDataIntegrity() -> String {
let validSessionIds = Set(sessions.map { $0.id }) let validSessionIds = Set(sessions.map { $0.id })
let validProblemIds = Set(problems.map { $0.id }) let validProblemIds = Set(problems.map { $0.id })
@@ -801,8 +796,6 @@ class ClimbingDataManager: ObservableObject {
return report return report
} }
/// Manually trigger cleanup of orphaned data
/// This can be called from settings or debug menu
func manualDataCleanup() { func manualDataCleanup() {
cleanupOrphanedData() cleanupOrphanedData()
successMessage = "Data cleanup completed" successMessage = "Data cleanup completed"
@@ -830,12 +823,12 @@ class ClimbingDataManager: ObservableObject {
} }
} }
func exportData() -> Data? { func exportData() async -> Data? {
do { do {
// Create backup objects on main thread (they access MainActor-isolated properties)
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
// Create export data with normalized image paths
let exportData = ClimbDataBackup( let exportData = ClimbDataBackup(
exportedAt: dateFormatter.string(from: Date()), exportedAt: dateFormatter.string(from: Date()),
version: "2.0", version: "2.0",
@@ -846,19 +839,30 @@ class ClimbingDataManager: ObservableObject {
attempts: attempts.map { BackupAttempt(from: $0) } attempts: attempts.map { BackupAttempt(from: $0) }
) )
// Get image manager path info on main thread
let imagesDirectory = ImageManager.shared.imagesDirectory.path
let problemsForImages = problems
// Move heavy I/O operations to background thread
let zipData = try await Task.detached(priority: .userInitiated) {
// Collect actual image paths from disk for the ZIP // Collect actual image paths from disk for the ZIP
let referencedImagePaths = collectReferencedImagePaths() let referencedImagePaths = await Self.collectReferencedImagePathsStatic(
problems: problemsForImages,
imagesDirectory: imagesDirectory)
print("Starting export with \(referencedImagePaths.count) images") print("Starting export with \(referencedImagePaths.count) images")
let zipData = try ZipUtils.createExportZip( let zipData = try await ZipUtils.createExportZip(
exportData: exportData, exportData: exportData,
referencedImagePaths: referencedImagePaths referencedImagePaths: referencedImagePaths
) )
print("Export completed successfully") print("Export completed successfully")
successMessage = "Export completed with \(referencedImagePaths.count) images" return (zipData, referencedImagePaths.count)
}.value
successMessage = "Export completed with \(zipData.1) images"
clearMessageAfterDelay() clearMessageAfterDelay()
return zipData return zipData.0
} catch { } catch {
let errorMessage = "Export failed: \(error.localizedDescription)" let errorMessage = "Export failed: \(error.localizedDescription)"
print("ERROR: \(errorMessage)") print("ERROR: \(errorMessage)")
@@ -955,36 +959,36 @@ class ClimbingDataManager: ObservableObject {
extension ClimbingDataManager { extension ClimbingDataManager {
private func collectReferencedImagePaths() -> Set<String> { private func collectReferencedImagePaths() -> Set<String> {
let imagesDirectory = ImageManager.shared.imagesDirectory.path
return Self.collectReferencedImagePathsStatic(
problems: problems,
imagesDirectory: imagesDirectory)
}
private static func collectReferencedImagePathsStatic(
problems: [Problem], imagesDirectory: String
) -> Set<String> {
var imagePaths = Set<String>() var imagePaths = Set<String>()
print("Starting image path collection...") var missingCount = 0
print("Total problems: \(problems.count)")
for problem in problems { for problem in problems {
if !problem.imagePaths.isEmpty { if !problem.imagePaths.isEmpty {
print(
"Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
)
for imagePath in problem.imagePaths { for imagePath in problem.imagePaths {
print(" - Stored path: \(imagePath)")
// Extract just the filename (migration should have normalized these) // Extract just the filename (migration should have normalized these)
let filename = URL(fileURLWithPath: imagePath).lastPathComponent let filename = URL(fileURLWithPath: imagePath).lastPathComponent
let fullPath = ImageManager.shared.getFullPath(from: filename) let fullPath = (imagesDirectory as NSString).appendingPathComponent(filename)
print(" - Full disk path: \(fullPath)")
if FileManager.default.fileExists(atPath: fullPath) { if FileManager.default.fileExists(atPath: fullPath) {
print(" ✓ File exists")
imagePaths.insert(fullPath) imagePaths.insert(fullPath)
} else { } else {
print(" ✗ WARNING: File not found at \(fullPath)") missingCount += 1
// Still add it to let ZipUtils handle the logging
imagePaths.insert(fullPath) imagePaths.insert(fullPath)
} }
} }
} }
} }
print("Collected \(imagePaths.count) total image paths for export") print("Export: Collected \(imagePaths.count) images (\(missingCount) missing)")
return imagePaths return imagePaths
} }
@@ -1273,10 +1277,12 @@ extension ClimbingDataManager {
) { [weak self] notification in ) { [weak self] notification in
if let updateCount = notification.userInfo?["updateCount"] as? Int { if let updateCount = notification.userInfo?["updateCount"] as? Int {
print("🔔 Image migration completed with \(updateCount) updates - reloading data") print("🔔 Image migration completed with \(updateCount) updates - reloading data")
Task { @MainActor in
self?.loadProblems() self?.loadProblems()
} }
} }
} }
}
/// Handle Live Activity being dismissed by user /// Handle Live Activity being dismissed by user
private func handleLiveActivityDismissed() async { private func handleLiveActivityDismissed() async {

View File

@@ -103,7 +103,6 @@ struct AddEditProblemView: View {
setupInitialGym() setupInitialGym()
} }
.onChange(of: dataManager.gyms) { .onChange(of: dataManager.gyms) {
// Ensure a gym is selected when gyms are loaded or changed
if selectedGym == nil && !dataManager.gyms.isEmpty { if selectedGym == nil && !dataManager.gyms.isEmpty {
selectedGym = dataManager.gyms.first selectedGym = dataManager.gyms.first
} }

View File

@@ -180,13 +180,15 @@ struct DataManagementSection: View {
private func exportDataAsync() { private func exportDataAsync() {
isExporting = true isExporting = true
Task { Task {
let data = await MainActor.run { dataManager.exportData() } let data = await dataManager.exportData()
await MainActor.run {
isExporting = false isExporting = false
if let data = data { if let data = data {
activeSheet = .export(data) activeSheet = .export(data)
} }
} }
} }
}
private func deleteAllImages() { private func deleteAllImages() {
isDeletingImages = true isDeletingImages = true

View File

@@ -256,10 +256,6 @@ final class AscentlyTests: XCTestCase {
// MARK: - Active Session Preservation Tests // MARK: - Active Session Preservation Tests
func testActiveSessionPreservationDuringImport() throws { func testActiveSessionPreservationDuringImport() throws {
// Test that active sessions are preserved during import operations
// This tests the fix for the bug where active sessions disappear after sync
// Simulate an active session that exists locally but not in import data
let activeSessionId = UUID() let activeSessionId = UUID()
let gymId = UUID() let gymId = UUID()

View File

@@ -18,5 +18,3 @@ This is a standard Xcode project. The main app code is in the `Ascently/` direct
- `ClimbingActivityWidget/`: A home screen widget. - `ClimbingActivityWidget/`: A home screen widget.
- `SessionStatusLive/`: A Live Activity for the lock screen. - `SessionStatusLive/`: A Live Activity for the lock screen.
The app is built to be offline-first. All data is stored locally on your device and works without an internet connection.

View File

@@ -2,10 +2,6 @@
A simple Go server for self-hosting your Ascently sync data. A simple Go server for self-hosting your Ascently sync data.
## How It Works
This server is dead simple. It 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.
## Getting Started ## Getting Started
1. Create a `.env` file in this directory: 1. Create a `.env` file in this directory:
@@ -27,7 +23,7 @@ This server is dead simple. It uses a single `ascently.json` file for your data
## API ## API
The API is minimal, just enough for the app to work. All endpoints require an `Authorization: Bearer <your-auth-token>` header. All endpoints require an `Authorization: Bearer <your-auth-token>` header.
- `GET /sync`: Download `ascently.json`. - `GET /sync`: Download `ascently.json`.
- `POST /sync`: Upload `ascently.json`. - `POST /sync`: Upload `ascently.json`.

View File

@@ -13,7 +13,7 @@ import (
"time" "time"
) )
const VERSION = "2.0.0" const VERSION = "2.1.0"
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
@@ -39,6 +39,24 @@ type ClimbDataBackup struct {
DeletedItems []DeletedItem `json:"deletedItems"` DeletedItems []DeletedItem `json:"deletedItems"`
} }
type DeltaSyncRequest struct {
LastSyncTime string `json:"lastSyncTime"`
Gyms []BackupGym `json:"gyms"`
Problems []BackupProblem `json:"problems"`
Sessions []BackupClimbSession `json:"sessions"`
Attempts []BackupAttempt `json:"attempts"`
DeletedItems []DeletedItem `json:"deletedItems"`
}
type DeltaSyncResponse struct {
ServerTime string `json:"serverTime"`
Gyms []BackupGym `json:"gyms"`
Problems []BackupProblem `json:"problems"`
Sessions []BackupClimbSession `json:"sessions"`
Attempts []BackupAttempt `json:"attempts"`
DeletedItems []DeletedItem `json:"deletedItems"`
}
type BackupGym struct { type BackupGym struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -154,6 +172,174 @@ func (s *SyncServer) loadData() (*ClimbDataBackup, error) {
return &backup, nil return &backup, nil
} }
func (s *SyncServer) mergeGyms(existing []BackupGym, updates []BackupGym) []BackupGym {
gymMap := make(map[string]BackupGym)
for _, gym := range existing {
gymMap[gym.ID] = gym
}
for _, gym := range updates {
if existingGym, exists := gymMap[gym.ID]; exists {
// Keep newer version based on updatedAt timestamp
if gym.UpdatedAt >= existingGym.UpdatedAt {
gymMap[gym.ID] = gym
}
} else {
gymMap[gym.ID] = gym
}
}
result := make([]BackupGym, 0, len(gymMap))
for _, gym := range gymMap {
result = append(result, gym)
}
return result
}
func (s *SyncServer) mergeProblems(existing []BackupProblem, updates []BackupProblem) []BackupProblem {
problemMap := make(map[string]BackupProblem)
for _, problem := range existing {
problemMap[problem.ID] = problem
}
for _, problem := range updates {
if existingProblem, exists := problemMap[problem.ID]; exists {
if problem.UpdatedAt >= existingProblem.UpdatedAt {
problemMap[problem.ID] = problem
}
} else {
problemMap[problem.ID] = problem
}
}
result := make([]BackupProblem, 0, len(problemMap))
for _, problem := range problemMap {
result = append(result, problem)
}
return result
}
func (s *SyncServer) mergeSessions(existing []BackupClimbSession, updates []BackupClimbSession) []BackupClimbSession {
sessionMap := make(map[string]BackupClimbSession)
for _, session := range existing {
sessionMap[session.ID] = session
}
for _, session := range updates {
if existingSession, exists := sessionMap[session.ID]; exists {
if session.UpdatedAt >= existingSession.UpdatedAt {
sessionMap[session.ID] = session
}
} else {
sessionMap[session.ID] = session
}
}
result := make([]BackupClimbSession, 0, len(sessionMap))
for _, session := range sessionMap {
result = append(result, session)
}
return result
}
func (s *SyncServer) mergeAttempts(existing []BackupAttempt, updates []BackupAttempt) []BackupAttempt {
attemptMap := make(map[string]BackupAttempt)
for _, attempt := range existing {
attemptMap[attempt.ID] = attempt
}
for _, attempt := range updates {
if existingAttempt, exists := attemptMap[attempt.ID]; exists {
if attempt.CreatedAt >= existingAttempt.CreatedAt {
attemptMap[attempt.ID] = attempt
}
} else {
attemptMap[attempt.ID] = attempt
}
}
result := make([]BackupAttempt, 0, len(attemptMap))
for _, attempt := range attemptMap {
result = append(result, attempt)
}
return result
}
func (s *SyncServer) mergeDeletedItems(existing []DeletedItem, updates []DeletedItem) []DeletedItem {
deletedMap := make(map[string]DeletedItem)
for _, item := range existing {
key := item.Type + ":" + item.ID
deletedMap[key] = item
}
for _, item := range updates {
key := item.Type + ":" + item.ID
if existingItem, exists := deletedMap[key]; exists {
if item.DeletedAt >= existingItem.DeletedAt {
deletedMap[key] = item
}
} else {
deletedMap[key] = item
}
}
result := make([]DeletedItem, 0, len(deletedMap))
for _, item := range deletedMap {
result = append(result, item)
}
return result
}
func (s *SyncServer) applyDeletions(backup *ClimbDataBackup, deletedItems []DeletedItem) {
deletedMap := make(map[string]map[string]bool)
for _, item := range deletedItems {
if deletedMap[item.Type] == nil {
deletedMap[item.Type] = make(map[string]bool)
}
deletedMap[item.Type][item.ID] = true
}
if deletedMap["gym"] != nil {
filtered := []BackupGym{}
for _, gym := range backup.Gyms {
if !deletedMap["gym"][gym.ID] {
filtered = append(filtered, gym)
}
}
backup.Gyms = filtered
}
if deletedMap["problem"] != nil {
filtered := []BackupProblem{}
for _, problem := range backup.Problems {
if !deletedMap["problem"][problem.ID] {
filtered = append(filtered, problem)
}
}
backup.Problems = filtered
}
if deletedMap["session"] != nil {
filtered := []BackupClimbSession{}
for _, session := range backup.Sessions {
if !deletedMap["session"][session.ID] {
filtered = append(filtered, session)
}
}
backup.Sessions = filtered
}
if deletedMap["attempt"] != nil {
filtered := []BackupAttempt{}
for _, attempt := range backup.Attempts {
if !deletedMap["attempt"][attempt.ID] {
filtered = append(filtered, attempt)
}
}
backup.Attempts = filtered
}
}
func (s *SyncServer) saveData(backup *ClimbDataBackup) error { func (s *SyncServer) saveData(backup *ClimbDataBackup) error {
backup.ExportedAt = time.Now().UTC().Format(time.RFC3339) backup.ExportedAt = time.Now().UTC().Format(time.RFC3339)
@@ -167,7 +353,6 @@ func (s *SyncServer) saveData(backup *ClimbDataBackup) error {
return err return err
} }
// Ensure images directory exists
if err := os.MkdirAll(s.imagesDir, 0755); err != nil { if err := os.MkdirAll(s.imagesDir, 0755); err != nil {
return err return err
} }
@@ -315,6 +500,123 @@ func (s *SyncServer) handleImageDownload(w http.ResponseWriter, r *http.Request)
w.Write(imageData) w.Write(imageData)
} }
func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(r) {
log.Printf("Unauthorized delta sync attempt from %s", r.RemoteAddr)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var deltaRequest DeltaSyncRequest
if err := json.NewDecoder(r.Body).Decode(&deltaRequest); err != nil {
log.Printf("Invalid JSON from %s: %v", r.RemoteAddr, err)
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
log.Printf("Delta sync from %s: lastSyncTime=%s, gyms=%d, problems=%d, sessions=%d, attempts=%d, deletedItems=%d",
r.RemoteAddr, deltaRequest.LastSyncTime,
len(deltaRequest.Gyms), len(deltaRequest.Problems),
len(deltaRequest.Sessions), len(deltaRequest.Attempts),
len(deltaRequest.DeletedItems))
// Load current server data
serverBackup, err := s.loadData()
if err != nil {
log.Printf("Failed to load data: %v", err)
http.Error(w, "Failed to load data", http.StatusInternalServerError)
return
}
// Merge client changes into server data
serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms)
serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems)
serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions)
serverBackup.Attempts = s.mergeAttempts(serverBackup.Attempts, deltaRequest.Attempts)
serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems)
// Apply deletions to remove deleted items
s.applyDeletions(serverBackup, serverBackup.DeletedItems)
// Save merged data
if err := s.saveData(serverBackup); err != nil {
log.Printf("Failed to save data: %v", err)
http.Error(w, "Failed to save data", http.StatusInternalServerError)
return
}
// Parse client's last sync time
clientLastSync, err := time.Parse(time.RFC3339, deltaRequest.LastSyncTime)
if err != nil {
// If parsing fails, send everything
clientLastSync = time.Time{}
}
// Prepare response with items modified since client's last sync
response := DeltaSyncResponse{
ServerTime: time.Now().UTC().Format(time.RFC3339),
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Filter gyms modified after client's last sync
for _, gym := range serverBackup.Gyms {
gymTime, err := time.Parse(time.RFC3339, gym.UpdatedAt)
if err == nil && gymTime.After(clientLastSync) {
response.Gyms = append(response.Gyms, gym)
}
}
// Filter problems modified after client's last sync
for _, problem := range serverBackup.Problems {
problemTime, err := time.Parse(time.RFC3339, problem.UpdatedAt)
if err == nil && problemTime.After(clientLastSync) {
response.Problems = append(response.Problems, problem)
}
}
// Filter sessions modified after client's last sync
for _, session := range serverBackup.Sessions {
sessionTime, err := time.Parse(time.RFC3339, session.UpdatedAt)
if err == nil && sessionTime.After(clientLastSync) {
response.Sessions = append(response.Sessions, session)
}
}
// Filter attempts created after client's last sync
for _, attempt := range serverBackup.Attempts {
attemptTime, err := time.Parse(time.RFC3339, attempt.CreatedAt)
if err == nil && attemptTime.After(clientLastSync) {
response.Attempts = append(response.Attempts, attempt)
}
}
// Filter deletions after client's last sync
for _, deletedItem := range serverBackup.DeletedItems {
deletedTime, err := time.Parse(time.RFC3339, deletedItem.DeletedAt)
if err == nil && deletedTime.After(clientLastSync) {
response.DeletedItems = append(response.DeletedItems, deletedItem)
}
}
log.Printf("Delta sync response to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d, deletedItems=%d",
r.RemoteAddr,
len(response.Gyms), len(response.Problems),
len(response.Sessions), len(response.Attempts),
len(response.DeletedItems))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (s *SyncServer) handleSync(w http.ResponseWriter, r *http.Request) { func (s *SyncServer) handleSync(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@@ -354,6 +656,7 @@ func main() {
} }
http.HandleFunc("/sync", server.handleSync) http.HandleFunc("/sync", server.handleSync)
http.HandleFunc("/sync/delta", server.handleDeltaSync)
http.HandleFunc("/health", server.handleHealth) http.HandleFunc("/health", server.handleHealth)
http.HandleFunc("/images/upload", server.handleImageUpload) http.HandleFunc("/images/upload", server.handleImageUpload)
http.HandleFunc("/images/download", server.handleImageDownload) http.HandleFunc("/images/download", server.handleImageDownload)
@@ -362,6 +665,8 @@ func main() {
fmt.Printf("Data file: %s\n", dataFile) fmt.Printf("Data file: %s\n", dataFile)
fmt.Printf("Images directory: %s\n", imagesDir) fmt.Printf("Images directory: %s\n", imagesDir)
fmt.Printf("Health check available at /health\n") fmt.Printf("Health check available at /health\n")
fmt.Printf("Delta sync: POST /sync/delta (incremental sync)\n")
fmt.Printf("Full sync: GET /sync (download all), PUT /sync (upload all)\n")
fmt.Printf("Image upload: POST /images/upload?filename=<name>\n") fmt.Printf("Image upload: POST /images/upload?filename=<name>\n")
fmt.Printf("Image download: GET /images/download?filename=<name>\n") fmt.Printf("Image download: GET /images/download?filename=<name>\n")