Compare commits

...

11 Commits

Author SHA1 Message Date
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
58 changed files with 5515 additions and 1898 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:
push:
branches: [main]
@@ -14,7 +14,7 @@ jobs:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
@@ -27,7 +27,7 @@ jobs:
password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push sync-server
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
context: ./sync
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_
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:
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.
Documentation can be found at [https://ascently.atri.dad](https://ascently.atri.dad)!
## 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.
- `service/`: Background service for tracking climbing sessions.
- `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"
minSdk = 31
targetSdk = 36
versionCode = 40
versionName = "2.0.0"
versionCode = 41
versionName = "2.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

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

View File

@@ -40,7 +40,6 @@ class HealthConnectManager(private val context: Context) {
val isEnabled: Flow<Boolean> = _isEnabled.asStateFlow()
val hasPermissions: Flow<Boolean> = _hasPermissions.asStateFlow()
val autoSyncEnabled: Flow<Boolean> = _autoSync.asStateFlow()
val isCompatible: Flow<Boolean> = _isCompatible.asStateFlow()
companion object {
@@ -83,12 +82,25 @@ class HealthConnectManager(private val context: Context) {
}
}
/** Enable or disable Health Connect integration */
fun setEnabled(enabled: Boolean) {
/**
* Enable or disable Health Connect integration and automatically request permissions if
* enabling
*/
suspend fun setEnabled(enabled: Boolean) {
preferences.edit().putBoolean("enabled", enabled).apply()
_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)
}
}
@@ -99,12 +111,6 @@ class HealthConnectManager(private val context: Context) {
_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 {
return try {
@@ -147,83 +153,28 @@ class HealthConnectManager(private val context: Context) {
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> {
return try {
REQUIRED_PERMISSIONS.map { it.toString() }.toSet()
REQUIRED_PERMISSIONS.map { it }.toSet()
} catch (e: Exception) {
Log.e(TAG, "Error getting required permissions", e)
emptySet()
}
}
/** Sync a completed climbing session to Health Connect */
/** Sync a completed climbing session to Health Connect (only when auto-sync is enabled) */
@SuppressLint("RestrictedApi")
suspend fun syncClimbingSession(
suspend fun syncCompletedSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
): Result<Unit> {
return try {
if (!isReady()) {
return Result.failure(IllegalStateException("Health Connect not ready"))
if (!isReady() || !_autoSync.value) {
return Result.failure(
IllegalStateException("Health Connect not ready or auto-sync disabled")
)
}
if (session.status != SessionStatus.COMPLETED) {
@@ -320,18 +271,19 @@ class HealthConnectManager(private val context: Context) {
}
}
/** Auto-sync a session if enabled */
suspend fun autoSyncSession(
/** Auto-sync a completed session if enabled - this is the only way to sync sessions */
suspend fun autoSyncCompletedSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
): Result<Unit> {
return if (_autoSync.value && isReady()) {
Log.d(TAG, "Auto-syncing session '${session.id}' to Health Connect...")
syncClimbingSession(session, gymName, attemptCount)
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
Log.d(TAG, "Auto-syncing completed session '${session.id}' to Health Connect...")
syncCompletedSession(session, gymName, attemptCount)
} else {
val reason =
when {
session.status != SessionStatus.COMPLETED -> "session not completed"
!_autoSync.value -> "auto-sync disabled"
!isReady() -> "Health Connect not ready"
else -> "unknown reason"
@@ -395,32 +347,9 @@ 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 {
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,
FALL,
NO_PROGRESS,
FLASH,
FLASH
}
@Entity(
@@ -74,5 +74,4 @@ data class Attempt(
)
}
}
}

View File

@@ -6,12 +6,11 @@ import kotlinx.serialization.Serializable
enum class ClimbType {
ROPE,
BOULDER;
/**
* Get the display name
*/
fun getDisplayName(): String = when (this) {
ROPE -> "Rope"
BOULDER -> "Bouldering"
}
val displayName: String
get() =
when (this) {
ROPE -> "Rope"
BOULDER -> "Bouldering"
}
}

View File

@@ -12,130 +12,129 @@ enum class DifficultySystem {
YDS,
CUSTOM;
/** Get the display name for the UI */
fun getDisplayName(): String =
when (this) {
V_SCALE -> "V Scale"
FONT -> "Font Scale"
YDS -> "YDS (Yosemite)"
CUSTOM -> "Custom"
}
val displayName: String
get() =
when (this) {
V_SCALE -> "V Scale"
FONT -> "Font Scale"
YDS -> "YDS (Yosemite)"
CUSTOM -> "Custom"
}
/** Check if this system is for bouldering */
fun isBoulderingSystem(): Boolean =
when (this) {
V_SCALE, FONT -> true
YDS -> false
CUSTOM -> true
}
val isBoulderingSystem: Boolean
get() =
when (this) {
V_SCALE, FONT -> true
YDS -> false
CUSTOM -> true
}
/** Check if this system is for rope climbing */
fun isRopeSystem(): Boolean =
when (this) {
YDS -> true
V_SCALE, FONT -> false
CUSTOM -> true
}
val isRopeSystem: Boolean
get() =
when (this) {
YDS -> true
V_SCALE, FONT -> false
CUSTOM -> true
}
/** Get available grades for this system */
fun getAvailableGrades(): List<String> =
when (this) {
V_SCALE ->
listOf(
"VB",
"V0",
"V1",
"V2",
"V3",
"V4",
"V5",
"V6",
"V7",
"V8",
"V9",
"V10",
"V11",
"V12",
"V13",
"V14",
"V15",
"V16",
"V17"
)
FONT ->
listOf(
"3",
"4A",
"4B",
"4C",
"5A",
"5B",
"5C",
"6A",
"6A+",
"6B",
"6B+",
"6C",
"6C+",
"7A",
"7A+",
"7B",
"7B+",
"7C",
"7C+",
"8A",
"8A+",
"8B",
"8B+",
"8C",
"8C+"
)
YDS ->
listOf(
"5.0",
"5.1",
"5.2",
"5.3",
"5.4",
"5.5",
"5.6",
"5.7",
"5.8",
"5.9",
"5.10a",
"5.10b",
"5.10c",
"5.10d",
"5.11a",
"5.11b",
"5.11c",
"5.11d",
"5.12a",
"5.12b",
"5.12c",
"5.12d",
"5.13a",
"5.13b",
"5.13c",
"5.13d",
"5.14a",
"5.14b",
"5.14c",
"5.14d",
"5.15a",
"5.15b",
"5.15c",
"5.15d"
)
CUSTOM -> emptyList()
}
val availableGrades: List<String>
get() =
when (this) {
V_SCALE ->
listOf(
"VB",
"V0",
"V1",
"V2",
"V3",
"V4",
"V5",
"V6",
"V7",
"V8",
"V9",
"V10",
"V11",
"V12",
"V13",
"V14",
"V15",
"V16",
"V17"
)
FONT ->
listOf(
"3",
"4A",
"4B",
"4C",
"5A",
"5B",
"5C",
"6A",
"6A+",
"6B",
"6B+",
"6C",
"6C+",
"7A",
"7A+",
"7B",
"7B+",
"7C",
"7C+",
"8A",
"8A+",
"8B",
"8B+",
"8C",
"8C+"
)
YDS ->
listOf(
"5.0",
"5.1",
"5.2",
"5.3",
"5.4",
"5.5",
"5.6",
"5.7",
"5.8",
"5.9",
"5.10a",
"5.10b",
"5.10c",
"5.10d",
"5.11a",
"5.11b",
"5.11c",
"5.11d",
"5.12a",
"5.12b",
"5.12c",
"5.12d",
"5.13a",
"5.13b",
"5.13c",
"5.13d",
"5.14a",
"5.14b",
"5.14c",
"5.14d",
"5.15a",
"5.15b",
"5.15c",
"5.15d"
)
CUSTOM -> emptyList()
}
companion object {
/** Get all difficulty systems based on type */
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
fun systemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
when (climbType) {
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }
ClimbType.ROPE -> entries.filter { it.isRopeSystem() }
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem }
ClimbType.ROPE -> entries.filter { it.isRopeSystem }
}
}
}
@@ -154,38 +153,78 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
DifficultySystem.V_SCALE -> {
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 -> {
when {
grade.startsWith("6A") -> 6
grade.startsWith("6B") -> 7
grade.startsWith("6C") -> 8
grade.startsWith("7A") -> 9
grade.startsWith("7B") -> 10
grade.startsWith("7C") -> 11
grade.startsWith("8A") -> 12
grade.startsWith("8B") -> 13
grade.startsWith("8C") -> 14
else -> grade.toIntOrNull() ?: 0
}
val fontMapping: Map<String, Int> =
mapOf(
"3" to 3,
"4A" to 4,
"4B" to 5,
"4C" to 6,
"5A" to 7,
"5B" to 8,
"5C" to 9,
"6A" to 10,
"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.CUSTOM -> grade.hashCode().rem(100)
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.toIntOrNull() ?: 0
}
}
}

View File

@@ -130,17 +130,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
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
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
set(value) {
@@ -277,8 +266,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun uploadData(backup: ClimbDataBackup) {
val requestBody =
json.encodeToString(ClimbDataBackup.serializer(), backup)
.toRequestBody("application/json".toMediaType())
json.encodeToString(backup).toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
@@ -440,9 +428,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
backup.problems.map { backupProblem ->
val imagePaths = backupProblem.imagePaths
val updatedImagePaths =
imagePaths?.map { oldPath ->
imagePathMapping[oldPath] ?: oldPath
}
imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath }
backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
}
val sessions = backup.sessions.map { it.toClimbSession() }
@@ -544,26 +530,16 @@ class SyncService(private val context: Context, private val repository: ClimbRep
sealed class SyncException(message: String) : IOException(message), Serializable {
object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.") {
@JvmStatic private fun readResolve(): Any = NotConfigured
}
SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.") {
@JvmStatic private fun readResolve(): Any = NotConnected
}
object NotConnected : SyncException("Not connected to server. Please test connection first.")
object Unauthorized : SyncException("Unauthorized. Please check your auth token.") {
@JvmStatic private fun readResolve(): Any = Unauthorized
}
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
object ImageNotFound : SyncException("Image not found on server") {
@JvmStatic private fun readResolve(): Any = ImageNotFound
}
object ImageNotFound : SyncException("Image not found on server")
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details")
data class DecodingError(val details: String) :
SyncException("Failed to decode server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details")
}

View File

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

View File

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

View File

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

View File

@@ -31,14 +31,14 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
// Collect flows
val isEnabled by healthConnectManager.isEnabled.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)
// Permission launcher
val permissionLauncher =
rememberLauncherForActivityResult(
contract = healthConnectManager.getPermissionRequestContract()
) { grantedPermissions ->
) { _ ->
coroutineScope.launch {
val allGranted = healthConnectManager.hasAllPermissions()
if (!allGranted) {
@@ -86,313 +86,207 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
) {
// Header with icon and title
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.HealthAndSafety,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint =
if (isHealthConnectAvailable && isEnabled && hasPermissions) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
imageVector = Icons.Default.HealthAndSafety,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint =
if (isHealthConnectAvailable && isEnabled && hasPermissions) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Health Connect",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
text = "Health Connect",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text =
when {
isLoading -> "Checking availability..."
!isCompatible -> "API Issue"
!isHealthConnectAvailable -> "Not available"
isEnabled && hasPermissions -> "Connected"
isEnabled && !hasPermissions -> "Needs permissions"
else -> "Disabled"
},
style = MaterialTheme.typography.bodySmall,
color =
when {
isLoading ->
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
!isCompatible -> MaterialTheme.colorScheme.error
!isHealthConnectAvailable -> MaterialTheme.colorScheme.error
isEnabled && hasPermissions ->
MaterialTheme.colorScheme.primary
isEnabled && !hasPermissions ->
MaterialTheme.colorScheme.tertiary
else ->
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
}
text =
when {
isLoading -> "Checking availability..."
!isCompatible -> "API Issue"
!isHealthConnectAvailable -> "Not available"
isEnabled && hasPermissions -> "Connected"
isEnabled && !hasPermissions -> "Needs permissions"
else -> "Disabled"
},
style = MaterialTheme.typography.bodySmall,
color =
when {
isLoading ->
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
!isCompatible -> MaterialTheme.colorScheme.error
!isHealthConnectAvailable -> MaterialTheme.colorScheme.error
isEnabled && hasPermissions ->
MaterialTheme.colorScheme.primary
isEnabled && !hasPermissions ->
MaterialTheme.colorScheme.tertiary
else ->
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
}
)
}
// Main toggle switch
Switch(
checked = isEnabled,
onCheckedChange = { enabled ->
checked = isEnabled,
onCheckedChange = { enabled ->
coroutineScope.launch {
if (enabled && isHealthConnectAvailable) {
healthConnectManager.setEnabled(true)
coroutineScope.launch {
try {
val permissionSet =
healthConnectManager.getRequiredPermissions()
if (permissionSet.isNotEmpty()) {
permissionLauncher.launch(permissionSet)
}
} catch (e: Exception) {
errorMessage = "Error requesting permissions: ${e.message}"
try {
val permissionSet =
healthConnectManager.getRequiredPermissions()
if (permissionSet.isNotEmpty()) {
permissionLauncher.launch(permissionSet)
}
} catch (e: Exception) {
errorMessage = "Error requesting permissions: ${e.message}"
}
} else {
healthConnectManager.setEnabled(false)
errorMessage = null
}
},
enabled = isHealthConnectAvailable && !isLoading && isCompatible
}
},
enabled = isHealthConnectAvailable && !isLoading && isCompatible
)
}
if (isEnabled) {
Spacer(modifier = Modifier.height(16.dp))
Card(
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(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (hasPermissions) {
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.3f
)
} else {
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f
)
}
)
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector =
if (hasPermissions) Icons.Default.CheckCircle
else Icons.Default.Warning,
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f
)
)
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint =
if (hasPermissions) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
if (hasPermissions) "Ready to sync"
else "Permissions needed",
Text(
text = "Permissions needed",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
if (!hasPermissions) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
"Grant Health Connect permissions to sync your climbing sessions",
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.8f
)
text =
"Grant Health Connect permissions to sync your climbing sessions",
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.8f
)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = {
coroutineScope.launch {
try {
val permissionSet =
healthConnectManager
.getRequiredPermissions()
if (permissionSet.isNotEmpty()) {
permissionLauncher.launch(permissionSet)
}
} catch (e: Exception) {
errorMessage =
"Error requesting permissions: ${e.message}"
onClick = {
coroutineScope.launch {
try {
val permissionSet =
healthConnectManager
.getRequiredPermissions()
if (permissionSet.isNotEmpty()) {
permissionLauncher.launch(permissionSet)
}
} catch (e: Exception) {
errorMessage =
"Error requesting permissions: ${e.message}"
}
},
modifier = Modifier.fillMaxWidth()
}
},
modifier = Modifier.fillMaxWidth()
) { 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 {
Spacer(modifier = Modifier.height(16.dp))
Text(
} else {
Spacer(modifier = Modifier.height(16.dp))
Text(
text =
"Sync your climbing sessions to Samsung Health, Google Fit, and other fitness apps through Health Connect.",
"Sync your climbing sessions to Samsung Health, Google Fit, and other fitness apps through Health Connect.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
)
}
errorMessage?.let { error ->
Spacer(modifier = Modifier.height(12.dp))
)
}
errorMessage?.let { error ->
Spacer(modifier = Modifier.height(12.dp))
Card(
Card(
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.5f
)
)
) {
Row(
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.5f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
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
)
}
}
@@ -401,40 +295,3 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
}
}
}
@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()
} else {
selectedClimbTypes
.flatMap { climbType -> DifficultySystem.getSystemsForClimbType(climbType) }
.flatMap { climbType -> DifficultySystem.systemsForClimbType(climbType) }
.distinct()
}
@@ -89,7 +89,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
)
if (isEditing) {
viewModel.updateGym(gym.copy(id = gymId!!))
viewModel.updateGym(gym.copy(id = gymId))
} else {
viewModel.addGym(gym)
}
@@ -164,7 +164,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
onCheckedChange = null
)
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
)
Spacer(modifier = Modifier.width(8.dp))
Text(system.getDisplayName())
Text(system.displayName)
}
}
}
@@ -248,7 +248,6 @@ fun AddEditProblemScreen(
) {
val isEditing = problemId != null
val gyms by viewModel.gyms.collectAsState()
val context = LocalContext.current
// Problem form state
var selectedGym by remember {
@@ -295,7 +294,7 @@ fun AddEditProblemScreen(
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
val availableDifficultySystems =
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
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)
LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades()
val availableGrades = selectedDifficultySystem.availableGrades
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
difficultyGrade = ""
}
@@ -387,12 +386,11 @@ fun AddEditProblemScreen(
)
if (isEditing) {
viewModel.updateProblem(
problem.copy(id = problemId),
context
)
problemId.let { id ->
viewModel.updateProblem(problem.copy(id = id))
}
} else {
viewModel.addProblem(problem, context)
viewModel.addProblem(problem)
}
onNavigateBack()
}
@@ -505,7 +503,7 @@ fun AddEditProblemScreen(
availableClimbTypes.forEach { climbType ->
FilterChip(
onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) },
label = { Text(climbType.displayName) },
selected = selectedClimbType == climbType
)
}
@@ -538,7 +536,7 @@ fun AddEditProblemScreen(
items(availableDifficultySystems) { system ->
FilterChip(
onClick = { selectedDifficultySystem = system },
label = { Text(system.getDisplayName()) },
label = { Text(system.displayName) },
selected = selectedDifficultySystem == system
)
}
@@ -570,7 +568,7 @@ fun AddEditProblemScreen(
)
} else {
var expanded by remember { mutableStateOf(false) }
val availableGrades = selectedDifficultySystem.getAvailableGrades()
val availableGrades = selectedDifficultySystem.availableGrades
ExposedDropdownMenuBox(
expanded = expanded,
@@ -592,8 +590,7 @@ fun AddEditProblemScreen(
.outlinedTextFieldColors(),
modifier =
Modifier.menuAnchor(
androidx.compose.material3
.MenuAnchorType
ExposedDropdownMenuAnchorType
.PrimaryNotEditable,
enabled = true
)
@@ -765,9 +762,9 @@ fun AddEditSessionScreen(
null
}
)
viewModel.updateSession(
session.copy(id = sessionId!!)
)
sessionId.let { id ->
viewModel.updateSession(session.copy(id = id))
}
} else {
viewModel.startSession(
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.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.utils.DateFormatUtils
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Composable
fun AnalyticsScreen(viewModel: ClimbViewModel) {
@@ -253,11 +253,8 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
systemFiltered.filter { dataPoint ->
try {
val attemptDate =
LocalDateTime.parse(
dataPoint.date,
DateTimeFormatter.ISO_LOCAL_DATE_TIME
)
attemptDate.isAfter(sevenDaysAgo)
DateFormatUtils.parseToLocalDateTime(dataPoint.date)
attemptDate?.isAfter(sevenDaysAgo) == true
} catch (_: Exception) {
// If date parsing fails, include the data point
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.Delete
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.KeyboardArrowUp
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewModelScope
import com.atridad.ascently.data.model.*
import com.atridad.ascently.ui.components.FullscreenImageViewer
import com.atridad.ascently.ui.components.ImageDisplaySection
import com.atridad.ascently.ui.theme.CustomIcons
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -221,7 +216,6 @@ fun SessionDetailScreen(
val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState()
var isGeneratingShare by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showAddAttemptDialog by remember { mutableStateOf(false) }
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) }
@@ -234,7 +228,7 @@ fun SessionDetailScreen(
val successfulAttempts =
attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
val uniqueProblems = attempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in uniqueProblems }
val completedProblems = successfulAttempts.map { it.problemId }.distinct()
val attemptsWithProblems =
@@ -261,64 +255,8 @@ fun SessionDetailScreen(
}
},
actions = {
if (session?.duration != null) {
val healthConnectManager = viewModel.getHealthConnectManager()
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"
)
}
}
}
// No manual actions needed - Health Connect syncs automatically when
// sessions complete
// Show stop icon for active sessions, delete icon for completed
// sessions
@@ -564,7 +502,7 @@ fun SessionDetailScreen(
viewModel.addAttempt(attempt)
showAddAttemptDialog = false
},
onProblemCreated = { problem -> viewModel.addProblem(problem, context) }
onProblemCreated = { problem -> viewModel.addProblem(problem) }
)
}
@@ -590,7 +528,7 @@ fun ProblemDetailScreen(
onNavigateBack: () -> Unit,
onNavigateToEdit: (String) -> Unit
) {
val context = LocalContext.current
var showDeleteDialog by remember { mutableStateOf(false) }
var showImageViewer by remember { mutableStateOf(false) }
var selectedImageIndex by remember { mutableIntStateOf(0) }
@@ -665,7 +603,7 @@ fun ProblemDetailScreen(
problem?.let { p ->
Text(
text =
"${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}",
"${p.difficulty.system.displayName}: ${p.difficulty.grade}",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
@@ -674,7 +612,7 @@ fun ProblemDetailScreen(
problem?.let { p ->
Text(
text = p.climbType.getDisplayName(),
text = p.climbType.displayName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -854,7 +792,7 @@ fun ProblemDetailScreen(
TextButton(
onClick = {
problem?.let { p ->
viewModel.deleteProblem(p, context)
viewModel.deleteProblem(p)
onNavigateBack()
}
showDeleteDialog = false
@@ -1236,19 +1174,10 @@ fun GymDetailScreen(
}
},
supportingContent = {
val dateTime =
try {
LocalDateTime.parse(session.date)
} catch (_: Exception) {
null
}
val formattedDate =
dateTime?.format(
DateTimeFormatter.ofPattern(
"MMM dd, yyyy"
)
DateFormatUtils.formatDateForDisplay(
session.date
)
?: session.date
Text(
"$formattedDate${sessionAttempts.size} attempts"
@@ -1463,7 +1392,7 @@ fun SessionAttemptCard(
Text(
text =
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
"${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
@@ -1538,14 +1467,7 @@ fun SessionAttemptCard(
}
private fun formatDate(dateString: String): String {
return try {
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
}
return DateFormatUtils.formatDateForDisplay(dateString)
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -1584,7 +1506,7 @@ fun EnhancedAddAttemptDialog(
// Auto-select difficulty system if there's only one available for the selected climb type
LaunchedEffect(selectedClimbType, gym.difficultySystems) {
val availableSystems =
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
gym.difficultySystems.contains(system)
}
@@ -1604,7 +1526,7 @@ fun EnhancedAddAttemptDialog(
// Reset grade when difficulty system changes
LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades()
val availableGrades = selectedDifficultySystem.availableGrades
if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) {
newProblemGrade = ""
}
@@ -1721,7 +1643,7 @@ fun EnhancedAddAttemptDialog(
Spacer(modifier = Modifier.height(4.dp))
Text(
text =
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
"${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
style =
MaterialTheme.typography
.bodyMedium,
@@ -1730,7 +1652,7 @@ fun EnhancedAddAttemptDialog(
MaterialTheme
.colorScheme
.onSurface.copy(
alpha = 0.8f
alpha = 0.9f
)
else
MaterialTheme
@@ -1807,7 +1729,7 @@ fun EnhancedAddAttemptDialog(
onClick = { selectedClimbType = climbType },
label = {
Text(
climbType.getDisplayName(),
climbType.displayName,
fontWeight = FontWeight.Medium
)
},
@@ -1838,7 +1760,7 @@ fun EnhancedAddAttemptDialog(
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val availableSystems =
DifficultySystem.getSystemsForClimbType(
DifficultySystem.systemsForClimbType(
selectedClimbType
)
.filter { system ->
@@ -1849,7 +1771,7 @@ fun EnhancedAddAttemptDialog(
onClick = { selectedDifficultySystem = system },
label = {
Text(
system.getDisplayName(),
system.displayName,
fontWeight = FontWeight.Medium
)
},
@@ -1926,8 +1848,7 @@ fun EnhancedAddAttemptDialog(
)
} else {
var expanded by remember { mutableStateOf(false) }
val availableGrades =
selectedDifficultySystem.getAvailableGrades()
val availableGrades = selectedDifficultySystem.availableGrades
ExposedDropdownMenuBox(
expanded = expanded,
@@ -1949,8 +1870,7 @@ fun EnhancedAddAttemptDialog(
.outlinedTextFieldColors(),
modifier =
Modifier.menuAnchor(
androidx.compose.material3
.MenuAnchorType
ExposedDropdownMenuAnchorType
.PrimaryNotEditable,
enabled = true
)

View File

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

View File

@@ -11,7 +11,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -30,7 +29,6 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState()
val attempts by viewModel.attempts.collectAsState()
val context = LocalContext.current
// Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
@@ -104,7 +102,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
items(ClimbType.entries) { climbType ->
FilterChip(
onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) },
label = { Text(climbType.displayName) },
selected = selectedClimbType == climbType
)
}
@@ -183,7 +181,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
onClick = { onNavigateToProblemDetail(problem.id) },
onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem, context)
viewModel.updateProblem(updatedProblem)
}
)
Spacer(modifier = Modifier.height(8.dp))
@@ -268,7 +266,7 @@ fun ProblemCard(
}
Text(
text = problem.climbType.getDisplayName(),
text = problem.climbType.displayName,
style = MaterialTheme.typography.bodySmall,
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.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import com.atridad.ascently.utils.DateFormatUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -247,10 +246,5 @@ fun EmptyStateMessage(
}
private fun formatDate(dateString: String): String {
return try {
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
} catch (_: Exception) {
dateString
}
return DateFormatUtils.formatDateForDisplay(dateString)
}

View File

@@ -50,15 +50,15 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
var isDeletingImages by remember { mutableStateOf(false) }
// Sync configuration state
var serverUrl by remember { mutableStateOf(syncService.serverURL) }
var serverUrl by remember { mutableStateOf(syncService.serverUrl) }
var authToken by remember { mutableStateOf(syncService.authToken) }
val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) }
val appVersion = packageInfo.versionName
// Update local state when sync service configuration changes
LaunchedEffect(syncService.serverURL, syncService.authToken) {
serverUrl = syncService.serverURL
LaunchedEffect(syncService.serverUrl, syncService.authToken) {
serverUrl = syncService.serverUrl
authToken = syncService.authToken
}
@@ -183,7 +183,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
},
supportingContent = {
Column {
Text("Server: ${syncService.serverURL}")
Text("Server: ${syncService.serverUrl}")
lastSyncTime?.let { time ->
Text(
"Last sync: ${
@@ -863,7 +863,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
onClick = {
coroutineScope.launch {
try {
syncService.serverURL = serverUrl.trim()
syncService.serverUrl = serverUrl.trim()
syncService.authToken = authToken.trim()
viewModel.testSyncConnection()
while (syncService.isTesting.value) {
@@ -905,7 +905,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
onClick = {
coroutineScope.launch {
try {
syncService.serverURL = serverUrl.trim()
syncService.serverUrl = serverUrl.trim()
syncService.authToken = authToken.trim()
viewModel.testSyncConnection()
while (syncService.isTesting.value) {
@@ -932,7 +932,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
dismissButton = {
TextButton(
onClick = {
serverUrl = syncService.serverURL
serverUrl = syncService.serverUrl
authToken = syncService.authToken
showSyncConfigDialog = false
}
@@ -981,7 +981,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
isDeletingImages = true
showDeleteImagesDialog = false
coroutineScope.launch {
viewModel.deleteAllImages(context)
viewModel.deleteAllImages()
isDeletingImages = false
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.sync.SyncService
import com.atridad.ascently.service.SessionTrackingService
import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils
import com.atridad.ascently.utils.SessionShareUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ClimbViewModel(
private val repository: ClimbRepository,
@@ -78,64 +74,57 @@ class ClimbViewModel(
)
// Gym operations
fun addGym(gym: Gym) {
viewModelScope.launch { repository.insertGym(gym) }
}
fun addGym(gym: Gym, context: Context) {
fun addGym(gym: Gym, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.insertGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
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 {
repository.updateGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
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 {
repository.deleteGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
// Problem operations
fun addProblem(problem: Problem, context: Context) {
fun addProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch {
val finalProblem = renameTemporaryImages(problem, context)
val finalProblem = renameTemporaryImages(problem)
repository.insertProblem(finalProblem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem {
private fun renameTemporaryImages(problem: Problem): Problem {
if (problem.imagePaths.isEmpty()) {
return problem
}
val appContext = context ?: return problem
val finalImagePaths = mutableListOf<String>()
problem.imagePaths.forEachIndexed { index, tempPath ->
if (tempPath.startsWith("temp_")) {
val deterministicName = ImageNamingUtils.generateImageFilename(problem.id, index)
val finalPath =
ImageUtils.renameTemporaryImage(appContext, tempPath, problem.id, index)
ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index)
finalImagePaths.add(finalPath ?: tempPath)
} else {
finalImagePaths.add(tempPath)
@@ -145,34 +134,34 @@ class ClimbViewModel(
return problem.copy(imagePaths = finalImagePaths)
}
fun updateProblem(problem: Problem, context: Context) {
fun updateProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch {
val finalProblem = renameTemporaryImages(problem, context)
val finalProblem = renameTemporaryImages(problem)
repository.updateProblem(finalProblem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun deleteProblem(problem: Problem, context: Context) {
fun deleteProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch {
// Delete associated images
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
repository.deleteProblem(problem)
cleanupOrphanedImages(context)
ClimbStatsWidgetProvider.updateAllWidgets(context)
cleanupOrphanedImages()
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
private suspend fun cleanupOrphanedImages(context: Context) {
private suspend fun cleanupOrphanedImages() {
val allProblems = repository.getAllProblems().first()
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
}
fun deleteAllImages(context: Context) {
fun deleteAllImages() {
viewModelScope.launch {
val imagesDir = ImageUtils.getImagesDirectory(context)
var deletedCount = 0
@@ -212,36 +201,22 @@ class ClimbViewModel(
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
// Session operations
fun addSession(session: ClimbSession) {
viewModelScope.launch { repository.insertSession(session) }
}
fun addSession(session: ClimbSession, context: Context) {
viewModelScope.launch {
repository.insertSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
fun updateSession(session: ClimbSession) {
viewModelScope.launch { repository.updateSession(session) }
}
fun updateSession(session: ClimbSession, context: Context) {
fun updateSession(session: ClimbSession, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.updateSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
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 {
repository.deleteSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
@@ -345,36 +320,30 @@ class ClimbViewModel(
}
// Attempt operations
fun addAttempt(attempt: Attempt) {
viewModelScope.launch { repository.insertAttempt(attempt) }
}
fun addAttempt(attempt: Attempt, context: Context) {
fun addAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.insertAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
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 {
repository.deleteAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
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 {
repository.updateAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
@@ -499,107 +468,28 @@ class ClimbViewModel(
val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size
val result = healthConnectManager.autoSyncSession(session, gymName, attemptCount)
val result =
healthConnectManager.autoSyncCompletedSession(
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}"
)
}
}
result.onFailure { error ->
if (healthConnectManager.isReadySync()) {
android.util.Log.w(
"ClimbViewModel",
"Health Connect sync failed: ${error.message}"
)
}
}
} catch (e: Exception) {
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
android.util.Log.w("ClimbViewModel", "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 =
healthConnectManager.syncClimbingSession(session, gymName, attemptCount)
result
.onSuccess {
_uiState.value =
_uiState.value.copy(
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) {
_uiState.value =
_uiState.value.copy(error = "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)
}
}
data class ClimbUiState(

View File

@@ -1,6 +1,8 @@
package com.atridad.ascently.utils
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@@ -15,32 +17,47 @@ object DateFormatUtils {
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 */
fun parseISO8601(dateString: String): Instant? {
return try {
Instant.from(ISO_FORMATTER.parse(dateString))
} catch (e: Exception) {
} catch (_: Exception) {
try {
Instant.parse(dateString)
} catch (e2: Exception) {
} catch (_: Exception) {
null
}
}
}
/** Validate that a date string matches the expected iOS format */
fun isValidISO8601(dateString: String): Boolean {
return parseISO8601(dateString) != null
/**
* Format a UTC ISO 8601 date string for display in local timezone This fixes the timezone
* 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 */
fun millisToISO8601(millis: Long): String {
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
/** Parse a UTC ISO 8601 date string to LocalDateTime in system timezone */
fun parseToLocalDateTime(dateString: String): LocalDateTime? {
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
import java.security.MessageDigest
import java.util.*
/**
* Utility for creating consistent image filenames across iOS and Android platforms. Uses
@@ -21,51 +20,11 @@ object ImageNamingUtils {
}
/** Legacy method for backward compatibility */
@Suppress("UNUSED_PARAMETER")
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
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 */
private fun createHash(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
@@ -74,53 +33,5 @@ object ImageNamingUtils {
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 */
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.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import androidx.core.graphics.scale
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 */
@SuppressLint("UseKtx")
private fun compressImage(original: Bitmap): Bitmap {
@@ -260,9 +165,8 @@ object ImageUtils {
/** Temporarily saves an image during selection process */
fun saveTemporaryImageFromUri(context: Context, imageUri: Uri): String? {
return try {
val originalBitmap =
MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri)
?: return null
val source = ImageDecoder.createSource(context.contentResolver, imageUri)
val originalBitmap = ImageDecoder.decodeBitmap(source)
val tempFilename = "temp_${UUID.randomUUID()}.jpg"
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 */
fun saveImageFromBytesWithFilename(
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 */
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
try {

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 attempts = repository.getAllAttempts().first()
val gyms = repository.getAllGyms().first()
val activeSession = repository.getActiveSession()
// Calculate stats
val completedSessions = sessions.filter { it.endTime != null }
@@ -108,7 +107,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
appWidgetManager.updateAppWidget(appWidgetId, views)
}
} catch (e: Exception) {
} catch (_: Exception) {
launch(Dispatchers.Main) {
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
views.setTextViewText(R.id.widget_total_sessions, "0")

View File

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

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

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

@@ -0,0 +1,61 @@
// @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: "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.0",
"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

3
docs/pnpm-workspace.yaml Normal file
View File

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

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

View File

@@ -0,0 +1,30 @@
---
title: Self-Hosted Sync Overview
description: Learn about Ascently's optional sync server for cross-device data synchronization
---
You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up using Docker.
## How It Works
This server uses a single `ascently.json` file for your data and a directory for images. The last client to upload wins, overwriting the old data. Authentication is just a static bearer token.
## API
All endpoints require an `Authorization: Bearer <your-auth-token>` header.
- `GET /sync`: Download `ascently.json`
- `POST /sync`: Upload `ascently.json`
- `GET /images/{imageName}`: Download an image
- `POST /images/{imageName}`: Upload an image
## Getting Started
The easiest way to get started is with the [Quick Start guide](/sync/quick-start/) using 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 clients with your server URL and auth token to start syncing.

View File

@@ -0,0 +1,61 @@
---
title: Quick Start
description: Get your Ascently sync server running with Docker Compose
---
Get your Ascently sync server up and running using Docker Compose.
## Prerequisites
- Docker and Docker Compose installed
- A server or computer to host the sync service
## Setup
1. Create a `.env` file with your configuration:
```env
IMAGE=git.atri.dad/atridad/ascently-sync:latest
APP_PORT=8080
AUTH_TOKEN=your-super-secret-token
DATA_FILE=/data/ascently.json
IMAGES_DIR=/data/images
ROOT_DIR=./ascently-data
```
Set `AUTH_TOKEN` to a long, random string. `ROOT_DIR` is where the server will store its data on your machine.
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
Configure your Ascently apps with:
- **Server URL**: `http://your-server-ip:8080` (or your domain)
- **Auth Token**: The token from your `.env` file
Enable sync and perform your first sync to start synchronizing data across devices.
## Generating a Secure Token
Generate a secure authentication token:
```bash
# On Linux/macOS
openssl rand -base64 32
```
Keep this token secure and don't share it publicly.
## Accessing Remotely
For remote access, you'll need to:
- Set up port forwarding on your router (port 8080)
- Use your public IP address or set up a domain name
- Consider using HTTPS with a reverse proxy for security

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

@@ -18,5 +18,3 @@ This is a standard Xcode project. The main app code is in the `Ascently/` direct
- `ClimbingActivityWidget/`: A home screen widget.
- `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.
## 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
1. Create a `.env` file in this directory:
@@ -27,11 +23,11 @@ This server is dead simple. It uses a single `ascently.json` file for your data
## 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`.
- `POST /sync`: Upload `ascently.json`.
- `GET /images/{imageName}`: Download an image.
- `POST /images/{imageName}`: Upload an image.
Check out `main.go` for the full details.
Check out `main.go` for the full details.