Compare commits

..

10 Commits

Author SHA1 Message Date
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
d5cf14d466 2.0.0 - Rebranding
All checks were successful
Ascently Docker Deploy / build-and-push (push) Successful in 2m18s
2025-10-13 15:10:54 -06:00
09b4055985 Moved to Ascently
All checks were successful
Ascently Docker Deploy / build-and-push (push) Successful in 2m31s
2025-10-13 14:54:54 -06:00
167 changed files with 6635 additions and 1784 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: OpenClimb 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,12 +27,12 @@ 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
platforms: linux/amd64
push: true
tags: |
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:latest
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-sync:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-sync:latest

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

@@ -1,42 +1,12 @@
# OpenClimb
# Ascently
This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS.
_Formerly OpenClimb_
## Download
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.
For Android do one of the following:
## Documentation
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.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%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%22OpenClimb%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/openclimb-sync:latest
APP_PORT=8080
AUTH_TOKEN=your-secure-auth-token-here
DATA_FILE=/data/openclimb.json
IMAGES_DIR=/data/images
ROOT_DIR=./openclimb-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

@@ -1,10 +1,10 @@
# OpenClimb for Android
# Ascently for Android
This is the native Android app for OpenClimb, built with Kotlin and Jetpack Compose.
This is the native Android app for Ascently, built with Kotlin and Jetpack Compose.
## Project Structure
This is a standard Android Gradle project. The main code lives in `app/src/main/java/com/atridad/openclimb/`.
This is a standard Android Gradle project. The main code lives in `app/src/main/java/com/atridad/ascently/`.
- `data/`: Handles all the app's data.
- `database/`: Room database setup (DAOs, entities).
@@ -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

@@ -9,15 +9,15 @@ plugins {
}
android {
namespace = "com.atridad.openclimb"
namespace = "com.atridad.ascently"
compileSdk = 36
defaultConfig {
applicationId = "com.atridad.openclimb"
applicationId = "com.atridad.ascently"
minSdk = 31
targetSdk = 36
versionCode = 39
versionName = "1.9.2"
versionCode = 41
versionName = "2.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -9,11 +9,11 @@ plugins {
}
android {
namespace = "com.atridad.openclimb"
namespace = "com.atridad.ascently"
compileSdk = 36
defaultConfig {
applicationId = "com.atridad.openclimb"
applicationId = "com.atridad.ascently"
minSdk = 31
targetSdk = 36
versionCode = 27

View File

@@ -50,13 +50,13 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OpenClimb"
android:theme="@style/Theme.Ascently"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.OpenClimb.Splash">
android:theme="@style/Theme.Ascently.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb
package com.atridad.ascently
import android.content.Intent
import android.os.Bundle
@@ -9,8 +9,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import com.atridad.openclimb.ui.OpenClimbApp
import com.atridad.openclimb.ui.theme.OpenClimbTheme
import com.atridad.ascently.ui.AscentlyApp
import com.atridad.ascently.ui.theme.AscentlyTheme
import com.atridad.ascently.utils.MigrationManager
class MainActivity : ComponentActivity() {
private var shortcutAction by mutableStateOf<String?>(null)
@@ -23,16 +24,19 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(R.style.Theme_OpenClimb)
setTheme(R.style.Theme_Ascently)
enableEdgeToEdge()
// Perform migration from OpenClimb to Ascently if needed
MigrationManager(this).migrateIfNeeded()
shortcutAction = intent?.action
lastUsedGymId = intent?.getStringExtra("LAST_USED_GYM_ID")
setContent {
OpenClimbTheme {
AscentlyTheme {
Surface(modifier = Modifier.fillMaxSize()) {
OpenClimbApp(
AscentlyApp(
shortcutAction = shortcutAction,
lastUsedGymId = lastUsedGymId,
onShortcutActionProcessed = { clearShortcutAction() }

View File

@@ -1,7 +1,7 @@
package com.atridad.openclimb.data.database
package com.atridad.ascently.data.database
import androidx.room.TypeConverter
import com.atridad.openclimb.data.model.*
import com.atridad.ascently.data.model.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.data.database
package com.atridad.ascently.data.database
import android.content.Context
import androidx.room.Database
@@ -7,8 +7,8 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.atridad.openclimb.data.database.dao.*
import com.atridad.openclimb.data.model.*
import com.atridad.ascently.data.database.dao.*
import com.atridad.ascently.data.model.*
@Database(
entities = [Gym::class, Problem::class, ClimbSession::class, Attempt::class],
@@ -16,7 +16,7 @@ import com.atridad.openclimb.data.model.*
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class OpenClimbDatabase : RoomDatabase() {
abstract class AscentlyDatabase : RoomDatabase() {
abstract fun gymDao(): GymDao
abstract fun problemDao(): ProblemDao
@@ -24,7 +24,7 @@ abstract class OpenClimbDatabase : RoomDatabase() {
abstract fun attemptDao(): AttemptDao
companion object {
@Volatile private var INSTANCE: OpenClimbDatabase? = null
@Volatile private var INSTANCE: AscentlyDatabase? = null
val MIGRATION_4_5 =
object : Migration(4, 5) {
@@ -84,14 +84,14 @@ abstract class OpenClimbDatabase : RoomDatabase() {
}
}
fun getDatabase(context: Context): OpenClimbDatabase {
fun getDatabase(context: Context): AscentlyDatabase {
return INSTANCE
?: synchronized(this) {
val instance =
Room.databaseBuilder(
context.applicationContext,
OpenClimbDatabase::class.java,
"openclimb_database"
AscentlyDatabase::class.java,
"ascently_database"
)
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
.enableMultiInstanceInvalidation()

View File

@@ -1,8 +1,8 @@
package com.atridad.openclimb.data.database.dao
package com.atridad.ascently.data.database.dao
import androidx.room.*
import com.atridad.openclimb.data.model.Attempt
import com.atridad.openclimb.data.model.AttemptResult
import com.atridad.ascently.data.model.Attempt
import com.atridad.ascently.data.model.AttemptResult
import kotlinx.coroutines.flow.Flow
@Dao

View File

@@ -1,8 +1,8 @@
package com.atridad.openclimb.data.database.dao
package com.atridad.ascently.data.database.dao
import androidx.room.*
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus
import com.atridad.ascently.data.model.ClimbSession
import com.atridad.ascently.data.model.SessionStatus
import kotlinx.coroutines.flow.Flow
@Dao

View File

@@ -1,8 +1,8 @@
package com.atridad.openclimb.data.database.dao
package com.atridad.ascently.data.database.dao
import androidx.room.*
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Gym
import com.atridad.ascently.data.model.ClimbType
import com.atridad.ascently.data.model.Gym
import kotlinx.coroutines.flow.Flow
@Dao

View File

@@ -1,8 +1,8 @@
package com.atridad.openclimb.data.database.dao
package com.atridad.ascently.data.database.dao
import androidx.room.*
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Problem
import com.atridad.ascently.data.model.ClimbType
import com.atridad.ascently.data.model.Problem
import kotlinx.coroutines.flow.Flow
@Dao

View File

@@ -1,9 +1,9 @@
package com.atridad.openclimb.data.format
package com.atridad.ascently.data.format
import com.atridad.openclimb.data.model.*
import com.atridad.ascently.data.model.*
import kotlinx.serialization.Serializable
// Root structure for OpenClimb backup data
// Root structure for Ascently backup data
@Serializable
data class ClimbDataBackup(
val exportedAt: String,

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.data.health
package com.atridad.ascently.data.health
import android.annotation.SuppressLint
import android.content.Context
@@ -12,9 +12,9 @@ import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
import androidx.health.connect.client.units.Energy
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.ascently.data.model.ClimbSession
import com.atridad.ascently.data.model.SessionStatus
import com.atridad.ascently.utils.DateFormatUtils
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
@@ -24,7 +24,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
/**
* Health Connect manager for OpenClimb that syncs climbing sessions to Samsung Health, Google Fit,
* Health Connect manager for Ascently that syncs climbing sessions to Samsung Health, Google Fit,
* and other health apps.
*/
@SuppressLint("RestrictedApi")
@@ -83,12 +83,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)
}
}
@@ -147,63 +160,6 @@ 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 OpenClimb 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 {
@@ -214,16 +170,18 @@ class HealthConnectManager(private val context: Context) {
}
}
/** 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 +278,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"

View File

@@ -1,10 +1,10 @@
package com.atridad.openclimb.data.model
package com.atridad.ascently.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable
@Serializable
@@ -12,7 +12,19 @@ enum class AttemptResult {
SUCCESS,
FALL,
NO_PROGRESS,
FLASH,
FLASH;
val displayName: String
get() =
when (this) {
SUCCESS -> "Success"
FALL -> "Fall"
NO_PROGRESS -> "No Progress"
FLASH -> "Flash"
}
val isSuccessful: Boolean
get() = this == SUCCESS || this == FLASH
}
@Entity(
@@ -74,5 +86,4 @@ data class Attempt(
)
}
}
}

View File

@@ -1,17 +1,25 @@
package com.atridad.openclimb.data.model
package com.atridad.ascently.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable
@Serializable
enum class SessionStatus {
ACTIVE,
COMPLETED,
PAUSED
PAUSED;
val displayName: String
get() =
when (this) {
ACTIVE -> "Active"
COMPLETED -> "Completed"
PAUSED -> "Paused"
}
}
@Entity(

View File

@@ -0,0 +1,16 @@
package com.atridad.ascently.data.model
import kotlinx.serialization.Serializable
@Serializable
enum class ClimbType {
ROPE,
BOULDER;
val displayName: String
get() =
when (this) {
ROPE -> "Rope"
BOULDER -> "Bouldering"
}
}

View File

@@ -0,0 +1,263 @@
package com.atridad.ascently.data.model
import kotlinx.serialization.Serializable
@Serializable
enum class DifficultySystem {
// Bouldering
V_SCALE,
FONT,
// Rope
YDS,
CUSTOM;
val displayName: String
get() =
when (this) {
V_SCALE -> "V Scale"
FONT -> "Font Scale"
YDS -> "YDS (Yosemite)"
CUSTOM -> "Custom"
}
val isBoulderingSystem: Boolean
get() =
when (this) {
V_SCALE, FONT -> true
YDS -> false
CUSTOM -> true
}
val isRopeSystem: Boolean
get() =
when (this) {
YDS -> true
V_SCALE, FONT -> false
CUSTOM -> true
}
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 {
fun systemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
when (climbType) {
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem }
ClimbType.ROPE -> entries.filter { it.isRopeSystem }
}
}
}
@Serializable
data class DifficultyGrade(val system: DifficultySystem, val grade: String, val numericValue: Int) {
constructor(
system: DifficultySystem,
grade: String
) : this(system = system, grade = grade, numericValue = calculateNumericValue(system, grade))
companion object {
private fun calculateNumericValue(system: DifficultySystem, grade: String): Int {
return when (system) {
DifficultySystem.V_SCALE -> {
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
}
DifficultySystem.FONT -> {
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.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
}
}
}
/**
* Compare this grade with another grade of the same system Returns negative if this grade is
* easier, positive if harder, 0 if equal
*/
fun compareTo(other: DifficultyGrade): Int {
if (system != other.system) return 0
return when (system) {
DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade)
DifficultySystem.FONT -> compareFontGrades(grade, other.grade)
DifficultySystem.YDS -> compareYDSGrades(grade, other.grade)
DifficultySystem.CUSTOM -> grade.compareTo(other.grade)
}
}
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB") return 0
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
return num1.compareTo(num2)
}
private fun compareFontGrades(grade1: String, grade2: String): Int {
return grade1.compareTo(grade2)
}
private fun compareYDSGrades(grade1: String, grade2: String): Int {
return grade1.compareTo(grade2)
}
}

View File

@@ -1,8 +1,8 @@
package com.atridad.openclimb.data.model
package com.atridad.ascently.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable
@Entity(tableName = "gyms")

View File

@@ -1,10 +1,10 @@
package com.atridad.openclimb.data.model
package com.atridad.ascently.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable
@Entity(

View File

@@ -1,25 +1,25 @@
package com.atridad.openclimb.data.repository
package com.atridad.ascently.data.repository
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym
import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup
import com.atridad.openclimb.data.format.DeletedItem
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.data.state.DataStateManager
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.openclimb.utils.ZipExportImportUtils
import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.ascently.data.format.BackupClimbSession
import com.atridad.ascently.data.format.BackupGym
import com.atridad.ascently.data.format.BackupProblem
import com.atridad.ascently.data.format.ClimbDataBackup
import com.atridad.ascently.data.format.DeletedItem
import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.ZipExportImportUtils
import java.io.File
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
class ClimbRepository(database: AscentlyDatabase, private val context: Context) {
private val gymDao = database.gymDao()
private val problemDao = database.problemDao()
private val sessionDao = database.climbSessionDao()
@@ -157,7 +157,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
.filter { imagePath ->
try {
val imageFile =
com.atridad.openclimb.utils.ImageUtils.getImageFile(
com.atridad.ascently.utils.ImageUtils.getImageFile(
context,
imagePath
)

View File

@@ -1,10 +1,10 @@
package com.atridad.openclimb.data.state
package com.atridad.ascently.data.state
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.atridad.openclimb.utils.DateFormatUtils
import androidx.core.content.edit
import com.atridad.ascently.utils.DateFormatUtils
/**
* Manages the overall data state timestamp for sync purposes. This tracks when any data in the
@@ -14,7 +14,7 @@ class DataStateManager(context: Context) {
companion object {
private const val TAG = "DataStateManager"
private const val PREFS_NAME = "openclimb_data_state"
private const val PREFS_NAME = "ascently_data_state"
private const val KEY_LAST_MODIFIED = "last_modified_timestamp"
private const val KEY_INITIALIZED = "state_initialized"
}
@@ -58,5 +58,4 @@ class DataStateManager(context: Context) {
private fun markAsInitialized() {
prefs.edit { putBoolean(KEY_INITIALIZED, true) }
}
}

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.data.sync
package com.atridad.ascently.data.sync
import android.content.Context
import android.content.SharedPreferences
@@ -7,16 +7,16 @@ import android.net.NetworkCapabilities
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.core.content.edit
import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym
import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.state.DataStateManager
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.openclimb.utils.ImageNamingUtils
import com.atridad.openclimb.utils.ImageUtils
import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.ascently.data.format.BackupClimbSession
import com.atridad.ascently.data.format.BackupGym
import com.atridad.ascently.data.format.BackupProblem
import com.atridad.ascently.data.format.ClimbDataBackup
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils
import java.io.IOException
import java.io.Serializable
import java.util.concurrent.TimeUnit
@@ -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) {

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.navigation
package com.atridad.ascently.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.navigation
package com.atridad.ascently.navigation
import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.service
package com.atridad.ascently.service
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -8,10 +8,10 @@ import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.ascently.MainActivity
import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime
@@ -52,7 +52,7 @@ class SessionTrackingService : Service() {
override fun onCreate() {
super.onCreate()
val database = OpenClimbDatabase.getDatabase(this)
val database = AscentlyDatabase.getDatabase(this)
repository = ClimbRepository(database, this)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
@@ -75,8 +75,8 @@ class SessionTrackingService : Service() {
sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession()
}
if (targetSession != null && targetSession.status == com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
val completed = with(com.atridad.openclimb.data.model.ClimbSession) { targetSession.complete() }
if (targetSession != null && targetSession.status == com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
val completed = with(com.atridad.ascently.data.model.ClimbSession) { targetSession.complete() }
repository.updateSession(completed)
}
} finally {
@@ -127,7 +127,7 @@ class SessionTrackingService : Service() {
}
val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
break
}
@@ -175,7 +175,7 @@ class SessionTrackingService : Service() {
val session = runBlocking {
repository.getSessionById(sessionId)
}
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
return
}

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui
package com.atridad.ascently.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -17,21 +17,21 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.sync.SyncService
import com.atridad.openclimb.navigation.Screen
import com.atridad.openclimb.navigation.bottomNavigationItems
import com.atridad.openclimb.ui.components.NotificationPermissionDialog
import com.atridad.openclimb.ui.screens.*
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
import com.atridad.openclimb.utils.AppShortcutManager
import com.atridad.openclimb.utils.NotificationPermissionUtils
import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.sync.SyncService
import com.atridad.ascently.navigation.Screen
import com.atridad.ascently.navigation.bottomNavigationItems
import com.atridad.ascently.ui.components.NotificationPermissionDialog
import com.atridad.ascently.ui.screens.*
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory
import com.atridad.ascently.utils.AppShortcutManager
import com.atridad.ascently.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OpenClimbApp(
fun AscentlyApp(
shortcutAction: String? = null,
lastUsedGymId: String? = null,
onShortcutActionProcessed: () -> Unit = {}
@@ -39,9 +39,9 @@ fun OpenClimbApp(
val navController = rememberNavController()
val context = LocalContext.current
var lastUsedGym by remember { mutableStateOf<com.atridad.openclimb.data.model.Gym?>(null) }
var lastUsedGym by remember { mutableStateOf<com.atridad.ascently.data.model.Gym?>(null) }
val database = remember { OpenClimbDatabase.getDatabase(context) }
val database = remember { AscentlyDatabase.getDatabase(context) }
val repository = remember { ClimbRepository(database, context) }
val syncService = remember { SyncService(context, repository) }
val viewModel: ClimbViewModel =
@@ -115,7 +115,7 @@ fun OpenClimbApp(
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d(
"OpenClimbApp",
"AscentlyApp",
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
)
@@ -125,12 +125,12 @@ fun OpenClimbApp(
context
)
) {
android.util.Log.d("OpenClimbApp", "Showing notification permission dialog")
android.util.Log.d("AscentlyApp", "Showing notification permission dialog")
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) {
android.util.Log.d(
"OpenClimbApp",
"AscentlyApp",
"Starting session with single gym: ${gyms.first().name}"
)
viewModel.startSession(context, gyms.first().id)
@@ -141,13 +141,13 @@ fun OpenClimbApp(
if (targetGym != null) {
android.util.Log.d(
"OpenClimbApp",
"AscentlyApp",
"Starting session with target gym: ${targetGym.name}"
)
viewModel.startSession(context, targetGym.id)
} else {
android.util.Log.d(
"OpenClimbApp",
"AscentlyApp",
"No target gym found, navigating to selection"
)
navController.navigate(Screen.AddEditSession())
@@ -156,7 +156,7 @@ fun OpenClimbApp(
}
} else {
android.util.Log.d(
"OpenClimbApp",
"AscentlyApp",
"Active session already exists: ${activeSession?.id}"
)
}
@@ -168,7 +168,7 @@ fun OpenClimbApp(
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
Scaffold(
bottomBar = { OpenClimbBottomNavigation(navController = navController) },
bottomBar = { AscentlyBottomNavigation(navController = navController) },
floatingActionButton = {
fabConfig?.let { config ->
FloatingActionButton(
@@ -363,7 +363,7 @@ fun OpenClimbApp(
}
@Composable
fun OpenClimbBottomNavigation(navController: NavHostController) {
fun AscentlyBottomNavigation(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.components
package com.atridad.ascently.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -10,9 +10,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.ui.theme.CustomIcons
import com.atridad.ascently.data.model.ClimbSession
import com.atridad.ascently.data.model.Gym
import com.atridad.ascently.ui.theme.CustomIcons
import kotlinx.coroutines.delay
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.components
package com.atridad.ascently.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box

View File

@@ -0,0 +1,161 @@
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.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
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) {
if (imagePaths.size > 1) {
thumbnailListState.animateScrollToItem(
index = pagerState.currentPage,
scrollOffset = -200
)
}
}
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true)
) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black).systemBarsPadding()) {
// Main image pager
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
OrientationAwareImage(
imagePath = imagePaths[page],
contentDescription = "Full screen image",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
}
// 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
) {
// 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 at bottom (if multiple images)
if (imagePaths.size > 1) {
Surface(
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
color = Color.Black.copy(alpha = 0.6f)
) {
LazyRow(
state = thumbnailListState,
modifier = Modifier.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {
itemsIndexed(imagePaths) { index, imagePath ->
val isSelected = index == pagerState.currentPage
Box(
modifier =
Modifier.size(48.dp)
.clip(RoundedCornerShape(8.dp))
.clickable {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
}
) {
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

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.components
package com.atridad.ascently.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.components
package com.atridad.ascently.ui.components
import android.Manifest
import android.content.pm.PackageManager
@@ -25,7 +25,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.atridad.openclimb.utils.ImageUtils
import com.atridad.ascently.utils.ImageUtils
import java.io.File
import java.text.SimpleDateFormat
import java.util.*

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.components
package com.atridad.ascently.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box

View File

@@ -0,0 +1,76 @@
package com.atridad.ascently.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun NotificationPermissionDialog(onDismiss: () -> Unit, onRequestPermission: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
shape = MaterialTheme.shapes.medium
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = "Notifications",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Enable Notifications",
style = MaterialTheme.typography.headlineSmall,
fontWeight = MaterialTheme.typography.headlineSmall.fontWeight,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text =
"Ascently needs notification permission to show your active climbing session. This helps you track your progress and ensures the session doesn't get interrupted.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(onClick = onDismiss, modifier = Modifier.weight(1f)) {
Text("Not Now")
}
Button(
onClick = {
onRequestPermission()
onDismiss()
},
modifier = Modifier.weight(1f)
) { Text("Enable") }
}
}
}
}
}

View File

@@ -1,18 +1,21 @@
package com.atridad.openclimb.ui.components
package com.atridad.ascently.ui.components
import android.graphics.BitmapFactory
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.openclimb.utils.ImageUtils
import com.atridad.ascently.utils.ImageUtils
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -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(

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.components
package com.atridad.ascently.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn

View File

@@ -0,0 +1,297 @@
package com.atridad.ascently.ui.health
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.ascently.data.health.HealthConnectManager
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HealthConnectCard(modifier: Modifier = Modifier) {
val context = LocalContext.current
val healthConnectManager = remember { HealthConnectManager(context) }
val coroutineScope = rememberCoroutineScope()
// State tracking
var isHealthConnectAvailable by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Collect flows
val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false)
val hasPermissions by healthConnectManager.hasPermissions.collectAsState(initial = false)
val isCompatible by healthConnectManager.isCompatible.collectAsState(initial = true)
// Permission launcher
val permissionLauncher =
rememberLauncherForActivityResult(
contract = healthConnectManager.getPermissionRequestContract()
) { _ ->
coroutineScope.launch {
val allGranted = healthConnectManager.hasAllPermissions()
if (!allGranted) {
errorMessage =
"Some Health Connect permissions were not granted. Please grant all permissions to enable syncing."
} else {
errorMessage = null
}
}
}
// Check Health Connect availability on first load
LaunchedEffect(Unit) {
coroutineScope.launch {
try {
healthConnectManager.isHealthConnectAvailable().collect { available ->
isHealthConnectAvailable = available
isLoading = false
if (!available && isCompatible) {
errorMessage = "Health Connect is not available on this device"
} else if (!isCompatible) {
errorMessage =
"Health Connect API compatibility issue. Please update your device or the app."
}
}
} catch (e: Exception) {
isLoading = false
errorMessage = "Error checking Health Connect availability: ${e.message}"
}
}
}
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header with icon and title
Row(
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
}
)
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(
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 ->
coroutineScope.launch {
if (enabled && isHealthConnectAvailable) {
healthConnectManager.setEnabled(true)
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
)
}
if (isEnabled) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Climbing sessions will be automatically added to Health Connect when completed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
if (!hasPermissions) {
Spacer(modifier = Modifier.height(12.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
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 = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Permissions needed",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
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
)
)
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}"
}
}
},
modifier = Modifier.fillMaxWidth()
) { Text("Grant Permissions") }
}
}
} 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.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
)
}
errorMessage?.let { error ->
Spacer(modifier = Modifier.height(12.dp))
Card(
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.5f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.screens
package com.atridad.ascently.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -17,9 +17,9 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.ui.components.ImagePicker
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.data.model.*
import com.atridad.ascently.ui.components.ImagePicker
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import kotlinx.coroutines.flow.first
@@ -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()
}
@@ -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 = ""
}
@@ -386,13 +385,12 @@ fun AddEditProblemScreen(
notes = notes.ifBlank { null }
)
if (isEditing) {
if (isEditing && problemId != null) {
viewModel.updateProblem(
problem.copy(id = problemId),
context
problem.copy(id = problemId)
)
} 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,

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.screens
package com.atridad.ascently.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -9,16 +9,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.AttemptResult
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.DifficultySystem
import com.atridad.openclimb.ui.components.BarChart
import com.atridad.openclimb.ui.components.BarChartDataPoint
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.R
import com.atridad.ascently.data.model.AttemptResult
import com.atridad.ascently.data.model.ClimbType
import com.atridad.ascently.data.model.DifficultySystem
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) {
@@ -39,7 +39,7 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
contentDescription = "Ascently Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
@@ -200,7 +200,9 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
},
modifier =
Modifier.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
type =
ExposedDropdownMenuAnchorType
.PrimaryNotEditable,
enabled = true
)
.width(120.dp),
@@ -251,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
@@ -394,9 +393,9 @@ data class GradeDistributionDataPoint(
)
fun calculateGradeDistribution(
sessions: List<com.atridad.openclimb.data.model.ClimbSession>,
problems: List<com.atridad.openclimb.data.model.Problem>,
attempts: List<com.atridad.openclimb.data.model.Attempt>
sessions: List<com.atridad.ascently.data.model.ClimbSession>,
problems: List<com.atridad.ascently.data.model.Problem>,
attempts: List<com.atridad.ascently.data.model.Attempt>
): List<GradeDistributionDataPoint> {
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
return emptyList()

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.screens
package com.atridad.ascently.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
@@ -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.openclimb.data.model.*
import com.atridad.openclimb.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplaySection
import com.atridad.openclimb.ui.theme.CustomIcons
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
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 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,

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.screens
package com.atridad.ascently.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -10,10 +10,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.R
import com.atridad.ascently.data.model.Gym
import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -28,7 +28,7 @@ fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Uni
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
contentDescription = "Ascently Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
@@ -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

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.screens
package com.atridad.ascently.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -15,14 +15,14 @@ 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
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.Attempt
import com.atridad.openclimb.data.model.AttemptResult
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.data.model.Problem
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.R
import com.atridad.ascently.data.model.Attempt
import com.atridad.ascently.data.model.AttemptResult
import com.atridad.ascently.data.model.ClimbType
import com.atridad.ascently.data.model.Gym
import com.atridad.ascently.data.model.Problem
import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -57,7 +57,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
contentDescription = "Ascently Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
@@ -104,7 +104,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 +183,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 +268,7 @@ fun ProblemCard(
}
Text(
text = problem.climbType.getDisplayName(),
text = problem.climbType.displayName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.screens
package com.atridad.ascently.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -16,14 +16,13 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus
import com.atridad.openclimb.ui.components.ActiveSessionBanner
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import com.atridad.ascently.R
import com.atridad.ascently.data.model.ClimbSession
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 com.atridad.ascently.utils.DateFormatUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -46,7 +45,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
contentDescription = "Ascently Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
@@ -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

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.screens
package com.atridad.ascently.ui.screens
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -15,10 +15,10 @@ 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
import com.atridad.openclimb.R
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.health.HealthConnectCard
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.R
import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.health.HealthConnectCard
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.io.File
import java.time.Instant
import kotlinx.coroutines.launch
@@ -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
}
@@ -86,7 +86,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
// Only allow ZIP files
if (!fileName.lowercase().endsWith(".zip")) {
viewModel.setError(
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
)
return@let
}
@@ -129,7 +129,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
contentDescription = "Ascently Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
@@ -183,7 +183,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
},
supportingContent = {
Column {
Text("Server: ${syncService.serverURL}")
Text("Server: ${syncService.serverUrl}")
lastSyncTime?.let { time ->
Text(
"Last sync: ${
@@ -336,7 +336,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
ListItem(
headlineContent = { Text("Setup Sync") },
supportingContent = {
Text("Connect to your OpenClimb sync server")
Text("Connect to your Ascently sync server")
},
leadingContent = {
Icon(Icons.Default.CloudSync, contentDescription = null)
@@ -421,7 +421,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
TextButton(
onClick = {
val defaultFileName =
"openclimb_export_${
"ascently_export_${
java.time.LocalDateTime.now()
.toString()
.replace(":", "-")
@@ -604,11 +604,11 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
painterResource(
id = R.drawable.ic_mountains
),
contentDescription = "OpenClimb Logo",
contentDescription = "Ascently Logo",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text("OpenClimb")
Text("Ascently")
}
},
supportingContent = { Text("Track your climbing progress") },
@@ -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

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.theme
package com.atridad.ascently.ui.theme
import androidx.compose.ui.graphics.Color

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.theme
package com.atridad.ascently.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.theme
package com.atridad.ascently.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
@@ -88,7 +88,7 @@ private val LightColorScheme = lightColorScheme(
)
@Composable
fun OpenClimbTheme(
fun AscentlyTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ and provides full Material You theming
// When enabled, it adapts to the user's system wallpaper colors

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.theme
package com.atridad.ascently.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle

View File

@@ -1,22 +1,18 @@
package com.atridad.openclimb.ui.viewmodel
package com.atridad.ascently.ui.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.atridad.openclimb.data.health.HealthConnectManager
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.sync.SyncService
import com.atridad.openclimb.service.SessionTrackingService
import com.atridad.openclimb.utils.ImageNamingUtils
import com.atridad.openclimb.utils.ImageUtils
import com.atridad.openclimb.utils.SessionShareUtils
import com.atridad.openclimb.widget.ClimbStatsWidgetProvider
import com.atridad.ascently.data.health.HealthConnectManager
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.ImageUtils
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)
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)
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)
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)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
}
}
}
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem {
private suspend 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)
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)
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,38 +201,32 @@ 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) {
fun addSession(session: ClimbSession, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.insertSession(session)
if (updateWidgets) {
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)
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)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun getSessionById(id: String): Flow<ClimbSession?> = flow {
emit(repository.getSessionById(id))
@@ -260,7 +243,7 @@ class ClimbViewModel(
viewModelScope.launch {
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context)
) {
android.util.Log.d("ClimbViewModel", "Notification permission not granted")
@@ -305,7 +288,7 @@ class ClimbViewModel(
fun endSession(context: Context, sessionId: String) {
viewModelScope.launch {
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context)
) {
_uiState.value =
@@ -345,38 +328,32 @@ 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)
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)
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)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId)
@@ -416,7 +393,7 @@ class ClimbViewModel(
if (!file.name.lowercase().endsWith(".zip")) {
throw Exception(
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
)
}
@@ -499,107 +476,30 @@ class ClimbViewModel(
val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size
val result = healthConnectManager.autoSyncSession(session, gymName, attemptCount)
result
.onSuccess {
_uiState.value =
_uiState.value.copy(
message =
"Session synced to Health Connect successfully!"
)
}
.onFailure { error ->
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(
error =
"Failed to sync to Health Connect: ${error.message}"
)
}
}
} catch (e: Exception) {
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
}
}
}
}
fun manualSyncToHealthConnect(sessionId: String) {
viewModelScope.launch {
try {
val session = repository.getSessionById(sessionId)
if (session == null) {
_uiState.value = _uiState.value.copy(error = "Session not found")
return@launch
}
if (session.status != SessionStatus.COMPLETED) {
_uiState.value =
_uiState.value.copy(error = "Only completed sessions can be synced")
return@launch
}
val gym = repository.getGymById(session.gymId)
val gymName = gym?.name ?: "Unknown Gym"
val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size
val result =
healthConnectManager.syncClimbingSession(session, gymName, attemptCount)
healthConnectManager.autoSyncCompletedSession(
session,
gymName,
attemptCount
)
result
.onSuccess {
_uiState.value =
_uiState.value.copy(
message =
"Session synced to Health Connect successfully!"
result.onFailure { error ->
if (healthConnectManager.isReadySync()) {
android.util.Log.w(
"ClimbViewModel",
"Health Connect sync failed: ${error.message}"
)
}
.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}")
if (healthConnectManager.isReadySync()) {
android.util.Log.w("ClimbViewModel", "Health Connect sync error: ${e.message}")
}
}
}
}
fun getHealthConnectManager(): HealthConnectManager = healthConnectManager
// Share operations
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
withContext(Dispatchers.IO) {
try {
val session = repository.getSessionById(sessionId) ?: return@withContext null
val attempts = repository.getAttemptsBySession(sessionId).first()
val problems =
repository.getAllProblems().first().filter { problem ->
attempts.any { it.problemId == problem.id }
}
val gym = repository.getGymById(session.gymId) ?: return@withContext null
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
SessionShareUtils.generateShareCard(context, session, gym, stats)
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(
error = "Failed to generate share card: ${e.message}"
)
null
}
}
fun shareSessionCard(context: Context, imageFile: File) {
SessionShareUtils.shareSessionCard(context, imageFile)
}
}
data class ClimbUiState(

View File

@@ -1,10 +1,10 @@
package com.atridad.openclimb.ui.viewmodel
package com.atridad.ascently.ui.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.sync.SyncService
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.sync.SyncService
class ClimbViewModelFactory(
private val repository: ClimbRepository,

View File

@@ -1,6 +1,8 @@
package com.atridad.openclimb.utils
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
@@ -43,4 +45,33 @@ object DateFormatUtils {
fun millisToISO8601(millis: Long): String {
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
}
/**
* 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, pattern: String = "MMM dd, yyyy"): String {
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 (e: Exception) {
dateString.take(10)
}
}
/** 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 (e: Exception) {
null
}
}
}

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.utils
package com.atridad.ascently.utils
import java.security.MessageDigest
import java.util.*
@@ -21,6 +21,7 @@ object ImageNamingUtils {
}
/** Legacy method for backward compatibility */
@Suppress("UNUSED_PARAMETER")
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
return generateImageFilename(problemId, imageIndex)
}
@@ -97,6 +98,26 @@ object ImageNamingUtils {
}
/** Creates a mapping of existing server filenames to canonical filenames */
/** Validates that a collection of filenames follow our naming convention */
fun validateFilenames(filenames: List<String>): ImageValidationResult {
val validImages = mutableListOf<String>()
val invalidImages = mutableListOf<String>()
for (filename in filenames) {
if (isValidImageFilename(filename)) {
validImages.add(filename)
} else {
invalidImages.add(filename)
}
}
return ImageValidationResult(
totalImages = filenames.size,
validImages = validImages,
invalidImages = invalidImages
)
}
fun createServerMigrationMap(
problemId: String,
serverImageFilenames: List<String>,
@@ -124,3 +145,16 @@ object ImageNamingUtils {
return migrationMap
}
}
/** Result of image filename validation */
data class ImageValidationResult(
val totalImages: Int,
val validImages: List<String>,
val invalidImages: List<String>
) {
val isAllValid: Boolean
get() = invalidImages.isEmpty()
val validPercentage: Double
get() = if (totalImages > 0) (validImages.size.toDouble() / totalImages) * 100.0 else 100.0
}

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.utils
package com.atridad.ascently.utils
import android.annotation.SuppressLint
import android.content.Context

View File

@@ -0,0 +1,175 @@
package com.atridad.ascently.utils
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
/**
* Handles migration of data from OpenClimb to Ascently This includes SharedPreferences, database
* names, and other local storage
*/
class MigrationManager(private val context: Context) {
companion object {
private const val TAG = "MigrationManager"
private const val MIGRATION_PREFS = "ascently_migration_state"
private const val MIGRATION_COMPLETED_KEY = "openclimb_to_ascently_completed"
}
private val migrationPrefs: SharedPreferences =
context.getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE)
/**
* Perform migration from OpenClimb to Ascently if needed This should be called early in app
* startup
*/
fun migrateIfNeeded() {
if (migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) {
Log.d(TAG, "Migration already completed, skipping")
return
}
Log.i(TAG, "🔄 Starting migration from OpenClimb to Ascently...")
var migrationCount = 0
// Migrate SharedPreferences
migrationCount += migrateSharedPreferences()
// Mark migration as completed
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, true) }
if (migrationCount > 0) {
Log.i(
TAG,
"🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently"
)
} else {
Log.i(TAG, " No OpenClimb data found to migrate")
}
}
/** Migrate SharedPreferences from OpenClimb naming to Ascently naming */
private fun migrateSharedPreferences(): Int {
var count = 0
// Define preference file migrations
val preferenceFileMigrations =
listOf(
"openclimb_data_state" to "ascently_data_state",
"health_connect_prefs" to "health_connect_prefs", // Keep same name
"deleted_items" to "deleted_items", // Keep same name
"sync_preferences" to "sync_preferences" // Keep same name
)
for ((oldFileName, newFileName) in preferenceFileMigrations) {
if (oldFileName != newFileName) {
count += migratePreferenceFile(oldFileName, newFileName)
}
}
// Migrate specific keys within preference files
count += migratePreferenceKeys()
return count
}
/** Migrate an entire SharedPreferences file */
private fun migratePreferenceFile(oldFileName: String, newFileName: String): Int {
val oldPrefs = context.getSharedPreferences(oldFileName, Context.MODE_PRIVATE)
val newPrefs = context.getSharedPreferences(newFileName, Context.MODE_PRIVATE)
// If old prefs exist and new prefs are empty, migrate
if (oldPrefs.all.isNotEmpty() && newPrefs.all.isEmpty()) {
newPrefs.edit {
oldPrefs.all.forEach { (key, value) ->
when (value) {
is String -> putString(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Float -> putFloat(key, value)
is Boolean -> putBoolean(key, value)
is Set<*> -> {
@Suppress("UNCHECKED_CAST") putStringSet(key, value as Set<String>)
}
}
}
}
// Clear old preferences
oldPrefs.edit { clear() }
Log.d(
TAG,
"✅ Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)"
)
return oldPrefs.all.size
}
return 0
}
/** Migrate specific keys within preference files that might contain openclimb references */
private fun migratePreferenceKeys(): Int {
var count = 0
// Check for any openclimb-prefixed keys across all preference files
val preferencesToCheck =
listOf(
"ascently_data_state",
"health_connect_prefs",
"deleted_items",
"sync_preferences"
)
for (prefFileName in preferencesToCheck) {
val prefs = context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
val keysToMigrate = mutableListOf<Pair<String, String>>()
// Find keys that start with openclimb_ and should be ascently_
prefs.all.keys.forEach { key ->
if (key.startsWith("openclimb_")) {
val newKey = key.replace("openclimb_", "ascently_")
keysToMigrate.add(key to newKey)
}
}
// Perform the key migrations
if (keysToMigrate.isNotEmpty()) {
prefs.edit {
keysToMigrate.forEach { (oldKey, newKey) ->
val value = prefs.all[oldKey]
when (value) {
is String -> putString(newKey, value)
is Int -> putInt(newKey, value)
is Long -> putLong(newKey, value)
is Float -> putFloat(newKey, value)
is Boolean -> putBoolean(newKey, value)
is Set<*> -> {
@Suppress("UNCHECKED_CAST")
putStringSet(newKey, value as Set<String>)
}
}
remove(oldKey)
}
}
Log.d(TAG, "✅ Migrated ${keysToMigrate.size} keys in $prefFileName")
count += keysToMigrate.size
}
}
return count
}
/** Check if migration has been completed */
fun isMigrationCompleted(): Boolean {
return migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)
}
/** Reset migration state (for testing purposes) */
fun resetMigrationState() {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, false) }
Log.d(TAG, "Migration state reset")
}
}

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.utils
package com.atridad.ascently.utils
import android.Manifest
import android.content.Context

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.utils
package com.atridad.ascently.utils
import android.content.Context
import android.content.Intent
@@ -7,11 +7,9 @@ import android.graphics.drawable.GradientDrawable
import androidx.core.content.FileProvider
import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt
import com.atridad.openclimb.data.model.*
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 {
@@ -382,7 +380,7 @@ object SessionShareUtils {
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
canvas.drawText("OpenClimb", width / 2f, height - 40f, brandingPaint)
canvas.drawText("Ascently", width / 2f, height - 40f, brandingPaint)
// Save to file
val shareDir = File(context.cacheDir, "shares")
@@ -457,14 +455,7 @@ object SessionShareUtils {
}
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)
}
return DateFormatUtils.formatDateForDisplay(dateString, "MMMM dd, yyyy")
}
fun shareSessionCard(context: Context, imageFile: File) {
@@ -481,7 +472,7 @@ object SessionShareUtils {
action = Intent.ACTION_SEND
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! #OpenClimb")
putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! #Ascently")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
@@ -512,7 +503,7 @@ object SessionShareUtils {
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
}
DifficultySystem.FONT -> {
val list = DifficultySystem.FONT.getAvailableGrades()
val list = DifficultySystem.FONT.availableGrades
val idx = list.indexOf(grade.uppercase())
if (idx >= 0) idx.toDouble()
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.utils
package com.atridad.ascently.utils
import android.content.Context
import android.content.Intent
@@ -7,23 +7,23 @@ import android.content.pm.ShortcutManager
import android.graphics.drawable.Icon
import android.os.Build
import androidx.annotation.RequiresApi
import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R
import com.atridad.ascently.MainActivity
import com.atridad.ascently.R
object AppShortcutManager {
const val SHORTCUT_START_SESSION = "start_session"
const val SHORTCUT_END_SESSION = "end_session"
const val ACTION_START_SESSION = "com.atridad.openclimb.action.START_SESSION"
const val ACTION_END_SESSION = "com.atridad.openclimb.action.END_SESSION"
const val ACTION_START_SESSION = "com.atridad.ascently.action.START_SESSION"
const val ACTION_END_SESSION = "com.atridad.ascently.action.END_SESSION"
/** Updates the app shortcuts based on current session state */
fun updateShortcuts(
context: Context,
hasActiveSession: Boolean,
hasGyms: Boolean,
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
lastUsedGym: com.atridad.ascently.data.model.Gym? = null
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
@@ -45,7 +45,7 @@ object AppShortcutManager {
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun createStartSessionShortcut(
context: Context,
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
lastUsedGym: com.atridad.ascently.data.model.Gym? = null
): ShortcutInfo {
val startIntent =
Intent(context, MainActivity::class.java).apply {

View File

@@ -1,8 +1,8 @@
package com.atridad.openclimb.utils
package com.atridad.ascently.utils
import android.content.Context
import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup
import com.atridad.ascently.data.format.BackupProblem
import com.atridad.ascently.data.format.ClimbDataBackup
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
@@ -33,14 +33,14 @@ object ZipExportImportUtils {
context.getExternalFilesDir(
android.os.Environment.DIRECTORY_DOCUMENTS
),
"OpenClimb"
"Ascently"
)
if (!exportDir.exists()) {
exportDir.mkdirs()
}
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
val zipFile = File(exportDir, "openclimb_export_$timestamp.zip")
val zipFile = File(exportDir, "ascently_export_$timestamp.zip")
try {
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
@@ -182,8 +182,8 @@ object ZipExportImportUtils {
referencedImagePaths: Set<String>
): String {
return buildString {
appendLine("OpenClimb Export Metadata")
appendLine("=======================")
appendLine("Ascently Export Metadata")
appendLine("========================")
appendLine("Export Date: ${exportData.exportedAt}")
appendLine("Version: ${exportData.version}")
appendLine("Gyms: ${exportData.gyms.size}")

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.widget
package com.atridad.ascently.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
@@ -7,10 +7,10 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.ascently.MainActivity
import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -45,7 +45,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
) {
coroutineScope.launch {
try {
val database = OpenClimbDatabase.getDatabase(context)
val database = AscentlyDatabase.getDatabase(context)
val repository = ClimbRepository(database, context)
// Fetch stats data
@@ -65,10 +65,10 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
attempts.any { attempt ->
attempt.problemId == problem.id &&
(attempt.result ==
com.atridad.openclimb.data.model
com.atridad.ascently.data.model
.AttemptResult.SUCCESS ||
attempt.result ==
com.atridad.openclimb.data.model
com.atridad.ascently.data.model
.AttemptResult.FLASH)
}
}

View File

@@ -1,17 +0,0 @@
package com.atridad.openclimb.data.model
import kotlinx.serialization.Serializable
@Serializable
enum class ClimbType {
ROPE,
BOULDER;
/**
* Get the display name
*/
fun getDisplayName(): String = when (this) {
ROPE -> "Rope"
BOULDER -> "Bouldering"
}
}

View File

@@ -1,224 +0,0 @@
package com.atridad.openclimb.data.model
import kotlinx.serialization.Serializable
@Serializable
enum class DifficultySystem {
// Bouldering
V_SCALE,
FONT,
// Rope
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"
}
/** Check if this system is for bouldering */
fun isBoulderingSystem(): Boolean =
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
}
/** 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()
}
companion object {
/** Get all difficulty systems based on type */
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
when (climbType) {
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }
ClimbType.ROPE -> entries.filter { it.isRopeSystem() }
}
}
}
@Serializable
data class DifficultyGrade(val system: DifficultySystem, val grade: String, val numericValue: Int) {
constructor(
system: DifficultySystem,
grade: String
) : this(system = system, grade = grade, numericValue = calculateNumericValue(system, grade))
companion object {
private fun calculateNumericValue(system: DifficultySystem, grade: String): Int {
return when (system) {
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
}
}
DifficultySystem.CUSTOM -> grade.hashCode().rem(100)
}
}
}
/**
* Compare this grade with another grade of the same system Returns negative if this grade is
* easier, positive if harder, 0 if equal
*/
fun compareTo(other: DifficultyGrade): Int {
if (system != other.system) return 0
return when (system) {
DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade)
DifficultySystem.FONT -> compareFontGrades(grade, other.grade)
DifficultySystem.YDS -> compareYDSGrades(grade, other.grade)
DifficultySystem.CUSTOM -> grade.compareTo(other.grade)
}
}
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB") return 0
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
return num1.compareTo(num2)
}
private fun compareFontGrades(grade1: String, grade2: String): Int {
return grade1.compareTo(grade2)
}
private fun compareYDSGrades(grade1: String, grade2: String): Int {
return grade1.compareTo(grade2)
}
}

View File

@@ -1,137 +0,0 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.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
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()
// Auto-scroll thumbnail list to center current image
LaunchedEffect(pagerState.currentPage) {
thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200)
}
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
// Main image pager
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
OrientationAwareImage(
imagePath = imagePaths[page],
contentDescription = "Full screen image",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
}
// 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)
)
) {
Text(
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
// Thumbnail strip (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)
)
) {
LazyRow(
state = thumbnailListState,
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
itemsIndexed(imagePaths) { index, imagePath ->
val isSelected = index == pagerState.currentPage
OrientationAwareImage(
imagePath = imagePath,
contentDescription = "Thumbnail ${index + 1}",
modifier =
Modifier.size(60.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
)
}
}
}
}
}
}
}

View File

@@ -1,89 +0,0 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun NotificationPermissionDialog(
onDismiss: () -> Unit,
onRequestPermission: () -> Unit
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = MaterialTheme.shapes.medium
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = "Notifications",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Enable Notifications",
style = MaterialTheme.typography.headlineSmall,
fontWeight = MaterialTheme.typography.headlineSmall.fontWeight,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "OpenClimb needs notification permission to show your active climbing session. This helps you track your progress and ensures the session doesn't get interrupted.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text("Not Now")
}
Button(
onClick = {
onRequestPermission()
onDismiss()
},
modifier = Modifier.weight(1f)
) {
Text("Enable")
}
}
}
}
}
}

View File

@@ -1,440 +0,0 @@
package com.atridad.openclimb.ui.health
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.health.HealthConnectManager
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HealthConnectCard(modifier: Modifier = Modifier) {
val context = LocalContext.current
val healthConnectManager = remember { HealthConnectManager(context) }
val coroutineScope = rememberCoroutineScope()
// State tracking
var isHealthConnectAvailable by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// 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) {
errorMessage =
"Some Health Connect permissions were not granted. Please grant all permissions to enable syncing."
} else {
errorMessage = null
}
}
}
// Check Health Connect availability on first load
LaunchedEffect(Unit) {
coroutineScope.launch {
try {
healthConnectManager.isHealthConnectAvailable().collect { available ->
isHealthConnectAvailable = available
isLoading = false
if (!available && isCompatible) {
errorMessage = "Health Connect is not available on this device"
} else if (!isCompatible) {
errorMessage =
"Health Connect API compatibility issue. Please update your device or the app."
}
}
} catch (e: Exception) {
isLoading = false
errorMessage = "Error checking Health Connect availability: ${e.message}"
}
}
}
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header with icon and title
Row(
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
}
)
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(
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 ->
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}"
}
}
} else {
healthConnectManager.setEnabled(false)
errorMessage = null
}
},
enabled = isHealthConnectAvailable && !isLoading && isCompatible
)
}
if (isEnabled) {
Spacer(modifier = Modifier.height(16.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,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint =
if (hasPermissions) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
if (hasPermissions) "Ready to sync"
else "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
)
)
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}"
}
}
},
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(
text =
"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))
Card(
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.5f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
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
)
}
}
}
}
}
}
}
@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

@@ -26,7 +26,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="OpenClimb"
android:text="Ascently"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary" />

View File

@@ -1,5 +1,5 @@
<resources>
<string name="app_name">OpenClimb</string>
<string name="app_name">Ascently</string>
<string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string>
<!-- App Shortcuts -->

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.Ascently" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.OpenClimb.Splash" parent="Theme.OpenClimb">
<style name="Theme.Ascently.Splash" parent="Theme.Ascently">
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
<item name="android:windowSplashScreenAnimationDuration">200</item>

View File

@@ -1,7 +1,7 @@
package com.atridad.openclimb
package com.atridad.ascently
import com.atridad.openclimb.data.format.*
import com.atridad.openclimb.data.model.*
import com.atridad.ascently.data.format.*
import com.atridad.ascently.data.model.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import org.junit.Assert.*

View File

@@ -1,7 +1,7 @@
package com.atridad.openclimb
package com.atridad.ascently
import com.atridad.openclimb.data.format.*
import com.atridad.openclimb.data.model.*
import com.atridad.ascently.data.format.*
import com.atridad.ascently.data.model.*
import java.time.Instant
import java.time.format.DateTimeFormatter
import org.junit.Assert.*
@@ -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))

View File

@@ -1,7 +1,7 @@
package com.atridad.openclimb
package com.atridad.ascently
import com.atridad.openclimb.data.format.*
import com.atridad.openclimb.data.model.*
import com.atridad.ascently.data.format.*
import com.atridad.ascently.data.model.*
import org.junit.Assert.*
import org.junit.Test

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb
package com.atridad.ascently
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

View File

@@ -11,6 +11,7 @@ pluginManagement {
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
@@ -20,5 +21,6 @@ dependencyResolutionManagement {
}
}
rootProject.name = "OpenClimb"
rootProject.name = "Ascently"
include(":app")

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"]
}

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