Compare commits

..

4 Commits

Author SHA1 Message Date
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
30d2b3938e [Android] 1.9.2
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m29s
2025-10-12 20:41:39 -06:00
405fb06d5d [Android] 1.9.1 - EXIF Fixes 2025-10-12 01:46:16 -06:00
141 changed files with 2241 additions and 3050 deletions

View File

@@ -1,4 +1,4 @@
name: OpenClimb Docker Deploy name: Ascently Docker Deploy
on: on:
push: push:
branches: [main] branches: [main]
@@ -34,5 +34,5 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
tags: | tags: |
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:${{ github.sha }} ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-sync:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:latest ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-sync:latest

View File

@@ -1,13 +1,15 @@
# 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_
This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-first, with an optional sync server and integrations with Apple Health and Health Connect. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS.
## Download ## Download
For Android do one of the following: For Android do one of the following:
1. Download the latest APK from the Releases page 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) 2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.ascently%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FAscently%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22Ascently%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Ascently%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
For iOS: For iOS:
@@ -22,12 +24,12 @@ You can run your own sync server to keep your data in sync across devices. The s
1. Create a `.env` file with your configuration: 1. Create a `.env` file with your configuration:
``` ```
IMAGE=git.atri.dad/atridad/openclimb-sync:latest IMAGE=git.atri.dad/atridad/ascently-sync:latest
APP_PORT=8080 APP_PORT=8080
AUTH_TOKEN=your-secure-auth-token-here AUTH_TOKEN=your-secure-auth-token-here
DATA_FILE=/data/openclimb.json DATA_FILE=/data/ascently.json
IMAGES_DIR=/data/images IMAGES_DIR=/data/images
ROOT_DIR=./openclimb-data ROOT_DIR=./ascently-data
``` ```
2. Use the provided `docker-compose.yml` in the `sync/` directory: 2. Use the provided `docker-compose.yml` in the `sync/` directory:

22
android/README.md Normal file
View File

@@ -0,0 +1,22 @@
# Ascently for Android
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/ascently/`.
- `data/`: Handles all the app's data.
- `database/`: Room database setup (DAOs, entities).
- `model/`: Core data models (`Problem`, `Gym`, `ClimbSession`).
- `repository/`: Manages the data, providing a clean API for the rest of the app.
- `sync/`: Handles talking to the sync server.
- `ui/`: All the Jetpack Compose UI code.
- `screens/`: The main screens of the app.
- `components/`: Reusable UI bits used across screens.
- `viewmodel/`: `ClimbViewModel` for managing UI state.
- `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 { android {
namespace = "com.atridad.openclimb" namespace = "com.atridad.ascently"
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.ascently"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 37 versionCode = 40
versionName = "1.9.0" versionName = "2.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

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

View File

@@ -10,6 +10,7 @@
<!-- Permission for sync functionality --> <!-- Permission for sync functionality -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Health Connect permissions --> <!-- Health Connect permissions -->
<uses-permission android:name="android.permission.health.READ_EXERCISE" /> <uses-permission android:name="android.permission.health.READ_EXERCISE" />
@@ -49,13 +50,13 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.OpenClimb" android:theme="@style/Theme.Ascently"
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.OpenClimb.Splash"> android:theme="@style/Theme.Ascently.Splash">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <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.content.Intent
import android.os.Bundle import android.os.Bundle
@@ -9,8 +9,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.atridad.openclimb.ui.OpenClimbApp import com.atridad.ascently.ui.AscentlyApp
import com.atridad.openclimb.ui.theme.OpenClimbTheme import com.atridad.ascently.ui.theme.AscentlyTheme
import com.atridad.ascently.utils.MigrationManager
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var shortcutAction by mutableStateOf<String?>(null) private var shortcutAction by mutableStateOf<String?>(null)
@@ -23,16 +24,19 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setTheme(R.style.Theme_OpenClimb) setTheme(R.style.Theme_Ascently)
enableEdgeToEdge() enableEdgeToEdge()
// Perform migration from OpenClimb to Ascently if needed
MigrationManager(this).migrateIfNeeded()
shortcutAction = intent?.action shortcutAction = intent?.action
lastUsedGymId = intent?.getStringExtra("LAST_USED_GYM_ID") lastUsedGymId = intent?.getStringExtra("LAST_USED_GYM_ID")
setContent { setContent {
OpenClimbTheme { AscentlyTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
OpenClimbApp( AscentlyApp(
shortcutAction = shortcutAction, shortcutAction = shortcutAction,
lastUsedGymId = lastUsedGymId, lastUsedGymId = lastUsedGymId,
onShortcutActionProcessed = { clearShortcutAction() } 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 androidx.room.TypeConverter
import com.atridad.openclimb.data.model.* import com.atridad.ascently.data.model.*
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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 android.content.Context
import androidx.room.Database import androidx.room.Database
@@ -7,8 +7,8 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.atridad.openclimb.data.database.dao.* import com.atridad.ascently.data.database.dao.*
import com.atridad.openclimb.data.model.* import com.atridad.ascently.data.model.*
@Database( @Database(
entities = [Gym::class, Problem::class, ClimbSession::class, Attempt::class], entities = [Gym::class, Problem::class, ClimbSession::class, Attempt::class],
@@ -16,7 +16,7 @@ import com.atridad.openclimb.data.model.*
exportSchema = false exportSchema = false
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class OpenClimbDatabase : RoomDatabase() { abstract class AscentlyDatabase : RoomDatabase() {
abstract fun gymDao(): GymDao abstract fun gymDao(): GymDao
abstract fun problemDao(): ProblemDao abstract fun problemDao(): ProblemDao
@@ -24,7 +24,7 @@ abstract class OpenClimbDatabase : RoomDatabase() {
abstract fun attemptDao(): AttemptDao abstract fun attemptDao(): AttemptDao
companion object { companion object {
@Volatile private var INSTANCE: OpenClimbDatabase? = null @Volatile private var INSTANCE: AscentlyDatabase? = null
val MIGRATION_4_5 = val MIGRATION_4_5 =
object : 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 return INSTANCE
?: synchronized(this) { ?: synchronized(this) {
val instance = val instance =
Room.databaseBuilder( Room.databaseBuilder(
context.applicationContext, context.applicationContext,
OpenClimbDatabase::class.java, AscentlyDatabase::class.java,
"openclimb_database" "ascently_database"
) )
.addMigrations(MIGRATION_4_5, MIGRATION_5_6) .addMigrations(MIGRATION_4_5, MIGRATION_5_6)
.enableMultiInstanceInvalidation() .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 androidx.room.*
import com.atridad.openclimb.data.model.Attempt import com.atridad.ascently.data.model.Attempt
import com.atridad.openclimb.data.model.AttemptResult import com.atridad.ascently.data.model.AttemptResult
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @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 androidx.room.*
import com.atridad.openclimb.data.model.ClimbSession import com.atridad.ascently.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus import com.atridad.ascently.data.model.SessionStatus
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @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 androidx.room.*
import com.atridad.openclimb.data.model.ClimbType import com.atridad.ascently.data.model.ClimbType
import com.atridad.openclimb.data.model.Gym import com.atridad.ascently.data.model.Gym
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @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 androidx.room.*
import com.atridad.openclimb.data.model.ClimbType import com.atridad.ascently.data.model.ClimbType
import com.atridad.openclimb.data.model.Problem import com.atridad.ascently.data.model.Problem
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @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 import kotlinx.serialization.Serializable
// Root structure for OpenClimb backup data // Root structure for Ascently backup data
@Serializable @Serializable
data class ClimbDataBackup( data class ClimbDataBackup(
val exportedAt: String, 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.annotation.SuppressLint
import android.content.Context 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.HeartRateRecord
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
import androidx.health.connect.client.units.Energy import androidx.health.connect.client.units.Energy
import com.atridad.openclimb.data.model.ClimbSession import com.atridad.ascently.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus import com.atridad.ascently.data.model.SessionStatus
import com.atridad.openclimb.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -24,7 +24,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow 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. * and other health apps.
*/ */
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
@@ -192,7 +192,7 @@ class HealthConnectManager(private val context: Context) {
} else { } else {
results.add("❌ Health Connect not ready") results.add("❌ Health Connect not ready")
if (!available) results.add("- Health Connect not available on device") if (!available) results.add("- Health Connect not available on device")
if (!_isEnabled.value) results.add("- Not enabled in OpenClimb settings") if (!_isEnabled.value) results.add("- Not enabled in Ascently settings")
if (!hasPerms) results.add("- Permissions not granted") if (!hasPerms) results.add("- Permissions not granted")
if (!_isCompatible.value) results.add("- API compatibility issues") if (!_isCompatible.value) results.add("- API compatibility issues")
} }

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.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -75,25 +75,4 @@ data class Attempt(
} }
} }
fun updated(
result: AttemptResult? = null,
highestHold: String? = null,
notes: String? = null,
duration: Long? = null,
restTime: Long? = null
): Attempt {
return Attempt(
id = this.id,
sessionId = this.sessionId,
problemId = this.problemId,
result = result ?: this.result,
highestHold = highestHold ?: this.highestHold,
notes = notes ?: this.notes,
duration = duration ?: this.duration,
restTime = restTime ?: this.restTime,
timestamp = this.timestamp,
createdAt = this.createdAt,
updatedAt = DateFormatUtils.nowISO8601()
)
}
} }

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.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable

View File

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

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.data.model package com.atridad.ascently.data.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -207,7 +207,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
private fun compareVScaleGrades(grade1: String, grade2: String): Int { private fun compareVScaleGrades(grade1: String, grade2: String): Int {
if (grade1 == "VB" && grade2 != "VB") return -1 if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1 if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB" && grade2 == "VB") return 0 if (grade1 == "VB") return 0
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0 val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0 val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0

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.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Entity(tableName = "gyms") @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.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Entity( @Entity(

View File

@@ -1,27 +1,25 @@
package com.atridad.openclimb.data.repository package com.atridad.ascently.data.repository
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.openclimb.data.format.BackupAttempt import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession import com.atridad.ascently.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym import com.atridad.ascently.data.format.BackupGym
import com.atridad.openclimb.data.format.BackupProblem import com.atridad.ascently.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup import com.atridad.ascently.data.format.ClimbDataBackup
import com.atridad.openclimb.data.format.DeletedItem import com.atridad.ascently.data.format.DeletedItem
import com.atridad.openclimb.data.model.* import com.atridad.ascently.data.model.*
import com.atridad.openclimb.data.state.DataStateManager import com.atridad.ascently.data.state.DataStateManager
import com.atridad.openclimb.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.openclimb.utils.ZipExportImportUtils import com.atridad.ascently.utils.ZipExportImportUtils
import java.io.File import java.io.File
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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 gymDao = database.gymDao()
private val problemDao = database.problemDao() private val problemDao = database.problemDao()
private val sessionDao = database.climbSessionDao() private val sessionDao = database.climbSessionDao()
@@ -159,7 +157,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
.filter { imagePath -> .filter { imagePath ->
try { try {
val imageFile = val imageFile =
com.atridad.openclimb.utils.ImageUtils.getImageFile( com.atridad.ascently.utils.ImageUtils.getImageFile(
context, context,
imagePath imagePath
) )
@@ -288,7 +286,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
try { try {
val deletion = json.decodeFromString<DeletedItem>(value) val deletion = json.decodeFromString<DeletedItem>(value)
deletions.add(deletion) deletions.add(deletion)
} catch (e: Exception) { } catch (_: Exception) {
// Invalid deletion record, ignore // Invalid deletion record, ignore
} }
} }

View File

@@ -1,9 +1,10 @@
package com.atridad.openclimb.data.state package com.atridad.ascently.data.state
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import 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 * Manages the overall data state timestamp for sync purposes. This tracks when any data in the
@@ -13,7 +14,7 @@ class DataStateManager(context: Context) {
companion object { companion object {
private const val TAG = "DataStateManager" 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_LAST_MODIFIED = "last_modified_timestamp"
private const val KEY_INITIALIZED = "state_initialized" private const val KEY_INITIALIZED = "state_initialized"
} }
@@ -35,7 +36,7 @@ class DataStateManager(context: Context) {
*/ */
fun updateDataState() { fun updateDataState() {
val now = DateFormatUtils.nowISO8601() val now = DateFormatUtils.nowISO8601()
prefs.edit().putString(KEY_LAST_MODIFIED, now).apply() prefs.edit { putString(KEY_LAST_MODIFIED, now) }
Log.d(TAG, "Data state updated to: $now") Log.d(TAG, "Data state updated to: $now")
} }
@@ -48,21 +49,6 @@ class DataStateManager(context: Context) {
?: DateFormatUtils.nowISO8601() ?: DateFormatUtils.nowISO8601()
} }
/**
* Sets the data state timestamp to a specific value. Used when importing data from server to
* sync the state.
*/
fun setLastModified(timestamp: String) {
prefs.edit().putString(KEY_LAST_MODIFIED, timestamp).apply()
Log.d(TAG, "Data state set to: $timestamp")
}
/** Resets the data state (for testing or complete data wipe). */
fun reset() {
prefs.edit().clear().apply()
Log.d(TAG, "Data state reset")
}
/** Checks if the data state has been initialized. */ /** Checks if the data state has been initialized. */
private fun isInitialized(): Boolean { private fun isInitialized(): Boolean {
return prefs.getBoolean(KEY_INITIALIZED, false) return prefs.getBoolean(KEY_INITIALIZED, false)
@@ -70,11 +56,6 @@ class DataStateManager(context: Context) {
/** Marks the data state as initialized. */ /** Marks the data state as initialized. */
private fun markAsInitialized() { private fun markAsInitialized() {
prefs.edit().putBoolean(KEY_INITIALIZED, true).apply() prefs.edit { putBoolean(KEY_INITIALIZED, true) }
}
/** Gets debug information about the current state. */
fun getDebugInfo(): String {
return "DataState(lastModified=${getLastModified()}, initialized=${isInitialized()})"
} }
} }

View File

@@ -0,0 +1,569 @@
package com.atridad.ascently.data.sync
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.core.content.edit
import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.ascently.data.format.BackupClimbSession
import com.atridad.ascently.data.format.BackupGym
import com.atridad.ascently.data.format.BackupProblem
import com.atridad.ascently.data.format.ClimbDataBackup
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils
import java.io.IOException
import java.io.Serializable
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class SyncService(private val context: Context, private val repository: ClimbRepository) {
private val dataStateManager = DataStateManager(context)
private val syncMutex = Mutex()
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
companion object {
private const val TAG = "SyncService"
}
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
private val httpClient =
OkHttpClient.Builder()
.connectTimeout(45, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS)
.build()
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
explicitNulls = false
}
// State
private val _isSyncing = MutableStateFlow(false)
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
private val _lastSyncTime = MutableStateFlow<String?>(null)
val lastSyncTime: StateFlow<String?> = _lastSyncTime.asStateFlow()
private val _syncError = MutableStateFlow<String?>(null)
val syncError: StateFlow<String?> = _syncError.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _isConfigured = MutableStateFlow(false)
val isConfiguredFlow: StateFlow<Boolean> = _isConfigured.asStateFlow()
private val _isTesting = MutableStateFlow(false)
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
private val _isAutoSyncEnabled = MutableStateFlow(true)
val isAutoSyncEnabled: StateFlow<Boolean> = _isAutoSyncEnabled.asStateFlow()
private var isOfflineMode = false
// Debounced sync properties
private var syncJob: Job? = null
private var pendingChanges = false
private val syncDebounceDelay = 2000L // 2 seconds
// Configuration keys
private object Keys {
const val SERVER_URL = "server_url"
const val AUTH_TOKEN = "auth_token"
const val IS_CONNECTED = "is_connected"
const val LAST_SYNC_TIME = "last_sync_time"
const val AUTO_SYNC_ENABLED = "auto_sync_enabled"
const val OFFLINE_MODE = "offline_mode"
}
init {
loadInitialState()
updateConfiguredState()
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
}
private fun loadInitialState() {
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
_isAutoSyncEnabled.value = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
isOfflineMode = sharedPreferences.getBoolean(Keys.OFFLINE_MODE, false)
}
private fun updateConfiguredState() {
_isConfigured.value = serverUrl.isNotBlank() && authToken.isNotBlank()
}
var serverUrl: String
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
}
// 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) {
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
}
fun setAutoSyncEnabled(enabled: Boolean) {
_isAutoSyncEnabled.value = enabled
sharedPreferences.edit { putBoolean(Keys.AUTO_SYNC_ENABLED, enabled) }
}
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
private fun isNetworkAvailable(): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
return when {
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
else -> false
}
}
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
suspend fun syncWithServer() {
if (isOfflineMode) {
Log.d(TAG, "Sync skipped: Offline mode is enabled.")
return
}
if (!isNetworkAvailable()) {
_syncError.value = "No internet connection."
Log.d(TAG, "Sync skipped: No internet connection.")
return
}
if (!_isConfigured.value) {
throw SyncException.NotConfigured
}
if (!_isConnected.value) {
throw SyncException.NotConnected
}
syncMutex.withLock {
_isSyncing.value = true
_syncError.value = null
try {
val localBackup = createBackupFromRepository()
val serverBackup = downloadData()
val hasLocalData =
localBackup.gyms.isNotEmpty() ||
localBackup.problems.isNotEmpty() ||
localBackup.sessions.isNotEmpty() ||
localBackup.attempts.isNotEmpty()
val hasServerData =
serverBackup.gyms.isNotEmpty() ||
serverBackup.problems.isNotEmpty() ||
serverBackup.sessions.isNotEmpty() ||
serverBackup.attempts.isNotEmpty()
when {
!hasLocalData && hasServerData -> {
Log.d(TAG, "No local data found, performing full restore from server")
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Full restore completed")
}
hasLocalData && !hasServerData -> {
Log.d(TAG, "No server data found, uploading local data to server")
uploadData(localBackup)
syncImagesForBackup(localBackup)
Log.d(TAG, "Initial upload completed")
}
hasLocalData && hasServerData -> {
Log.d(TAG, "Both local and server data exist, merging (server wins)")
mergeDataSafely(serverBackup)
Log.d(TAG, "Merge completed")
}
else -> {
Log.d(TAG, "No data to sync")
}
}
val now = DateFormatUtils.nowISO8601()
_lastSyncTime.value = now
sharedPreferences.edit { putString(Keys.LAST_SYNC_TIME, now) }
} catch (e: Exception) {
_syncError.value = e.message
throw e
} finally {
_isSyncing.value = false
}
}
}
private suspend fun downloadData(): ClimbDataBackup {
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.get()
.build()
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(body)
} else {
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
gyms = emptyList(),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
}
} else {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
}
private suspend fun uploadData(backup: ClimbDataBackup) {
val requestBody =
json.encodeToString(ClimbDataBackup.serializer(), backup)
.toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
}
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> {
val imagePathMapping = mutableMapOf<String, String>()
val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 }
Log.d(TAG, "Starting image download from server for $totalImages images")
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
val localImagePath = downloadImage(serverFilename)
if (localImagePath != null) {
imagePathMapping[imagePath] = localImagePath
}
} catch (_: SyncException.ImageNotFound) {
Log.w(TAG, "Image not found on server: $imagePath")
} catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
}
}
}
}
return imagePathMapping
}
private suspend fun downloadImage(serverFilename: String): String? {
val request =
Request.Builder()
.url("$serverUrl/images/download?filename=$serverFilename")
.header("Authorization", "Bearer $authToken")
.build()
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body?.bytes()?.let {
ImageUtils.saveImageFromBytesWithFilename(context, it, serverFilename)
}
} else {
if (response.code == 404) throw SyncException.ImageNotFound
null
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error downloading image $serverFilename", e)
null
}
}
}
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems")
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
problem.imagePaths?.forEach { localPath ->
val filename = localPath.substringAfterLast('/')
uploadImage(localPath, filename)
}
}
}
}
private suspend fun uploadImage(localPath: String, filename: String) {
val file = ImageUtils.getImageFile(context, localPath)
if (!file.exists()) {
Log.w(TAG, "Local image file not found, cannot upload: $localPath")
return
}
val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/images/upload?filename=$filename")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
Log.d(TAG, "Successfully uploaded image: $filename")
} else {
Log.w(
TAG,
"Failed to upload image $filename. Server responded with ${response.code}"
)
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error uploading image $filename", e)
}
}
}
private suspend fun createBackupFromRepository(): ClimbDataBackup {
return withContext(Dispatchers.Default) {
ClimbDataBackup(
exportedAt = dataStateManager.getLastModified(),
gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) },
problems =
repository.getAllProblems().first().map { problem ->
val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(
problem.id,
index
)
}
if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
},
sessions =
repository.getAllSessions().first().map {
BackupClimbSession.fromClimbSession(it)
},
attempts =
repository.getAllAttempts().first().map {
BackupAttempt.fromAttempt(it)
},
deletedItems = repository.getDeletedItems()
)
}
}
private suspend fun importBackupToRepository(
backup: ClimbDataBackup,
imagePathMapping: Map<String, String>
) {
val gyms = backup.gyms.map { it.toGym() }
val problems =
backup.problems.map { backupProblem ->
val imagePaths = backupProblem.imagePaths
val updatedImagePaths =
imagePaths?.map { oldPath ->
imagePathMapping[oldPath] ?: oldPath
}
backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
}
val sessions = backup.sessions.map { it.toClimbSession() }
val attempts = backup.attempts.map { it.toAttempt() }
repository.resetAllData()
gyms.forEach { repository.insertGymWithoutSync(it) }
problems.forEach { repository.insertProblemWithoutSync(it) }
sessions.forEach { repository.insertSessionWithoutSync(it) }
attempts.forEach { repository.insertAttemptWithoutSync(it) }
repository.clearDeletedItems()
}
private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) {
Log.d(TAG, "Server data will overwrite local data. Performing full restore.")
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
}
private fun handleHttpError(code: Int): Nothing {
when (code) {
401 -> throw SyncException.Unauthorized
in 500..599 -> throw SyncException.ServerError(code)
else -> throw SyncException.InvalidResponse("HTTP error code: $code")
}
}
suspend fun testConnection() {
if (!_isConfigured.value) {
_isConnected.value = false
_syncError.value = "Server URL or Auth Token is not set."
return
}
_isTesting.value = true
_syncError.value = null
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.head()
.build()
try {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute().use { response ->
_isConnected.value = response.isSuccessful || response.code == 405
}
}
if (!_isConnected.value) {
_syncError.value = "Connection failed. Check URL and token."
}
} catch (e: Exception) {
_isConnected.value = false
_syncError.value = "Connection error: ${e.message}"
} finally {
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) }
_isTesting.value = false
}
}
fun triggerAutoSync() {
if (!_isConfigured.value || !_isConnected.value || !_isAutoSyncEnabled.value) {
return
}
if (_isSyncing.value) {
pendingChanges = true
return
}
syncJob?.cancel()
syncJob =
serviceScope.launch {
delay(syncDebounceDelay)
try {
syncWithServer()
} catch (e: Exception) {
Log.e(TAG, "Auto-sync failed", e)
}
if (pendingChanges) {
pendingChanges = false
triggerAutoSync()
}
}
}
fun clearConfiguration() {
syncJob?.cancel()
serverUrl = ""
authToken = ""
setAutoSyncEnabled(true)
_lastSyncTime.value = null
_isConnected.value = false
_syncError.value = null
sharedPreferences.edit { clear() }
updateConfiguredState()
}
}
sealed class SyncException(message: String) : IOException(message), Serializable {
object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.") {
@JvmStatic private fun readResolve(): Any = NotConfigured
}
object NotConnected : SyncException("Not connected to server. Please test connection first.") {
@JvmStatic private fun readResolve(): Any = NotConnected
}
object Unauthorized : SyncException("Unauthorized. Please check your auth token.") {
@JvmStatic private fun readResolve(): Any = Unauthorized
}
object ImageNotFound : SyncException("Image not found on server") {
@JvmStatic private fun readResolve(): Any = ImageNotFound
}
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details")
data class DecodingError(val details: String) :
SyncException("Failed to decode server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details")
}

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.navigation package com.atridad.ascently.navigation
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* 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 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.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -8,10 +8,10 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.atridad.openclimb.MainActivity import com.atridad.ascently.MainActivity
import com.atridad.openclimb.R import com.atridad.ascently.R
import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -52,7 +52,7 @@ class SessionTrackingService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val database = OpenClimbDatabase.getDatabase(this) val database = AscentlyDatabase.getDatabase(this)
repository = ClimbRepository(database, this) repository = ClimbRepository(database, this)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
@@ -75,8 +75,8 @@ class SessionTrackingService : Service() {
sessionId != null -> repository.getSessionById(sessionId) sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession() else -> repository.getActiveSession()
} }
if (targetSession != null && targetSession.status == com.atridad.openclimb.data.model.SessionStatus.ACTIVE) { if (targetSession != null && targetSession.status == com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
val completed = with(com.atridad.openclimb.data.model.ClimbSession) { targetSession.complete() } val completed = with(com.atridad.ascently.data.model.ClimbSession) { targetSession.complete() }
repository.updateSession(completed) repository.updateSession(completed)
} }
} finally { } finally {
@@ -89,10 +89,6 @@ class SessionTrackingService : Service() {
return START_REDELIVER_INTENT return START_REDELIVER_INTENT
} }
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
}
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) { private fun startSessionTracking(sessionId: String) {
@@ -131,7 +127,7 @@ class SessionTrackingService : Service() {
} }
val session = repository.getSessionById(sessionId) 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() stopSessionTracking()
break break
} }
@@ -153,7 +149,7 @@ class SessionTrackingService : Service() {
return try { return try {
val activeNotifications = notificationManager.activeNotifications val activeNotifications = notificationManager.activeNotifications
activeNotifications.any { it.id == NOTIFICATION_ID } activeNotifications.any { it.id == NOTIFICATION_ID }
} catch (e: Exception) { } catch (_: Exception) {
false false
} }
} }
@@ -179,7 +175,7 @@ class SessionTrackingService : Service() {
val session = runBlocking { val session = runBlocking {
repository.getSessionById(sessionId) 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() stopSessionTracking()
return 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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -17,21 +17,21 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.openclimb.data.sync.SyncService import com.atridad.ascently.data.sync.SyncService
import com.atridad.openclimb.navigation.Screen import com.atridad.ascently.navigation.Screen
import com.atridad.openclimb.navigation.bottomNavigationItems import com.atridad.ascently.navigation.bottomNavigationItems
import com.atridad.openclimb.ui.components.NotificationPermissionDialog import com.atridad.ascently.ui.components.NotificationPermissionDialog
import com.atridad.openclimb.ui.screens.* import com.atridad.ascently.ui.screens.*
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory
import com.atridad.openclimb.utils.AppShortcutManager import com.atridad.ascently.utils.AppShortcutManager
import com.atridad.openclimb.utils.NotificationPermissionUtils import com.atridad.ascently.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun OpenClimbApp( fun AscentlyApp(
shortcutAction: String? = null, shortcutAction: String? = null,
lastUsedGymId: String? = null, lastUsedGymId: String? = null,
onShortcutActionProcessed: () -> Unit = {} onShortcutActionProcessed: () -> Unit = {}
@@ -39,9 +39,9 @@ fun OpenClimbApp(
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current 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 repository = remember { ClimbRepository(database, context) }
val syncService = remember { SyncService(context, repository) } val syncService = remember { SyncService(context, repository) }
val viewModel: ClimbViewModel = val viewModel: ClimbViewModel =
@@ -115,7 +115,7 @@ fun OpenClimbApp(
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) { LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) { if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d( android.util.Log.d(
"OpenClimbApp", "AscentlyApp",
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}" "Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
) )
@@ -125,12 +125,12 @@ fun OpenClimbApp(
context context
) )
) { ) {
android.util.Log.d("OpenClimbApp", "Showing notification permission dialog") android.util.Log.d("AscentlyApp", "Showing notification permission dialog")
showNotificationPermissionDialog = true showNotificationPermissionDialog = true
} else { } else {
if (gyms.size == 1) { if (gyms.size == 1) {
android.util.Log.d( android.util.Log.d(
"OpenClimbApp", "AscentlyApp",
"Starting session with single gym: ${gyms.first().name}" "Starting session with single gym: ${gyms.first().name}"
) )
viewModel.startSession(context, gyms.first().id) viewModel.startSession(context, gyms.first().id)
@@ -141,13 +141,13 @@ fun OpenClimbApp(
if (targetGym != null) { if (targetGym != null) {
android.util.Log.d( android.util.Log.d(
"OpenClimbApp", "AscentlyApp",
"Starting session with target gym: ${targetGym.name}" "Starting session with target gym: ${targetGym.name}"
) )
viewModel.startSession(context, targetGym.id) viewModel.startSession(context, targetGym.id)
} else { } else {
android.util.Log.d( android.util.Log.d(
"OpenClimbApp", "AscentlyApp",
"No target gym found, navigating to selection" "No target gym found, navigating to selection"
) )
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
@@ -156,7 +156,7 @@ fun OpenClimbApp(
} }
} else { } else {
android.util.Log.d( android.util.Log.d(
"OpenClimbApp", "AscentlyApp",
"Active session already exists: ${activeSession?.id}" "Active session already exists: ${activeSession?.id}"
) )
} }
@@ -168,7 +168,7 @@ fun OpenClimbApp(
var fabConfig by remember { mutableStateOf<FabConfig?>(null) } var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
Scaffold( Scaffold(
bottomBar = { OpenClimbBottomNavigation(navController = navController) }, bottomBar = { AscentlyBottomNavigation(navController = navController) },
floatingActionButton = { floatingActionButton = {
fabConfig?.let { config -> fabConfig?.let { config ->
FloatingActionButton( FloatingActionButton(
@@ -363,7 +363,7 @@ fun OpenClimbApp(
} }
@Composable @Composable
fun OpenClimbBottomNavigation(navController: NavHostController) { fun AscentlyBottomNavigation(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route 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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -10,9 +10,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.ClimbSession import com.atridad.ascently.data.model.ClimbSession
import com.atridad.openclimb.data.model.Gym import com.atridad.ascently.data.model.Gym
import com.atridad.openclimb.ui.theme.CustomIcons import com.atridad.ascently.ui.theme.CustomIcons
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit 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.Canvas
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box

View File

@@ -0,0 +1,137 @@
package com.atridad.ascently.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

@@ -0,0 +1,64 @@
package com.atridad.ascently.ui.components
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.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@Composable
fun ImageDisplay(
imagePaths: List<String>,
modifier: Modifier = Modifier,
imageSize: Int = 120,
onImageClick: ((Int) -> Unit)? = null
) {
val context = LocalContext.current
if (imagePaths.isNotEmpty()) {
LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(imagePaths) { index, imagePath ->
OrientationAwareImage(
imagePath = imagePath,
contentDescription = "Problem photo",
modifier =
Modifier.size(imageSize.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(enabled = onImageClick != null) {
onImageClick?.invoke(index)
},
contentScale = ContentScale.Crop
)
}
}
}
}
@Composable
fun ImageDisplaySection(
imagePaths: List<String>,
modifier: Modifier = Modifier,
title: String = "Photos",
onImageClick: ((Int) -> Unit)? = null
) {
if (imagePaths.isNotEmpty()) {
Column(modifier = modifier) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
ImageDisplay(imagePaths = imagePaths, imageSize = 120, onImageClick = onImageClick)
}
}
}

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.components package com.atridad.ascently.ui.components
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -25,8 +25,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import coil.compose.AsyncImage import com.atridad.ascently.utils.ImageUtils
import com.atridad.openclimb.utils.ImageUtils
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -259,8 +258,8 @@ private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifie
val imageFile = ImageUtils.getImageFile(context, imagePath) val imageFile = ImageUtils.getImageFile(context, imagePath)
Box(modifier = modifier.size(80.dp)) { Box(modifier = modifier.size(80.dp)) {
AsyncImage( OrientationAwareImage(
model = imageFile, imagePath = imagePath,
contentDescription = "Problem photo", contentDescription = "Problem photo",
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)), modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop

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.Canvas
import androidx.compose.foundation.layout.Box 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

@@ -0,0 +1,149 @@
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.material3.CircularProgressIndicator
import androidx.compose.runtime.*
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.exifinterface.media.ExifInterface
import com.atridad.ascently.utils.ImageUtils
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun OrientationAwareImage(
imagePath: String,
contentDescription: String? = null,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit
) {
val context = LocalContext.current
var imageBitmap by
remember(imagePath) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
var isLoading by remember(imagePath) { mutableStateOf(true) }
LaunchedEffect(imagePath) {
isLoading = true
val bitmap =
withContext(Dispatchers.IO) {
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (!imageFile.exists()) return@withContext null
val originalBitmap =
BitmapFactory.decodeFile(imageFile.absolutePath)
?: return@withContext null
val correctedBitmap = correctImageOrientation(imageFile, originalBitmap)
correctedBitmap.asImageBitmap()
} catch (e: Exception) {
null
}
}
imageBitmap = bitmap
isLoading = false
}
Box(modifier = modifier) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.fillMaxSize())
} else {
imageBitmap?.let { bitmap ->
Image(
bitmap = bitmap,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
contentScale = contentScale
)
}
}
}
}
private fun correctImageOrientation(
imageFile: File,
bitmap: android.graphics.Bitmap
): android.graphics.Bitmap {
return try {
val exif = ExifInterface(imageFile.absolutePath)
val orientation =
exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
val matrix = Matrix()
var needsTransform = false
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
needsTransform = true
}
ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
needsTransform = true
}
ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
needsTransform = true
}
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
needsTransform = true
}
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
needsTransform = true
}
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
needsTransform = true
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
needsTransform = true
}
else -> {
if (orientation == ExifInterface.ORIENTATION_UNDEFINED || orientation == 0) {
if (imageFile.name.startsWith("problem_") &&
imageFile.name.contains("_") &&
imageFile.name.endsWith(".jpg")
) {
matrix.postRotate(90f)
needsTransform = true
}
}
}
}
if (!needsTransform) {
bitmap
} else {
val rotatedBitmap =
android.graphics.Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true
)
if (rotatedBitmap != bitmap) {
bitmap.recycle()
}
rotatedBitmap
}
} catch (e: Exception) {
bitmap
}
}

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.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.health package com.atridad.ascently.ui.health
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -13,7 +13,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.health.HealthConnectManager import com.atridad.ascently.data.health.HealthConnectManager
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)

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.layout.*
import androidx.compose.foundation.lazy.LazyColumn 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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.* import com.atridad.ascently.data.model.*
import com.atridad.openclimb.ui.components.ImagePicker import com.atridad.ascently.ui.components.ImagePicker
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first

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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -9,14 +9,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.ascently.R
import com.atridad.openclimb.data.model.AttemptResult import com.atridad.ascently.data.model.AttemptResult
import com.atridad.openclimb.data.model.ClimbType import com.atridad.ascently.data.model.ClimbType
import com.atridad.openclimb.data.model.DifficultySystem import com.atridad.ascently.data.model.DifficultySystem
import com.atridad.openclimb.ui.components.BarChart import com.atridad.ascently.ui.components.BarChart
import com.atridad.openclimb.ui.components.BarChartDataPoint import com.atridad.ascently.ui.components.BarChartDataPoint
import com.atridad.openclimb.ui.components.SyncIndicator import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -39,7 +39,7 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_mountains), painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo", contentDescription = "Ascently Logo",
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
@@ -200,7 +200,9 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
}, },
modifier = modifier =
Modifier.menuAnchor( Modifier.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, type =
ExposedDropdownMenuAnchorType
.PrimaryNotEditable,
enabled = true enabled = true
) )
.width(120.dp), .width(120.dp),
@@ -394,9 +396,9 @@ data class GradeDistributionDataPoint(
) )
fun calculateGradeDistribution( fun calculateGradeDistribution(
sessions: List<com.atridad.openclimb.data.model.ClimbSession>, sessions: List<com.atridad.ascently.data.model.ClimbSession>,
problems: List<com.atridad.openclimb.data.model.Problem>, problems: List<com.atridad.ascently.data.model.Problem>,
attempts: List<com.atridad.openclimb.data.model.Attempt> attempts: List<com.atridad.ascently.data.model.Attempt>
): List<GradeDistributionDataPoint> { ): List<GradeDistributionDataPoint> {
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) { if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
return emptyList() 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.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -31,11 +31,11 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.atridad.openclimb.data.model.* import com.atridad.ascently.data.model.*
import com.atridad.openclimb.ui.components.FullscreenImageViewer import com.atridad.ascently.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplaySection import com.atridad.ascently.ui.components.ImageDisplaySection
import com.atridad.openclimb.ui.theme.CustomIcons import com.atridad.ascently.ui.theme.CustomIcons
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first

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

View File

@@ -1,9 +1,12 @@
package com.atridad.openclimb.ui.screens package com.atridad.ascently.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -12,24 +15,22 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.ascently.R
import com.atridad.openclimb.data.model.ClimbType import com.atridad.ascently.data.model.Attempt
import com.atridad.openclimb.data.model.Gym import com.atridad.ascently.data.model.AttemptResult
import com.atridad.openclimb.data.model.Problem import com.atridad.ascently.data.model.ClimbType
import com.atridad.openclimb.ui.components.FullscreenImageViewer import com.atridad.ascently.data.model.Gym
import com.atridad.openclimb.ui.components.ImageDisplay import com.atridad.ascently.data.model.Problem
import com.atridad.openclimb.ui.components.SyncIndicator import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) { fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val attempts by viewModel.attempts.collectAsState()
val context = LocalContext.current val context = LocalContext.current
var showImageViewer by remember { mutableStateOf(false) }
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedImageIndex by remember { mutableIntStateOf(0) }
// Filter state // Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) } var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
@@ -56,7 +57,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_mountains), painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo", contentDescription = "Ascently Logo",
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
@@ -178,12 +179,8 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
ProblemCard( ProblemCard(
problem = problem, problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
attempts = attempts,
onClick = { onNavigateToProblemDetail(problem.id) }, onClick = { onNavigateToProblemDetail(problem.id) },
onImageClick = { imagePaths, index ->
selectedImagePaths = imagePaths
selectedImageIndex = index
showImageViewer = true
},
onToggleActive = { onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive) val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem, context) viewModel.updateProblem(updatedProblem, context)
@@ -194,15 +191,6 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
} }
} }
} }
// Fullscreen Image Viewer
if (showImageViewer && selectedImagePaths.isNotEmpty()) {
FullscreenImageViewer(
imagePaths = selectedImagePaths,
initialIndex = selectedImageIndex,
onDismiss = { showImageViewer = false }
)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -210,10 +198,17 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
fun ProblemCard( fun ProblemCard(
problem: Problem, problem: Problem,
gymName: String, gymName: String,
attempts: List<Attempt>,
onClick: () -> Unit, onClick: () -> Unit,
onImageClick: ((List<String>, Int) -> Unit)? = null,
onToggleActive: (() -> Unit)? = null onToggleActive: (() -> Unit)? = null
) { ) {
val isCompleted =
attempts.any { attempt ->
attempt.problemId == problem.id &&
(attempt.result == AttemptResult.SUCCESS ||
attempt.result == AttemptResult.FLASH)
}
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) { Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row( Row(
@@ -242,12 +237,35 @@ fun ProblemCard(
} }
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (problem.imagePaths.isNotEmpty()) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = "Has images",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
}
if (isCompleted) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Completed",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
}
Text( Text(
text = problem.difficulty.grade, text = problem.difficulty.grade,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
}
Text( Text(
text = problem.climbType.getDisplayName(), text = problem.climbType.getDisplayName(),
@@ -279,16 +297,6 @@ fun ProblemCard(
} }
} }
// Display images if any
if (problem.imagePaths.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
ImageDisplay(
imagePaths = problem.imagePaths.take(3), // Show max 3 images in list
imageSize = 60,
onImageClick = { index -> onImageClick?.invoke(problem.imagePaths, index) }
)
}
if (!problem.isActive) { if (!problem.isActive) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(

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

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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.ascently.R
import com.atridad.openclimb.ui.components.SyncIndicator import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.openclimb.ui.health.HealthConnectCard import com.atridad.ascently.ui.health.HealthConnectCard
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.io.File import java.io.File
import java.time.Instant import java.time.Instant
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -38,14 +38,15 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
val isTesting by syncService.isTesting.collectAsState() val isTesting by syncService.isTesting.collectAsState()
val lastSyncTime by syncService.lastSyncTime.collectAsState() val lastSyncTime by syncService.lastSyncTime.collectAsState()
val syncError by syncService.syncError.collectAsState() val syncError by syncService.syncError.collectAsState()
val isAutoSyncEnabled by syncService.isAutoSyncEnabled.collectAsState()
// State for dialogs // State for dialogs
var showResetDialog by remember { mutableStateOf(false) } var showResetDialog by remember { mutableStateOf(false) }
var showSyncConfigDialog by remember { mutableStateOf(false) } var showSyncConfigDialog by remember { mutableStateOf(false) }
var showDisconnectDialog by remember { mutableStateOf(false) } var showDisconnectDialog by remember { mutableStateOf(false) }
var showFixImagesDialog by remember { mutableStateOf(false) }
var showDeleteImagesDialog by remember { mutableStateOf(false) } var showDeleteImagesDialog by remember { mutableStateOf(false) }
var isFixingImages by remember { mutableStateOf(false) }
var isDeletingImages by remember { mutableStateOf(false) } var isDeletingImages by remember { mutableStateOf(false) }
// Sync configuration state // Sync configuration state
@@ -85,7 +86,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
// Only allow ZIP files // Only allow ZIP files
if (!fileName.lowercase().endsWith(".zip")) { if (!fileName.lowercase().endsWith(".zip")) {
viewModel.setError( 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 return@let
} }
@@ -128,7 +129,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_mountains), painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo", contentDescription = "Ascently Logo",
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
@@ -280,8 +281,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
} }
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Switch( Switch(
checked = syncService.isAutoSyncEnabled, checked = isAutoSyncEnabled,
onCheckedChange = { syncService.isAutoSyncEnabled = it } onCheckedChange = { enabled ->
syncService.setAutoSyncEnabled(enabled)
}
) )
} }
} }
@@ -333,7 +336,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
ListItem( ListItem(
headlineContent = { Text("Setup Sync") }, headlineContent = { Text("Setup Sync") },
supportingContent = { supportingContent = {
Text("Connect to your OpenClimb sync server") Text("Connect to your Ascently sync server")
}, },
leadingContent = { leadingContent = {
Icon(Icons.Default.CloudSync, contentDescription = null) Icon(Icons.Default.CloudSync, contentDescription = null)
@@ -418,7 +421,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
TextButton( TextButton(
onClick = { onClick = {
val defaultFileName = val defaultFileName =
"openclimb_export_${ "ascently_export_${
java.time.LocalDateTime.now() java.time.LocalDateTime.now()
.toString() .toString()
.replace(":", "-") .replace(":", "-")
@@ -481,46 +484,6 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Fix Image Names") },
supportingContent = {
Text(
"Rename all images to use consistent naming across devices"
)
},
leadingContent = {
Icon(Icons.Default.Build, contentDescription = null)
},
trailingContent = {
TextButton(
onClick = { showFixImagesDialog = true },
enabled = !isFixingImages && !uiState.isLoading
) {
if (isFixingImages) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Fix Names")
}
}
}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = colors =
@@ -641,11 +604,11 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
painterResource( painterResource(
id = R.drawable.ic_mountains id = R.drawable.ic_mountains
), ),
contentDescription = "OpenClimb Logo", contentDescription = "Ascently Logo",
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Text("OpenClimb") Text("Ascently")
} }
}, },
supportingContent = { Text("Track your climbing progress") }, supportingContent = { Text("Track your climbing progress") },
@@ -1002,35 +965,6 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
) )
} }
// Fix Image Names dialog
if (showFixImagesDialog) {
AlertDialog(
onDismissRequest = { showFixImagesDialog = false },
title = { Text("Fix Image Names") },
text = {
Text(
"This will rename all existing image files to use a consistent naming system across devices.\n\nThis improves sync reliability between iOS and Android. Your images will not be lost, only renamed.\n\nThis is safe to run multiple times."
)
},
confirmButton = {
TextButton(
onClick = {
isFixingImages = true
showFixImagesDialog = false
coroutineScope.launch {
viewModel.migrateImageNamesToDeterministic(context)
isFixingImages = false
viewModel.setMessage("Image names fixed successfully!")
}
}
) { Text("Fix Names") }
},
dismissButton = {
TextButton(onClick = { showFixImagesDialog = false }) { Text("Cancel") }
}
)
}
// Delete All Images dialog // Delete All Images dialog
if (showDeleteImagesDialog) { if (showDeleteImagesDialog) {
AlertDialog( AlertDialog(

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.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.Color
import androidx.compose.ui.graphics.SolidColor 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 android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -88,7 +88,7 @@ private val LightColorScheme = lightColorScheme(
) )
@Composable @Composable
fun OpenClimbTheme( fun AscentlyTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ and provides full Material You theming // Dynamic color is available on Android 12+ and provides full Material You theming
// When enabled, it adapts to the user's system wallpaper colors // 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.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle

View File

@@ -1,17 +1,17 @@
package com.atridad.openclimb.ui.viewmodel package com.atridad.ascently.ui.viewmodel
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.atridad.openclimb.data.health.HealthConnectManager import com.atridad.ascently.data.health.HealthConnectManager
import com.atridad.openclimb.data.model.* import com.atridad.ascently.data.model.*
import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.openclimb.data.sync.SyncService import com.atridad.ascently.data.sync.SyncService
import com.atridad.openclimb.service.SessionTrackingService import com.atridad.ascently.service.SessionTrackingService
import com.atridad.openclimb.utils.ImageNamingUtils import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.openclimb.utils.ImageUtils import com.atridad.ascently.utils.ImageUtils
import com.atridad.openclimb.utils.SessionShareUtils import com.atridad.ascently.utils.SessionShareUtils
import com.atridad.openclimb.widget.ClimbStatsWidgetProvider import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -171,64 +171,6 @@ class ClimbViewModel(
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths) ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
} }
fun migrateImageNamesToDeterministic(context: Context) {
viewModelScope.launch {
val allProblems = repository.getAllProblems().first()
var migrationCount = 0
val updatedProblems = mutableListOf<Problem>()
for (problem in allProblems) {
if (problem.imagePaths.isEmpty()) continue
var newImagePaths = mutableListOf<String>()
var problemNeedsUpdate = false
for ((index, imagePath) in problem.imagePaths.withIndex()) {
val currentFilename = File(imagePath).name
if (ImageNamingUtils.isValidImageFilename(currentFilename)) {
newImagePaths.add(imagePath)
continue
}
val deterministicName =
ImageNamingUtils.generateImageFilename(problem.id, index)
val imagesDir = ImageUtils.getImagesDirectory(context)
val oldFile = File(imagesDir, currentFilename)
val newFile = File(imagesDir, deterministicName)
if (oldFile.exists()) {
if (oldFile.renameTo(newFile)) {
newImagePaths.add(deterministicName)
problemNeedsUpdate = true
migrationCount++
println("Migrated: $currentFilename$deterministicName")
} else {
println("Failed to migrate $currentFilename")
newImagePaths.add(imagePath)
}
} else {
println("Warning: Image file not found: $currentFilename")
newImagePaths.add(imagePath)
}
}
if (problemNeedsUpdate) {
val updatedProblem = problem.copy(imagePaths = newImagePaths)
updatedProblems.add(updatedProblem)
}
}
for (updatedProblem in updatedProblems) {
repository.insertProblemWithoutSync(updatedProblem)
}
println(
"Migration completed: $migrationCount images renamed, ${updatedProblems.size} problems updated"
)
}
}
fun deleteAllImages(context: Context) { fun deleteAllImages(context: Context) {
viewModelScope.launch { viewModelScope.launch {
@@ -318,7 +260,7 @@ class ClimbViewModel(
viewModelScope.launch { viewModelScope.launch {
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId") android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
if (!com.atridad.openclimb.utils.NotificationPermissionUtils if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context) .isNotificationPermissionGranted(context)
) { ) {
android.util.Log.d("ClimbViewModel", "Notification permission not granted") android.util.Log.d("ClimbViewModel", "Notification permission not granted")
@@ -363,7 +305,7 @@ class ClimbViewModel(
fun endSession(context: Context, sessionId: String) { fun endSession(context: Context, sessionId: String) {
viewModelScope.launch { viewModelScope.launch {
if (!com.atridad.openclimb.utils.NotificationPermissionUtils if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context) .isNotificationPermissionGranted(context)
) { ) {
_uiState.value = _uiState.value =
@@ -474,7 +416,7 @@ class ClimbViewModel(
if (!file.name.lowercase().endsWith(".zip")) { if (!file.name.lowercase().endsWith(".zip")) {
throw Exception( throw Exception(
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb." "Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
) )
} }

View File

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

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.utils package com.atridad.ascently.utils
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.utils package com.atridad.ascently.utils
import java.security.MessageDigest import java.security.MessageDigest
import java.util.* import java.util.*

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.utils package com.atridad.ascently.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
@@ -8,6 +8,7 @@ import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.core.graphics.scale import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.UUID import java.util.UUID
@@ -27,7 +28,57 @@ object ImageUtils {
return imagesDir return imagesDir
} }
/** Saves an image from a URI with compression and proper orientation */ /** Saves an image from a URI while preserving EXIF orientation data */
private fun saveImageWithExif(
context: Context,
imageUri: Uri,
originalBitmap: Bitmap,
outputFile: File
): Boolean {
return try {
// Get EXIF data from original image
val originalExif =
context.contentResolver.openInputStream(imageUri)?.use { input ->
ExifInterface(input)
}
// Compress and save the bitmap
val compressedBitmap = compressImage(originalBitmap)
FileOutputStream(outputFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Copy EXIF data to the saved file
originalExif?.let { sourceExif ->
val destExif = ExifInterface(outputFile.absolutePath)
// Copy orientation and other important EXIF attributes
val orientationValue = sourceExif.getAttribute(ExifInterface.TAG_ORIENTATION)
orientationValue?.let { destExif.setAttribute(ExifInterface.TAG_ORIENTATION, it) }
// Copy other useful EXIF data
sourceExif.getAttribute(ExifInterface.TAG_DATETIME)?.let {
destExif.setAttribute(ExifInterface.TAG_DATETIME, it)
}
sourceExif.getAttribute(ExifInterface.TAG_GPS_LATITUDE)?.let {
destExif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, it)
}
sourceExif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE)?.let {
destExif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, it)
}
destExif.saveAttributes()
}
compressedBitmap.recycle()
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
/** Saves an image from a URI with compression */
fun saveImageFromUri( fun saveImageFromUri(
context: Context, context: Context,
imageUri: Uri, imageUri: Uri,
@@ -42,10 +93,7 @@ object ImageUtils {
} }
?: return null ?: return null
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) // Always require deterministic naming
val compressedBitmap = compressImage(orientedBitmap)
// Always require deterministic naming - no UUID fallback
require(problemId != null && imageIndex != null) { require(problemId != null && imageIndex != null) {
"Problem ID and image index are required for deterministic image naming" "Problem ID and image index are required for deterministic image naming"
} }
@@ -53,15 +101,10 @@ object ImageUtils {
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex) val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val imageFile = File(getImagesDirectory(context), filename) val imageFile = File(getImagesDirectory(context), filename)
FileOutputStream(imageFile).use { output -> val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
originalBitmap.recycle() originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle() if (!success) return null
}
compressedBitmap.recycle()
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
} catch (e: Exception) { } catch (e: Exception) {
@@ -221,23 +264,13 @@ object ImageUtils {
MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri) MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri)
?: return null ?: return null
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap)
val tempFilename = "temp_${UUID.randomUUID()}.jpg" val tempFilename = "temp_${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), tempFilename) val imageFile = File(getImagesDirectory(context), tempFilename)
FileOutputStream(imageFile).use { output -> val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
originalBitmap.recycle() originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle() if (!success) return null
}
if (compressedBitmap != orientedBitmap) {
compressedBitmap.recycle()
}
tempFilename tempFilename
} catch (e: Exception) { } catch (e: Exception) {
@@ -315,21 +348,40 @@ object ImageUtils {
filename: String filename: String
): String? { ): String? {
return try { return try {
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap)
// Use the provided filename instead of generating a new UUID
val imageFile = File(getImagesDirectory(context), filename) val imageFile = File(getImagesDirectory(context), filename)
// Check if image is too large and needs compression
if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold
// For large images, decode, compress, and try to preserve EXIF
val bitmap =
BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap)
// Save compressed image // Save compressed image
FileOutputStream(imageFile).use { output -> FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
} }
// Clean up bitmaps // Try to preserve EXIF orientation from original data
try {
val originalExif = ExifInterface(java.io.ByteArrayInputStream(imageData))
val destExif = ExifInterface(imageFile.absolutePath)
val orientationValue = originalExif.getAttribute(ExifInterface.TAG_ORIENTATION)
orientationValue?.let {
destExif.setAttribute(ExifInterface.TAG_ORIENTATION, it)
}
destExif.saveAttributes()
} catch (e: Exception) {
// If EXIF preservation fails, continue without it
Log.w("ImageUtils", "Failed to preserve EXIF data: ${e.message}")
}
bitmap.recycle() bitmap.recycle()
compressedBitmap.recycle() compressedBitmap.recycle()
} else {
// For smaller images, save raw data to preserve all EXIF information
FileOutputStream(imageFile).use { output -> output.write(imageData) }
}
// Return relative path // Return relative path
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"

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.Manifest
import android.content.Context 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.Context
import android.content.Intent import android.content.Intent
@@ -7,7 +7,7 @@ import android.graphics.drawable.GradientDrawable
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import com.atridad.openclimb.data.model.* import com.atridad.ascently.data.model.*
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -382,7 +382,7 @@ object SessionShareUtils {
isAntiAlias = true isAntiAlias = true
textAlign = Paint.Align.CENTER textAlign = Paint.Align.CENTER
} }
canvas.drawText("OpenClimb", width / 2f, height - 40f, brandingPaint) canvas.drawText("Ascently", width / 2f, height - 40f, brandingPaint)
// Save to file // Save to file
val shareDir = File(context.cacheDir, "shares") val shareDir = File(context.cacheDir, "shares")
@@ -481,7 +481,7 @@ object SessionShareUtils {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
type = "image/png" type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri) 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) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.utils package com.atridad.ascently.utils
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -7,23 +7,23 @@ import android.content.pm.ShortcutManager
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.atridad.openclimb.MainActivity import com.atridad.ascently.MainActivity
import com.atridad.openclimb.R import com.atridad.ascently.R
object AppShortcutManager { object AppShortcutManager {
const val SHORTCUT_START_SESSION = "start_session" const val SHORTCUT_START_SESSION = "start_session"
const val SHORTCUT_END_SESSION = "end_session" const val SHORTCUT_END_SESSION = "end_session"
const val ACTION_START_SESSION = "com.atridad.openclimb.action.START_SESSION" const val ACTION_START_SESSION = "com.atridad.ascently.action.START_SESSION"
const val ACTION_END_SESSION = "com.atridad.openclimb.action.END_SESSION" const val ACTION_END_SESSION = "com.atridad.ascently.action.END_SESSION"
/** Updates the app shortcuts based on current session state */ /** Updates the app shortcuts based on current session state */
fun updateShortcuts( fun updateShortcuts(
context: Context, context: Context,
hasActiveSession: Boolean, hasActiveSession: Boolean,
hasGyms: 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
val shortcutManager = context.getSystemService(ShortcutManager::class.java) val shortcutManager = context.getSystemService(ShortcutManager::class.java)
@@ -45,7 +45,7 @@ object AppShortcutManager {
@RequiresApi(Build.VERSION_CODES.N_MR1) @RequiresApi(Build.VERSION_CODES.N_MR1)
private fun createStartSessionShortcut( private fun createStartSessionShortcut(
context: Context, context: Context,
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null lastUsedGym: com.atridad.ascently.data.model.Gym? = null
): ShortcutInfo { ): ShortcutInfo {
val startIntent = val startIntent =
Intent(context, MainActivity::class.java).apply { 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 android.content.Context
import com.atridad.openclimb.data.format.BackupProblem import com.atridad.ascently.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup import com.atridad.ascently.data.format.ClimbDataBackup
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -33,14 +33,14 @@ object ZipExportImportUtils {
context.getExternalFilesDir( context.getExternalFilesDir(
android.os.Environment.DIRECTORY_DOCUMENTS android.os.Environment.DIRECTORY_DOCUMENTS
), ),
"OpenClimb" "Ascently"
) )
if (!exportDir.exists()) { if (!exportDir.exists()) {
exportDir.mkdirs() exportDir.mkdirs()
} }
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-") val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
val zipFile = File(exportDir, "openclimb_export_$timestamp.zip") val zipFile = File(exportDir, "ascently_export_$timestamp.zip")
try { try {
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut -> ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
@@ -182,8 +182,8 @@ object ZipExportImportUtils {
referencedImagePaths: Set<String> referencedImagePaths: Set<String>
): String { ): String {
return buildString { return buildString {
appendLine("OpenClimb Export Metadata") appendLine("Ascently Export Metadata")
appendLine("=======================") appendLine("========================")
appendLine("Export Date: ${exportData.exportedAt}") appendLine("Export Date: ${exportData.exportedAt}")
appendLine("Version: ${exportData.version}") appendLine("Version: ${exportData.version}")
appendLine("Gyms: ${exportData.gyms.size}") 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.app.PendingIntent
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
@@ -7,10 +7,10 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.widget.RemoteViews import android.widget.RemoteViews
import com.atridad.openclimb.MainActivity import com.atridad.ascently.MainActivity
import com.atridad.openclimb.R import com.atridad.ascently.R
import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -45,7 +45,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
) { ) {
coroutineScope.launch { coroutineScope.launch {
try { try {
val database = OpenClimbDatabase.getDatabase(context) val database = AscentlyDatabase.getDatabase(context)
val repository = ClimbRepository(database, context) val repository = ClimbRepository(database, context)
// Fetch stats data // Fetch stats data
@@ -65,10 +65,10 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
attempts.any { attempt -> attempts.any { attempt ->
attempt.problemId == problem.id && attempt.problemId == problem.id &&
(attempt.result == (attempt.result ==
com.atridad.openclimb.data.model com.atridad.ascently.data.model
.AttemptResult.SUCCESS || .AttemptResult.SUCCESS ||
attempt.result == attempt.result ==
com.atridad.openclimb.data.model com.atridad.ascently.data.model
.AttemptResult.FLASH) .AttemptResult.FLASH)
} }
} }

View File

@@ -1,205 +0,0 @@
package com.atridad.openclimb.data.migration
import android.content.Context
import android.util.Log
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.utils.ImageNamingUtils
import com.atridad.openclimb.utils.ImageUtils
import kotlinx.coroutines.flow.first
/**
* Service responsible for migrating images to use consistent naming convention across platforms.
* This ensures that iOS and Android use the same image filenames for sync compatibility.
*/
class ImageMigrationService(private val context: Context, private val repository: ClimbRepository) {
companion object {
private const val TAG = "ImageMigrationService"
private const val MIGRATION_PREF_KEY = "image_naming_migration_completed"
}
/**
* Performs a complete migration of all images in the system to use consistent naming. This
* should be called once during app startup after the naming convention is implemented.
*/
suspend fun performFullMigration(): ImageMigrationResult {
Log.i(TAG, "Starting full image naming migration")
val prefs = context.getSharedPreferences("openclimb_migration", Context.MODE_PRIVATE)
if (prefs.getBoolean(MIGRATION_PREF_KEY, false)) {
Log.i(TAG, "Image migration already completed, skipping")
return ImageMigrationResult.AlreadyCompleted
}
try {
val allProblems = repository.getAllProblems().first()
val migrationResults = mutableMapOf<String, String>()
var migratedCount = 0
var errorCount = 0
Log.i(TAG, "Found ${allProblems.size} problems to check for image migration")
for (problem in allProblems) {
if (problem.imagePaths.isNotEmpty()) {
Log.d(
TAG,
"Migrating images for problem '${problem.name}': ${problem.imagePaths}"
)
try {
val problemMigrations =
ImageUtils.migrateImageNaming(
context = context,
problemId = problem.id,
currentImagePaths = problem.imagePaths
)
if (problemMigrations.isNotEmpty()) {
migrationResults.putAll(problemMigrations)
migratedCount += problemMigrations.size
// Update image paths
val newImagePaths =
problem.imagePaths.map { oldPath ->
problemMigrations[oldPath] ?: oldPath
}
val updatedProblem = problem.copy(imagePaths = newImagePaths)
repository.insertProblem(updatedProblem)
Log.d(
TAG,
"Updated problem '${problem.name}' with ${problemMigrations.size} migrated images"
)
}
} catch (e: Exception) {
Log.e(
TAG,
"Failed to migrate images for problem '${problem.name}': ${e.message}",
e
)
errorCount++
}
}
}
// Mark migration as completed
prefs.edit().putBoolean(MIGRATION_PREF_KEY, true).apply()
Log.i(
TAG,
"Image migration completed: $migratedCount images migrated, $errorCount errors"
)
return ImageMigrationResult.Success(
totalMigrated = migratedCount,
errors = errorCount,
migrations = migrationResults
)
} catch (e: Exception) {
Log.e(TAG, "Image migration failed: ${e.message}", e)
return ImageMigrationResult.Failed(e.message ?: "Unknown error")
}
}
/** Validates that all images in the system follow the consistent naming convention. */
suspend fun validateImageNaming(): ValidationResult {
try {
val allProblems = repository.getAllProblems().first()
val validImages = mutableListOf<String>()
val invalidImages = mutableListOf<String>()
val missingImages = mutableListOf<String>()
for (problem in allProblems) {
for (imagePath in problem.imagePaths) {
val filename = imagePath.substringAfterLast('/')
// Check if file exists
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (!imageFile.exists()) {
missingImages.add(imagePath)
continue
}
// Check if filename follows convention
if (ImageNamingUtils.isValidImageFilename(filename)) {
validImages.add(imagePath)
} else {
invalidImages.add(imagePath)
}
}
}
return ValidationResult(
totalImages = validImages.size + invalidImages.size + missingImages.size,
validImages = validImages,
invalidImages = invalidImages,
missingImages = missingImages
)
} catch (e: Exception) {
Log.e(TAG, "Image validation failed: ${e.message}", e)
return ValidationResult(
totalImages = 0,
validImages = emptyList(),
invalidImages = emptyList(),
missingImages = emptyList()
)
}
}
/** Migrates images for a specific problem during sync operations. */
suspend fun migrateProblemImages(
problemId: String,
currentImagePaths: List<String>
): Map<String, String> {
return try {
ImageUtils.migrateImageNaming(context, problemId, currentImagePaths)
} catch (e: Exception) {
Log.e(TAG, "Failed to migrate images for problem $problemId: ${e.message}", e)
emptyMap()
}
}
/**
* Cleans up any orphaned image files that don't follow our naming convention and aren't
* referenced by any problems.
*/
suspend fun cleanupOrphanedImages() {
try {
val allProblems = repository.getAllProblems().first()
val referencedPaths = allProblems.flatMap { it.imagePaths }.toSet()
ImageUtils.cleanupOrphanedImages(context, referencedPaths)
Log.i(TAG, "Orphaned image cleanup completed")
} catch (e: Exception) {
Log.e(TAG, "Failed to cleanup orphaned images: ${e.message}", e)
}
}
}
/** Result of an image migration operation */
sealed class ImageMigrationResult {
object AlreadyCompleted : ImageMigrationResult()
data class Success(
val totalMigrated: Int,
val errors: Int,
val migrations: Map<String, String>
) : ImageMigrationResult()
data class Failed(val error: String) : ImageMigrationResult()
}
/** Result of image naming validation */
data class ValidationResult(
val totalImages: Int,
val validImages: List<String>,
val invalidImages: List<String>,
val missingImages: List<String>
) {
val isAllValid: Boolean
get() = invalidImages.isEmpty() && missingImages.isEmpty()
val validPercentage: Double
get() = if (totalImages == 0) 100.0 else (validImages.size.toDouble() / totalImages) * 100
}

View File

@@ -1,209 +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.gestures.detectTransformGestures
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.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
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 coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils
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 ->
ZoomableImage(
imagePath = imagePaths[page],
modifier = Modifier.fillMaxSize()
)
}
// 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 imageFile = ImageUtils.getImageFile(context, imagePath)
val isSelected = index == pagerState.currentPage
AsyncImage(
model = imageFile,
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
)
}
}
}
}
}
}
}
@Composable
private fun ZoomableImage(
imagePath: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val imageFile = ImageUtils.getImageFile(context, imagePath)
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Box(
modifier = modifier
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(0.5f, 5f)
val maxOffsetX = (size.width * (scale - 1)) / 2
val maxOffsetY = (size.height * (scale - 1)) / 2
offsetX = (offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
offsetY = (offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
}
)
},
contentAlignment = Alignment.Center
) {
AsyncImage(
model = imageFile,
contentDescription = "Full screen image",
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
),
contentScale = ContentScale.Fit
)
}
}

View File

@@ -1,75 +0,0 @@
package com.atridad.openclimb.ui.components
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.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils
@Composable
fun ImageDisplay(
imagePaths: List<String>,
modifier: Modifier = Modifier,
imageSize: Int = 120,
onImageClick: ((Int) -> Unit)? = null
) {
val context = LocalContext.current
if (imagePaths.isNotEmpty()) {
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(imagePaths) { index, imagePath ->
val imageFile = ImageUtils.getImageFile(context, imagePath)
AsyncImage(
model = imageFile,
contentDescription = "Problem photo",
modifier = Modifier
.size(imageSize.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(enabled = onImageClick != null) {
onImageClick?.invoke(index)
},
contentScale = ContentScale.Crop
)
}
}
}
}
@Composable
fun ImageDisplaySection(
imagePaths: List<String>,
modifier: Modifier = Modifier,
title: String = "Photos",
onImageClick: ((Int) -> Unit)? = null
) {
if (imagePaths.isNotEmpty()) {
Column(modifier = modifier) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
ImageDisplay(
imagePaths = imagePaths,
imageSize = 120,
onImageClick = onImageClick
)
}
}
}

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

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

View File

@@ -1,5 +1,5 @@
<resources> <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> <string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string>
<!-- App Shortcuts --> <!-- App Shortcuts -->

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
<item name="android:windowSplashScreenAnimationDuration">200</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.ascently.data.format.*
import com.atridad.openclimb.data.model.* import com.atridad.ascently.data.model.*
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import org.junit.Assert.* 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.ascently.data.format.*
import com.atridad.openclimb.data.model.* import com.atridad.ascently.data.model.*
import java.time.Instant import java.time.Instant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import org.junit.Assert.* 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.ascently.data.format.*
import com.atridad.openclimb.data.model.* import com.atridad.ascently.data.model.*
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Test 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.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter

View File

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

View File

@@ -20,7 +20,7 @@
containerPortal = D24C19602E75002A0045894C /* Project object */; containerPortal = D24C19602E75002A0045894C /* Project object */;
proxyType = 1; proxyType = 1;
remoteGlobalIDString = D24C19672E75002A0045894C; remoteGlobalIDString = D24C19672E75002A0045894C;
remoteInfo = OpenClimb; remoteInfo = Ascently;
}; };
D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */ = { D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
@@ -46,9 +46,9 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; }; D24C19682E75002A0045894C /* Ascently.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ascently.app; sourceTree = BUILT_PRODUCTS_DIR; };
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionStatusLiveExtension.entitlements; sourceTree = "<group>"; }; D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionStatusLiveExtension.entitlements; sourceTree = "<group>"; };
D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenClimbTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D2F32FAD2E90B26500B1BC56 /* AscentlyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AscentlyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D2FE94802E78E958008CDB25 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; D2FE94802E78E958008CDB25 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionStatusLiveExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionStatusLiveExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
@@ -56,12 +56,12 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
D28C3C8B2E75111D00F7AEE9 /* Exceptions for "OpenClimb" folder in "OpenClimb" target */ = { D28C3C8B2E75111D00F7AEE9 /* Exceptions for "Ascently" folder in "Ascently" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Info.plist, Info.plist,
); );
target = D24C19672E75002A0045894C /* OpenClimb */; target = D24C19672E75002A0045894C /* Ascently */;
}; };
D2FE94A42E78FEE1008CDB25 /* Exceptions for "SessionStatusLive" folder in "SessionStatusLiveExtension" target */ = { D2FE94A42E78FEE1008CDB25 /* Exceptions for "SessionStatusLive" folder in "SessionStatusLiveExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
@@ -73,17 +73,17 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
D24C196A2E75002A0045894C /* OpenClimb */ = { D24C196A2E75002A0045894C /* Ascently */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
D28C3C8B2E75111D00F7AEE9 /* Exceptions for "OpenClimb" folder in "OpenClimb" target */, D28C3C8B2E75111D00F7AEE9 /* Exceptions for "Ascently" folder in "Ascently" target */,
); );
path = OpenClimb; path = Ascently;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */ = { D2F32FAE2E90B26500B1BC56 /* AscentlyTests */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
path = OpenClimbTests; path = AscentlyTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */ = { D2FE94902E78FEE0008CDB25 /* SessionStatusLive */ = {
@@ -129,9 +129,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */, D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */,
D24C196A2E75002A0045894C /* OpenClimb */, D24C196A2E75002A0045894C /* Ascently */,
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */, D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */, D2F32FAE2E90B26500B1BC56 /* AscentlyTests */,
D2FE947F2E78E958008CDB25 /* Frameworks */, D2FE947F2E78E958008CDB25 /* Frameworks */,
D24C19692E75002A0045894C /* Products */, D24C19692E75002A0045894C /* Products */,
); );
@@ -140,9 +140,9 @@
D24C19692E75002A0045894C /* Products */ = { D24C19692E75002A0045894C /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D24C19682E75002A0045894C /* OpenClimb.app */, D24C19682E75002A0045894C /* Ascently.app */,
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */, D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */,
D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */, D2F32FAD2E90B26500B1BC56 /* AscentlyTests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -160,9 +160,9 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
D24C19672E75002A0045894C /* OpenClimb */ = { D24C19672E75002A0045894C /* Ascently */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "OpenClimb" */; buildConfigurationList = D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "Ascently" */;
buildPhases = ( buildPhases = (
D24C19642E75002A0045894C /* Sources */, D24C19642E75002A0045894C /* Sources */,
D24C19652E75002A0045894C /* Frameworks */, D24C19652E75002A0045894C /* Frameworks */,
@@ -175,18 +175,18 @@
D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */, D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
D24C196A2E75002A0045894C /* OpenClimb */, D24C196A2E75002A0045894C /* Ascently */,
); );
name = OpenClimb; name = Ascently;
packageProductDependencies = ( packageProductDependencies = (
); );
productName = OpenClimb; productName = Ascently;
productReference = D24C19682E75002A0045894C /* OpenClimb.app */; productReference = D24C19682E75002A0045894C /* Ascently.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
D2F32FAC2E90B26500B1BC56 /* OpenClimbTests */ = { D2F32FAC2E90B26500B1BC56 /* AscentlyTests */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "OpenClimbTests" */; buildConfigurationList = D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "AscentlyTests" */;
buildPhases = ( buildPhases = (
D2F32FA92E90B26500B1BC56 /* Sources */, D2F32FA92E90B26500B1BC56 /* Sources */,
D2F32FAA2E90B26500B1BC56 /* Frameworks */, D2F32FAA2E90B26500B1BC56 /* Frameworks */,
@@ -198,13 +198,13 @@
D2F32FB22E90B26500B1BC56 /* PBXTargetDependency */, D2F32FB22E90B26500B1BC56 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */, D2F32FAE2E90B26500B1BC56 /* AscentlyTests */,
); );
name = OpenClimbTests; name = AscentlyTests;
packageProductDependencies = ( packageProductDependencies = (
); );
productName = OpenClimbTests; productName = AscentlyTests;
productReference = D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */; productReference = D2F32FAD2E90B26500B1BC56 /* AscentlyTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
}; };
D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */ = { D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */ = {
@@ -251,7 +251,7 @@
}; };
}; };
}; };
buildConfigurationList = D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */; buildConfigurationList = D24C19632E75002A0045894C /* Build configuration list for PBXProject "Ascently" */;
developmentRegion = en; developmentRegion = en;
hasScannedForEncodings = 0; hasScannedForEncodings = 0;
knownRegions = ( knownRegions = (
@@ -265,9 +265,9 @@
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
D24C19672E75002A0045894C /* OpenClimb */, D24C19672E75002A0045894C /* Ascently */,
D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */, D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */,
D2F32FAC2E90B26500B1BC56 /* OpenClimbTests */, D2F32FAC2E90B26500B1BC56 /* AscentlyTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -323,7 +323,7 @@
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
D2F32FB22E90B26500B1BC56 /* PBXTargetDependency */ = { D2F32FB22E90B26500B1BC56 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = D24C19672E75002A0045894C /* OpenClimb */; target = D24C19672E75002A0045894C /* Ascently */;
targetProxy = D2F32FB12E90B26500B1BC56 /* PBXContainerItemProxy */; targetProxy = D2F32FB12E90B26500B1BC56 /* PBXContainerItemProxy */;
}; };
D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */ = { D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */ = {
@@ -462,20 +462,20 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenClimb/Info.plist; INFOPLIST_FILE = Ascently/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenClimb; INFOPLIST_KEY_CFBundleDisplayName = Ascently;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "OpenClimb needs camera access to take photos of climbing problems."; INFOPLIST_KEY_NSCameraUsageDescription = "Ascently needs camera access to take photos of climbing problems.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "OpenClimb needs access to your photo library to save and display climbing problem images."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Ascently needs access to your photo library to save and display climbing problem images.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -487,8 +487,8 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -510,20 +510,20 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenClimb/Info.plist; INFOPLIST_FILE = Ascently/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenClimb; INFOPLIST_KEY_CFBundleDisplayName = Ascently;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "OpenClimb needs camera access to take photos of climbing problems."; INFOPLIST_KEY_NSCameraUsageDescription = "Ascently needs camera access to take photos of climbing problems.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "OpenClimb needs access to your photo library to save and display climbing problem images."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Ascently needs access to your photo library to save and display climbing problem images.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -535,8 +535,8 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -562,7 +562,7 @@
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.OpenClimb.Watch.OpenClimbTests; PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.OpenClimb.Watch.AscentlyTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -570,7 +570,7 @@
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OpenClimb.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/OpenClimb"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ascently.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/OpenClimb";
}; };
name = Debug; name = Debug;
}; };
@@ -583,7 +583,7 @@
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.OpenClimb.Watch.OpenClimbTests; PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.OpenClimb.Watch.AscentlyTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -591,7 +591,7 @@
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OpenClimb.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/OpenClimb"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ascently.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/OpenClimb";
}; };
name = Release; name = Release;
}; };
@@ -602,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -613,8 +613,8 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -643,8 +643,8 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -658,7 +658,7 @@
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */ = { D24C19632E75002A0045894C /* Build configuration list for PBXProject "Ascently" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
D24C19712E75002A0045894C /* Debug */, D24C19712E75002A0045894C /* Debug */,
@@ -667,7 +667,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "OpenClimb" */ = { D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "Ascently" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
D24C19742E75002A0045894C /* Debug */, D24C19742E75002A0045894C /* Debug */,
@@ -676,7 +676,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "OpenClimbTests" */ = { D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "AscentlyTests" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
D2F32FB32E90B26500B1BC56 /* Debug */, D2F32FB32E90B26500B1BC56 /* Debug */,

View File

@@ -15,9 +15,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C" BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app" BuildableName = "Ascently.app"
BlueprintName = "OpenClimb" BlueprintName = "Ascently"
ReferencedContainer = "container:OpenClimb.xcodeproj"> ReferencedContainer = "container:Ascently.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
@@ -34,9 +34,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "D2F32FAC2E90B26500B1BC56" BlueprintIdentifier = "D2F32FAC2E90B26500B1BC56"
BuildableName = "OpenClimbTests.xctest" BuildableName = "AscentlyTests.xctest"
BlueprintName = "OpenClimbTests" BlueprintName = "AscentlyTests"
ReferencedContainer = "container:OpenClimb.xcodeproj"> ReferencedContainer = "container:Ascently.xcodeproj">
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
</Testables> </Testables>
@@ -56,9 +56,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C" BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app" BuildableName = "Ascently.app"
BlueprintName = "OpenClimb" BlueprintName = "Ascently"
ReferencedContainer = "container:OpenClimb.xcodeproj"> ReferencedContainer = "container:Ascently.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</LaunchAction> </LaunchAction>
@@ -73,9 +73,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C" BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app" BuildableName = "Ascently.app"
BlueprintName = "OpenClimb" BlueprintName = "Ascently"
ReferencedContainer = "container:OpenClimb.xcodeproj"> ReferencedContainer = "container:Ascently.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</ProfileAction> </ProfileAction>

View File

@@ -19,7 +19,7 @@
BlueprintIdentifier = "D2FE948A2E78FEE0008CDB25" BlueprintIdentifier = "D2FE948A2E78FEE0008CDB25"
BuildableName = "SessionStatusLiveExtension.appex" BuildableName = "SessionStatusLiveExtension.appex"
BlueprintName = "SessionStatusLiveExtension" BlueprintName = "SessionStatusLiveExtension"
ReferencedContainer = "container:OpenClimb.xcodeproj"> ReferencedContainer = "container:Ascently.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
<BuildActionEntry <BuildActionEntry
@@ -31,9 +31,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C" BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app" BuildableName = "Ascently.app"
BlueprintName = "OpenClimb" BlueprintName = "Ascently"
ReferencedContainer = "container:OpenClimb.xcodeproj"> ReferencedContainer = "container:Ascently.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
@@ -51,9 +51,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "D2F32FAC2E90B26500B1BC56" BlueprintIdentifier = "D2F32FAC2E90B26500B1BC56"
BuildableName = "OpenClimbTests.xctest" BuildableName = "AscentlyTests.xctest"
BlueprintName = "OpenClimbTests" BlueprintName = "AscentlyTests"
ReferencedContainer = "container:OpenClimb.xcodeproj"> ReferencedContainer = "container:Ascently.xcodeproj">
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
</Testables> </Testables>
@@ -75,9 +75,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C" BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app" BuildableName = "Ascently.app"
BlueprintName = "OpenClimb" BlueprintName = "Ascently"
ReferencedContainer = "container:OpenClimb.xcodeproj"> ReferencedContainer = "container:Ascently.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<EnvironmentVariables> <EnvironmentVariables>
@@ -111,9 +111,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C" BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app" BuildableName = "Ascently.app"
BlueprintName = "OpenClimb" BlueprintName = "Ascently"
ReferencedContainer = "container:OpenClimb.xcodeproj"> ReferencedContainer = "container:Ascently.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</ProfileAction> </ProfileAction>

View File

@@ -4,7 +4,12 @@
<dict> <dict>
<key>SchemeUserState</key> <key>SchemeUserState</key>
<dict> <dict>
<key>OpenClimb.xcscheme_^#shared#^_</key> <key>AscentlyTests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>Ascently.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>1</integer>

View File

@@ -4,7 +4,7 @@
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.com.atridad.OpenClimb</string> <string>group.com.atridad.Ascently</string>
</array> </array>
<key>com.apple.developer.healthkit</key> <key>com.apple.developer.healthkit</key>
<true/> <true/>

View File

@@ -1,8 +1,7 @@
import SwiftUI import SwiftUI
@main @main
struct OpenClimbApp: App { struct AscentlyApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,39 @@
import SwiftUI
struct AsyncImageView: View {
let imagePath: String
let targetSize: CGSize
@State private var image: UIImage?
var body: some View {
ZStack {
Rectangle()
.fill(Color(.systemGray6))
if let image = image {
Image(uiImage: image)
.resizable()
.scaledToFill()
.transition(.opacity.animation(.easeInOut(duration: 0.3)))
} else {
Image(systemName: "photo")
.font(.system(size: 24))
.foregroundColor(Color(.systemGray3))
}
}
.frame(width: targetSize.width, height: targetSize.height)
.clipped()
.cornerRadius(8)
.task(id: imagePath) {
if self.image != nil {
self.image = nil
}
self.image = await ImageManager.shared.loadThumbnail(
fromPath: imagePath,
targetSize: targetSize
)
}
}
}

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