Compare commits

...

16 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
77f8110d85 [Android] 1.9.0 2025-10-11 23:23:24 -06:00
53fa74cc83 iOS Build 23 2025-10-11 18:54:24 -06:00
e7c46634da iOS Build 22 2025-10-10 17:09:23 -06:00
40efd6636f Build 21 2025-10-10 16:32:10 -06:00
719181aa16 iOS Build 20 2025-10-10 13:36:07 -06:00
790b7075c5 [iOS] 1.4.0 - Apple Fitness Integration! 2025-10-10 11:44:33 -06:00
ad8723b8fe One small change 2025-10-09 21:20:08 -06:00
6a39d23f28 [iOS & Android] iOS 1.3.0 & Android 1.8.0
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m13s
2025-10-09 21:00:12 -06:00
603a683ab2 Fixed major issue with sync logic. Should be stable now. Solidified with
tests... turns out syncing is hard...
2025-10-06 18:04:56 -06:00
a19ff8ef66 [iOS & Android] iOS 1.2.5 & Android 1.7.4 [Sync] Sync 1.1.0
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m25s
2025-10-06 17:38:19 -06:00
c10fa48bf5 [iOS & Android] iOS 1.2.4 & Android 1.7.3 2025-10-06 11:54:36 -06:00
acf487db93 wtf 2025-10-06 00:38:58 -06:00
155 changed files with 6375 additions and 3060 deletions

View File

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

View File

@@ -1,17 +1,20 @@
# 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
For Android do one of the following:
1. Download the latest APK from the Releases page
2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.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:
Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
For development builds, sign up for the TestFlight [here](https://testflight.apple.com/join/88RtxV4J)!
## Self-Hosted Sync Server
@@ -21,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:
```
IMAGE=git.atri.dad/atridad/openclimb-sync:latest
IMAGE=git.atri.dad/atridad/ascently-sync:latest
APP_PORT=8080
AUTH_TOKEN=your-secure-auth-token-here
DATA_FILE=/data/openclimb.json
DATA_FILE=/data/ascently.json
IMAGES_DIR=/data/images
ROOT_DIR=./openclimb-data
ROOT_DIR=./ascently-data
```
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 {
namespace = "com.atridad.openclimb"
namespace = "com.atridad.ascently"
compileSdk = 36
defaultConfig {
applicationId = "com.atridad.openclimb"
applicationId = "com.atridad.ascently"
minSdk = 31
targetSdk = 36
versionCode = 30
versionName = "1.7.2"
versionCode = 40
versionName = "2.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -60,6 +60,7 @@ dependencies {
// Room Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.exifinterface)
ksp(libs.androidx.room.compiler)
@@ -78,6 +79,9 @@ dependencies {
// Image Loading
implementation(libs.coil.compose)
// Health Connect
implementation("androidx.health.connect:connect-client:1.1.0-alpha07")
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)

View File

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

View File

@@ -10,6 +10,17 @@
<!-- Permission for sync functionality -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Health Connect permissions -->
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED" />
<!-- Hardware features -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
@@ -19,6 +30,18 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- Health Connect queries -->
<queries>
<package android:name="com.google.android.apps.healthdata" />
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE"/>
<category android:name="android.intent.category.HEALTH_PERMISSIONS"/>
</intent>
</queries>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -27,19 +50,29 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OpenClimb"
android:theme="@style/Theme.Ascently"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.OpenClimb.Splash">
android:theme="@style/Theme.Ascently.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Health Connect permission rationale handling -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
<!-- Permission handling for Android 14 and later -->
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE"/>
<category android:name="android.intent.category.HEALTH_PERMISSIONS"/>
</intent-filter>
</activity>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
package com.atridad.openclimb.data.format
package com.atridad.ascently.data.format
import com.atridad.openclimb.data.model.*
import com.atridad.ascently.data.model.*
import kotlinx.serialization.Serializable
// Root structure for OpenClimb backup data
// Root structure for Ascently backup data
@Serializable
data class ClimbDataBackup(
val exportedAt: String,
@@ -12,7 +12,15 @@ data class ClimbDataBackup(
val gyms: List<BackupGym>,
val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>
val attempts: List<BackupAttempt>,
val deletedItems: List<DeletedItem> = emptyList()
)
@Serializable
data class DeletedItem(
val id: String,
val type: String, // "gym", "problem", "session", "attempt"
val deletedAt: String
)
// Platform-neutral gym representation for backup/restore

View File

@@ -0,0 +1,426 @@
package com.atridad.ascently.data.health
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.activity.result.contract.ActivityResultContract
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
import androidx.health.connect.client.units.Energy
import com.atridad.ascently.data.model.ClimbSession
import com.atridad.ascently.data.model.SessionStatus
import com.atridad.ascently.utils.DateFormatUtils
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
/**
* Health Connect manager for Ascently that syncs climbing sessions to Samsung Health, Google Fit,
* and other health apps.
*/
@SuppressLint("RestrictedApi")
class HealthConnectManager(private val context: Context) {
private val preferences: SharedPreferences =
context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE)
private val _isEnabled = MutableStateFlow(preferences.getBoolean("enabled", false))
private val _hasPermissions = MutableStateFlow(preferences.getBoolean("permissions", false))
private val _autoSync = MutableStateFlow(preferences.getBoolean("auto_sync", true))
private val _isCompatible = MutableStateFlow(true)
val isEnabled: Flow<Boolean> = _isEnabled.asStateFlow()
val hasPermissions: Flow<Boolean> = _hasPermissions.asStateFlow()
val autoSyncEnabled: Flow<Boolean> = _autoSync.asStateFlow()
val isCompatible: Flow<Boolean> = _isCompatible.asStateFlow()
companion object {
private const val TAG = "HealthConnectManager"
val REQUIRED_PERMISSIONS =
setOf(
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
HealthPermission.getWritePermission(ExerciseSessionRecord::class),
HealthPermission.getReadPermission(HeartRateRecord::class),
HealthPermission.getWritePermission(HeartRateRecord::class),
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class)
)
}
private val healthConnectClient by lazy {
try {
HealthConnectClient.getOrCreate(context)
} catch (e: Exception) {
Log.e(TAG, "Failed to create Health Connect client", e)
_isCompatible.value = false
null
}
}
/** Check if Health Connect is available on this device */
fun isHealthConnectAvailable(): Flow<Boolean> = flow {
try {
if (!_isCompatible.value) {
emit(false)
return@flow
}
val status = HealthConnectClient.getSdkStatus(context)
emit(status == HealthConnectClient.SDK_AVAILABLE)
} catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect availability", e)
_isCompatible.value = false
emit(false)
}
}
/** Enable or disable Health Connect integration */
fun setEnabled(enabled: Boolean) {
preferences.edit().putBoolean("enabled", enabled).apply()
_isEnabled.value = enabled
if (!enabled) {
setPermissionsGranted(false)
}
}
/** Update the permissions granted state */
fun setPermissionsGranted(granted: Boolean) {
preferences.edit().putBoolean("permissions", granted).apply()
_hasPermissions.value = granted
}
/** Enable or disable auto-sync */
fun setAutoSyncEnabled(enabled: Boolean) {
preferences.edit().putBoolean("auto_sync", enabled).apply()
_autoSync.value = enabled
}
/** Check if all required permissions are granted */
suspend fun hasAllPermissions(): Boolean {
return try {
if (!_isCompatible.value || healthConnectClient == null) {
return false
}
val grantedPermissions =
healthConnectClient!!.permissionController.getGrantedPermissions()
val hasAll =
REQUIRED_PERMISSIONS.all { permission ->
grantedPermissions.contains(permission)
}
setPermissionsGranted(hasAll)
hasAll
} catch (e: Exception) {
Log.e(TAG, "Error checking permissions", e)
setPermissionsGranted(false)
false
}
}
/** Check if Health Connect is ready for use */
suspend fun isReady(): Boolean {
return try {
if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null)
return false
val isAvailable =
HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
val hasPerms = if (isAvailable) hasAllPermissions() else false
isAvailable && hasPerms
} catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect readiness", e)
false
}
}
/** Get permission request contract */
fun getPermissionRequestContract(): ActivityResultContract<Set<String>, Set<String>> {
return PermissionController.createRequestPermissionResultContract()
}
/** Test Health Connect functionality */
fun testHealthConnectSync(): String {
val results = mutableListOf<String>()
results.add("=== Health Connect Debug Test ===")
try {
// Check availability synchronously
val packageManager = context.packageManager
val healthConnectPackages =
listOf(
"com.google.android.apps.healthdata",
"com.android.health.connect",
"androidx.health.connect"
)
val available =
healthConnectPackages.any { packageName ->
try {
packageManager.getPackageInfo(packageName, 0)
true
} catch (e: Exception) {
false
}
}
results.add("Available: $available")
// Check enabled state
results.add("Enabled in settings: ${_isEnabled.value}")
// Check permissions (simplified)
val hasPerms = _hasPermissions.value
results.add("Has permissions: $hasPerms")
// Check compatibility
results.add("API Compatible: ${_isCompatible.value}")
val ready = _isEnabled.value && _isCompatible.value && available && hasPerms
results.add("Ready to sync: $ready")
if (ready) {
results.add("Health Connect is connected!")
} else {
results.add("❌ Health Connect not ready")
if (!available) results.add("- Health Connect not available on device")
if (!_isEnabled.value) results.add("- Not enabled in Ascently settings")
if (!hasPerms) results.add("- Permissions not granted")
if (!_isCompatible.value) results.add("- API compatibility issues")
}
} catch (e: Exception) {
results.add("Test failed with error: ${e.message}")
Log.e(TAG, "Health Connect test failed", e)
}
return results.joinToString("\n")
}
/** Get required permissions as strings */
fun getRequiredPermissions(): Set<String> {
return try {
REQUIRED_PERMISSIONS.map { it.toString() }.toSet()
} catch (e: Exception) {
Log.e(TAG, "Error getting required permissions", e)
emptySet()
}
}
/** Sync a completed climbing session to Health Connect */
@SuppressLint("RestrictedApi")
suspend fun syncClimbingSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
): Result<Unit> {
return try {
if (!isReady()) {
return Result.failure(IllegalStateException("Health Connect not ready"))
}
if (session.status != SessionStatus.COMPLETED) {
return Result.failure(
IllegalArgumentException("Only completed sessions can be synced")
)
}
val startTime = session.startTime?.let { DateFormatUtils.parseISO8601(it) }
val endTime = session.endTime?.let { DateFormatUtils.parseISO8601(it) }
if (startTime == null || endTime == null) {
return Result.failure(
IllegalArgumentException("Session must have valid start and end times")
)
}
Log.d(TAG, "Attempting to sync session '${session.id}' to Health Connect...")
val records = mutableListOf<androidx.health.connect.client.records.Record>()
try {
val exerciseSession =
ExerciseSessionRecord(
startTime = startTime,
startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
exerciseType =
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
title = "Rock Climbing at $gymName"
)
records.add(exerciseSession)
} catch (e: Exception) {
Log.w(TAG, "Failed to create exercise session record", e)
}
try {
val durationMinutes = Duration.between(startTime, endTime).toMinutes()
val estimatedCalories = estimateCaloriesForClimbing(durationMinutes, attemptCount)
if (estimatedCalories > 0) {
val caloriesRecord =
TotalCaloriesBurnedRecord(
startTime = startTime,
startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(endTime),
energy = Energy.calories(estimatedCalories)
)
records.add(caloriesRecord)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to create calories record", e)
}
try {
val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount)
heartRateRecord?.let { records.add(it) }
} catch (e: Exception) {
Log.w(TAG, "Failed to create heart rate record", e)
}
if (records.isNotEmpty() && healthConnectClient != null) {
Log.d(TAG, "Writing ${records.size} records to Health Connect...")
healthConnectClient!!.insertRecords(records)
Log.i(
TAG,
"Successfully synced ${records.size} records for session '${session.id}' to Health Connect"
)
preferences
.edit()
.putString("last_sync_success", DateFormatUtils.nowISO8601())
.apply()
} else {
val reason =
when {
records.isEmpty() -> "No records created"
healthConnectClient == null -> "Health Connect client unavailable"
else -> "Unknown reason"
}
Log.w(TAG, "Sync failed for session '${session.id}': $reason")
return Result.failure(Exception("Sync failed: $reason"))
}
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Error syncing climbing session to Health Connect", e)
Result.failure(e)
}
}
/** Auto-sync a session if enabled */
suspend fun autoSyncSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
): Result<Unit> {
return if (_autoSync.value && isReady()) {
Log.d(TAG, "Auto-syncing session '${session.id}' to Health Connect...")
syncClimbingSession(session, gymName, attemptCount)
} else {
val reason =
when {
!_autoSync.value -> "auto-sync disabled"
!isReady() -> "Health Connect not ready"
else -> "unknown reason"
}
Log.d(TAG, "Auto-sync skipped for session '${session.id}': $reason")
Result.success(Unit)
}
}
/** Estimate calories burned during climbing */
private fun estimateCaloriesForClimbing(durationMinutes: Long, attemptCount: Int): Double {
val baseCaloriesPerMinute = 8.0
val intensityMultiplier =
when {
attemptCount >= 20 -> 1.3
attemptCount >= 10 -> 1.1
else -> 0.9
}
return durationMinutes * baseCaloriesPerMinute * intensityMultiplier
}
/** Create heart rate data */
@SuppressLint("RestrictedApi")
private fun createHeartRateRecord(
startTime: Instant,
endTime: Instant,
attemptCount: Int
): HeartRateRecord? {
return try {
val samples = mutableListOf<HeartRateRecord.Sample>()
val intervalMinutes = 5L
val baseHeartRate =
when {
attemptCount >= 20 -> 155L
attemptCount >= 10 -> 145L
else -> 135L
}
var currentTime = startTime
while (currentTime.isBefore(endTime)) {
val variation = (-15..15).random()
val heartRate = (baseHeartRate + variation).coerceIn(110L, 180L)
samples.add(HeartRateRecord.Sample(time = currentTime, beatsPerMinute = heartRate))
currentTime = currentTime.plusSeconds(intervalMinutes * 60)
}
if (samples.isEmpty()) return null
HeartRateRecord(
startTime = startTime,
startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
samples = samples
)
} catch (e: Exception) {
Log.e(TAG, "Error creating heart rate record", e)
null
}
}
/** Reset all preferences */
fun reset() {
preferences.edit().clear().apply()
_isEnabled.value = false
_hasPermissions.value = false
_autoSync.value = true
}
/** Check if ready for use */
fun isReadySync(): Boolean {
return _isEnabled.value && _hasPermissions.value
}
/** Get last successful sync timestamp */
fun getLastSyncSuccess(): String? {
return preferences.getString("last_sync_success", null)
}
/** Get detailed status */
fun getDetailedStatus(): Map<String, String> {
return mapOf(
"enabled" to _isEnabled.value.toString(),
"hasPermissions" to _hasPermissions.value.toString(),
"autoSync" to _autoSync.value.toString(),
"compatible" to _isCompatible.value.toString(),
"lastSyncSuccess" to (getLastSyncSuccess() ?: "never")
)
}
}

View File

@@ -1,10 +1,10 @@
package com.atridad.openclimb.data.model
package com.atridad.ascently.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable
@Serializable
@@ -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.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable
@Serializable

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.data.model
package com.atridad.ascently.data.model
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
@@ -207,7 +207,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB" && grade2 == "VB") return 0
if (grade1 == "VB") return 0
val num1 = grade1.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.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable
@Entity(tableName = "gyms")

View File

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

View File

@@ -1,27 +1,32 @@
package com.atridad.openclimb.data.repository
package com.atridad.ascently.data.repository
import android.content.Context
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym
import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.data.state.DataStateManager
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.openclimb.utils.ZipExportImportUtils
import android.content.SharedPreferences
import androidx.core.content.edit
import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.ascently.data.format.BackupClimbSession
import com.atridad.ascently.data.format.BackupGym
import com.atridad.ascently.data.format.BackupProblem
import com.atridad.ascently.data.format.ClimbDataBackup
import com.atridad.ascently.data.format.DeletedItem
import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.ZipExportImportUtils
import java.io.File
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
class ClimbRepository(database: AscentlyDatabase, private val context: Context) {
private val gymDao = database.gymDao()
private val problemDao = database.problemDao()
private val sessionDao = database.climbSessionDao()
private val attemptDao = database.attemptDao()
private val dataStateManager = DataStateManager(context)
private val deletionPreferences: SharedPreferences =
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
private var autoSyncCallback: (() -> Unit)? = null
@@ -45,6 +50,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
}
suspend fun deleteGym(gym: Gym) {
gymDao.deleteGym(gym)
trackDeletion(gym.id, "gym")
dataStateManager.updateDataState()
triggerAutoSync()
}
@@ -56,17 +62,15 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun insertProblem(problem: Problem) {
problemDao.insertProblem(problem)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun updateProblem(problem: Problem) {
problemDao.updateProblem(problem)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun deleteProblem(problem: Problem) {
problemDao.deleteProblem(problem)
trackDeletion(problem.id, "problem")
dataStateManager.updateDataState()
triggerAutoSync()
}
// Session operations
@@ -79,15 +83,22 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun insertSession(session: ClimbSession) {
sessionDao.insertSession(session)
dataStateManager.updateDataState()
triggerAutoSync()
// Only trigger sync for completed sessions
if (session.status != SessionStatus.ACTIVE) {
triggerAutoSync()
}
}
suspend fun updateSession(session: ClimbSession) {
sessionDao.updateSession(session)
dataStateManager.updateDataState()
triggerAutoSync()
// Only trigger sync for completed sessions
if (session.status != SessionStatus.ACTIVE) {
triggerAutoSync()
}
}
suspend fun deleteSession(session: ClimbSession) {
sessionDao.deleteSession(session)
trackDeletion(session.id, "session")
dataStateManager.updateDataState()
triggerAutoSync()
}
@@ -109,17 +120,15 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun insertAttempt(attempt: Attempt) {
attemptDao.insertAttempt(attempt)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun updateAttempt(attempt: Attempt) {
attemptDao.updateAttempt(attempt)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun deleteAttempt(attempt: Attempt) {
attemptDao.deleteAttempt(attempt)
trackDeletion(attempt.id, "attempt")
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
@@ -148,7 +157,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
.filter { imagePath ->
try {
val imageFile =
com.atridad.openclimb.utils.ImageUtils.getImageFile(
com.atridad.ascently.utils.ImageUtils.getImageFile(
context,
imagePath
)
@@ -258,6 +267,38 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
autoSyncCallback?.invoke()
}
private fun trackDeletion(itemId: String, itemType: String) {
val currentDeletions = getDeletedItems().toMutableList()
val newDeletion =
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
currentDeletions.add(newDeletion)
val json = json.encodeToString(newDeletion)
deletionPreferences.edit { putString("deleted_${itemId}", json) }
}
fun getDeletedItems(): List<DeletedItem> {
val deletions = mutableListOf<DeletedItem>()
val allPrefs = deletionPreferences.all
for ((key, value) in allPrefs) {
if (key.startsWith("deleted_") && value is String) {
try {
val deletion = json.decodeFromString<DeletedItem>(value)
deletions.add(deletion)
} catch (_: Exception) {
// Invalid deletion record, ignore
}
}
}
return deletions
}
fun clearDeletedItems() {
deletionPreferences.edit { clear() }
}
private fun validateDataIntegrity(
gyms: List<Gym>,
problems: List<Problem>,

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.SharedPreferences
import android.util.Log
import com.atridad.openclimb.utils.DateFormatUtils
import androidx.core.content.edit
import com.atridad.ascently.utils.DateFormatUtils
/**
* Manages the overall data state timestamp for sync purposes. This tracks when any data in the
@@ -13,7 +14,7 @@ class DataStateManager(context: Context) {
companion object {
private const val TAG = "DataStateManager"
private const val PREFS_NAME = "openclimb_data_state"
private const val PREFS_NAME = "ascently_data_state"
private const val KEY_LAST_MODIFIED = "last_modified_timestamp"
private const val KEY_INITIALIZED = "state_initialized"
}
@@ -35,7 +36,7 @@ class DataStateManager(context: Context) {
*/
fun updateDataState() {
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")
}
@@ -48,21 +49,6 @@ class DataStateManager(context: Context) {
?: 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. */
private fun isInitialized(): Boolean {
return prefs.getBoolean(KEY_INITIALIZED, false)
@@ -70,11 +56,6 @@ class DataStateManager(context: Context) {
/** Marks the data state as initialized. */
private fun markAsInitialized() {
prefs.edit().putBoolean(KEY_INITIALIZED, true).apply()
}
/** Gets debug information about the current state. */
fun getDebugInfo(): String {
return "DataState(lastModified=${getLastModified()}, initialized=${isInitialized()})"
prefs.edit { putBoolean(KEY_INITIALIZED, true) }
}
}

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.filled.*

View File

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

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.service
package com.atridad.ascently.service
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -8,10 +8,10 @@ import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.ascently.MainActivity
import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime
@@ -52,7 +52,7 @@ class SessionTrackingService : Service() {
override fun onCreate() {
super.onCreate()
val database = OpenClimbDatabase.getDatabase(this)
val database = AscentlyDatabase.getDatabase(this)
repository = ClimbRepository(database, this)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
@@ -75,8 +75,8 @@ class SessionTrackingService : Service() {
sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession()
}
if (targetSession != null && targetSession.status == com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
val completed = with(com.atridad.openclimb.data.model.ClimbSession) { targetSession.complete() }
if (targetSession != null && targetSession.status == com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
val completed = with(com.atridad.ascently.data.model.ClimbSession) { targetSession.complete() }
repository.updateSession(completed)
}
} finally {
@@ -88,11 +88,7 @@ class SessionTrackingService : Service() {
return START_REDELIVER_INTENT
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
}
override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) {
@@ -131,7 +127,7 @@ class SessionTrackingService : Service() {
}
val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
break
}
@@ -153,7 +149,7 @@ class SessionTrackingService : Service() {
return try {
val activeNotifications = notificationManager.activeNotifications
activeNotifications.any { it.id == NOTIFICATION_ID }
} catch (e: Exception) {
} catch (_: Exception) {
false
}
}
@@ -179,7 +175,7 @@ class SessionTrackingService : Service() {
val session = runBlocking {
repository.getSessionById(sessionId)
}
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
return
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -0,0 +1,285 @@
package com.atridad.ascently.ui.components
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PhotoLibrary
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.atridad.ascently.utils.ImageUtils
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun ImagePicker(
imageUris: List<String>,
onImagesChanged: (List<String>) -> Unit,
modifier: Modifier = Modifier,
maxImages: Int = 5
) {
val context = LocalContext.current
var tempImageUris by remember { mutableStateOf(imageUris) }
var showImageSourceDialog by remember { mutableStateOf(false) }
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
// Image picker launcher
val imagePickerLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris ->
if (uris.isNotEmpty()) {
val currentCount = tempImageUris.size
val remainingSlots = maxImages - currentCount
val urisToProcess = uris.take(remainingSlots)
// Process images
val newImagePaths = mutableListOf<String>()
urisToProcess.forEach { uri ->
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
if (imagePath != null) {
newImagePaths.add(imagePath)
}
}
if (newImagePaths.isNotEmpty()) {
val updatedUris = tempImageUris + newImagePaths
tempImageUris = updatedUris
onImagesChanged(updatedUris)
}
}
}
// Camera launcher
val cameraLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.TakePicture()) {
success ->
if (success) {
cameraImageUri?.let { uri ->
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
if (imagePath != null) {
val updatedUris = tempImageUris + imagePath
tempImageUris = updatedUris
onImagesChanged(updatedUris)
}
}
}
}
// Camera permission launcher
val cameraPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
// Create image file for camera
val imageFile = createImageFile(context)
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
imageFile
)
cameraImageUri = uri
cameraLauncher.launch(uri)
}
}
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Photos (${tempImageUris.size}/$maxImages)",
style = MaterialTheme.typography.titleMedium
)
if (tempImageUris.size < maxImages) {
TextButton(onClick = { showImageSourceDialog = true }) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Add Photos")
}
}
}
if (tempImageUris.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(tempImageUris) { imagePath ->
ImageItem(
imagePath = imagePath,
onRemove = {
val updatedUris = tempImageUris.filter { it != imagePath }
tempImageUris = updatedUris
onImagesChanged(updatedUris)
// Delete the image file
ImageUtils.deleteImage(context, imagePath)
}
)
}
}
} else {
Spacer(modifier = Modifier.height(8.dp))
Card(
modifier = Modifier.fillMaxWidth().height(100.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Add photos of this problem",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Image Source Selection Dialog
if (showImageSourceDialog) {
AlertDialog(
onDismissRequest = { showImageSourceDialog = false },
title = { Text("Add Photo") },
text = { Text("Choose how you'd like to add a photo") },
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
showImageSourceDialog = false
imagePickerLauncher.launch("image/*")
}
) {
Icon(
Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Gallery")
}
TextButton(
onClick = {
showImageSourceDialog = false
when (ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
)
) {
PackageManager.PERMISSION_GRANTED -> {
// Create image file for camera
val imageFile = createImageFile(context)
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
imageFile
)
cameraImageUri = uri
cameraLauncher.launch(uri)
}
else -> {
cameraPermissionLauncher.launch(
Manifest.permission.CAMERA
)
}
}
}
) {
Icon(
Icons.Default.CameraAlt,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Camera")
}
}
},
dismissButton = {
TextButton(onClick = { showImageSourceDialog = false }) { Text("Cancel") }
}
)
}
}
}
private fun createImageFile(context: android.content.Context): File {
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val imageFileName = "JPEG_${timeStamp}_"
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile(imageFileName, ".jpg", storageDir)
}
@Composable
private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) {
val context = LocalContext.current
val imageFile = ImageUtils.getImageFile(context, imagePath)
Box(modifier = modifier.size(80.dp)) {
OrientationAwareImage(
imagePath = imagePath,
contentDescription = "Problem photo",
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
IconButton(onClick = onRemove, modifier = Modifier.align(Alignment.TopEnd).size(24.dp)) {
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove photo",
modifier = Modifier.fillMaxSize().padding(2.dp),
tint = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}

View File

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

View File

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

View File

@@ -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.fadeIn

View File

@@ -0,0 +1,440 @@
package com.atridad.ascently.ui.health
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.ascently.data.health.HealthConnectManager
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HealthConnectCard(modifier: Modifier = Modifier) {
val context = LocalContext.current
val healthConnectManager = remember { HealthConnectManager(context) }
val coroutineScope = rememberCoroutineScope()
// State tracking
var isHealthConnectAvailable by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Collect flows
val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false)
val hasPermissions by healthConnectManager.hasPermissions.collectAsState(initial = false)
val autoSyncEnabled by healthConnectManager.autoSyncEnabled.collectAsState(initial = true)
val isCompatible by healthConnectManager.isCompatible.collectAsState(initial = true)
// Permission launcher
val permissionLauncher =
rememberLauncherForActivityResult(
contract = healthConnectManager.getPermissionRequestContract()
) { grantedPermissions ->
coroutineScope.launch {
val allGranted = healthConnectManager.hasAllPermissions()
if (!allGranted) {
errorMessage =
"Some Health Connect permissions were not granted. Please grant all permissions to enable syncing."
} else {
errorMessage = null
}
}
}
// Check Health Connect availability on first load
LaunchedEffect(Unit) {
coroutineScope.launch {
try {
healthConnectManager.isHealthConnectAvailable().collect { available ->
isHealthConnectAvailable = available
isLoading = false
if (!available && isCompatible) {
errorMessage = "Health Connect is not available on this device"
} else if (!isCompatible) {
errorMessage =
"Health Connect API compatibility issue. Please update your device or the app."
}
}
} catch (e: Exception) {
isLoading = false
errorMessage = "Error checking Health Connect availability: ${e.message}"
}
}
}
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header with icon and title
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.HealthAndSafety,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint =
if (isHealthConnectAvailable && isEnabled && hasPermissions) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Health Connect",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text =
when {
isLoading -> "Checking availability..."
!isCompatible -> "API Issue"
!isHealthConnectAvailable -> "Not available"
isEnabled && hasPermissions -> "Connected"
isEnabled && !hasPermissions -> "Needs permissions"
else -> "Disabled"
},
style = MaterialTheme.typography.bodySmall,
color =
when {
isLoading ->
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
!isCompatible -> MaterialTheme.colorScheme.error
!isHealthConnectAvailable -> MaterialTheme.colorScheme.error
isEnabled && hasPermissions ->
MaterialTheme.colorScheme.primary
isEnabled && !hasPermissions ->
MaterialTheme.colorScheme.tertiary
else ->
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
}
)
}
// Main toggle switch
Switch(
checked = isEnabled,
onCheckedChange = { enabled ->
if (enabled && isHealthConnectAvailable) {
healthConnectManager.setEnabled(true)
coroutineScope.launch {
try {
val permissionSet =
healthConnectManager.getRequiredPermissions()
if (permissionSet.isNotEmpty()) {
permissionLauncher.launch(permissionSet)
}
} catch (e: Exception) {
errorMessage = "Error requesting permissions: ${e.message}"
}
}
} else {
healthConnectManager.setEnabled(false)
errorMessage = null
}
},
enabled = isHealthConnectAvailable && !isLoading && isCompatible
)
}
if (isEnabled) {
Spacer(modifier = Modifier.height(16.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (hasPermissions) {
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.3f
)
} else {
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f
)
}
)
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector =
if (hasPermissions) Icons.Default.CheckCircle
else Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint =
if (hasPermissions) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
if (hasPermissions) "Ready to sync"
else "Permissions needed",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (!hasPermissions) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
"Grant Health Connect permissions to sync your climbing sessions",
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.8f
)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = {
coroutineScope.launch {
try {
val permissionSet =
healthConnectManager
.getRequiredPermissions()
if (permissionSet.isNotEmpty()) {
permissionLauncher.launch(permissionSet)
}
} catch (e: Exception) {
errorMessage =
"Error requesting permissions: ${e.message}"
}
}
},
modifier = Modifier.fillMaxWidth()
) { Text("Grant Permissions") }
}
}
}
if (hasPermissions) {
Spacer(modifier = Modifier.height(12.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Auto-sync sessions",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Automatically sync completed climbing sessions",
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
)
}
Switch(
checked = autoSyncEnabled,
onCheckedChange = { enabled ->
healthConnectManager.setAutoSyncEnabled(enabled)
}
)
}
}
}
} else {
Spacer(modifier = Modifier.height(16.dp))
Text(
text =
"Sync your climbing sessions to Samsung Health, Google Fit, and other fitness apps through Health Connect.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
)
}
errorMessage?.let { error ->
Spacer(modifier = Modifier.height(12.dp))
Card(
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.5f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
if (isEnabled) {
Spacer(modifier = Modifier.height(12.dp))
var testResult by remember { mutableStateOf<String?>(null) }
var isTestRunning by remember { mutableStateOf(false) }
OutlinedButton(
onClick = {
isTestRunning = true
coroutineScope.launch {
try {
testResult = healthConnectManager.testHealthConnectSync()
} catch (e: Exception) {
testResult = "Test failed: ${e.message}"
} finally {
isTestRunning = false
}
}
},
enabled = !isTestRunning,
modifier = Modifier.fillMaxWidth()
) {
if (isTestRunning) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(if (isTestRunning) "Testing..." else "Test Connection")
}
testResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.5f
)
)
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = "Debug Results:",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = result,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
}
}
}
}
}
}
@Composable
fun HealthConnectStatusBanner(isConnected: Boolean, modifier: Modifier = Modifier) {
if (isConnected) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.5f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.CloudDone,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Health Connect active - sessions will sync automatically",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.screens
package com.atridad.ascently.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -17,9 +17,9 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.ui.components.ImagePicker
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.data.model.*
import com.atridad.ascently.ui.components.ImagePicker
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import kotlinx.coroutines.flow.first
@@ -248,6 +248,7 @@ fun AddEditProblemScreen(
) {
val isEditing = problemId != null
val gyms by viewModel.gyms.collectAsState()
val context = LocalContext.current
// Problem form state
var selectedGym by remember {
@@ -387,10 +388,11 @@ fun AddEditProblemScreen(
if (isEditing) {
viewModel.updateProblem(
problem.copy(id = problemId!!)
problem.copy(id = problemId),
context
)
} else {
viewModel.addProblem(problem)
viewModel.addProblem(problem, context)
}
onNavigateBack()
}

View File

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

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.screens
package com.atridad.ascently.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Share
@@ -30,11 +31,11 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewModelScope
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplaySection
import com.atridad.openclimb.ui.theme.CustomIcons
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.data.model.*
import com.atridad.ascently.ui.components.FullscreenImageViewer
import com.atridad.ascently.ui.components.ImageDisplaySection
import com.atridad.ascently.ui.theme.CustomIcons
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.flow.first
@@ -260,6 +261,32 @@ fun SessionDetailScreen(
}
},
actions = {
if (session?.duration != null) {
val healthConnectManager = viewModel.getHealthConnectManager()
val isHealthConnectEnabled by
healthConnectManager.isEnabled.collectAsState(
initial = false
)
val hasPermissions by
healthConnectManager.hasPermissions.collectAsState(
initial = false
)
if (isHealthConnectEnabled && hasPermissions) {
IconButton(
onClick = {
viewModel.manualSyncToHealthConnect(sessionId)
}
) {
Icon(
imageVector = Icons.Default.HealthAndSafety,
contentDescription = "Sync to Health Connect",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
// Share button
if (session?.duration != null) { // Only show for completed sessions
IconButton(
@@ -537,7 +564,7 @@ fun SessionDetailScreen(
viewModel.addAttempt(attempt)
showAddAttemptDialog = false
},
onProblemCreated = { problem -> viewModel.addProblem(problem) }
onProblemCreated = { problem -> viewModel.addProblem(problem, context) }
)
}

View File

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

View File

@@ -1,33 +1,36 @@
package com.atridad.openclimb.ui.screens
package com.atridad.ascently.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.data.model.Problem
import com.atridad.openclimb.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplay
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.R
import com.atridad.ascently.data.model.Attempt
import com.atridad.ascently.data.model.AttemptResult
import com.atridad.ascently.data.model.ClimbType
import com.atridad.ascently.data.model.Gym
import com.atridad.ascently.data.model.Problem
import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState()
var showImageViewer by remember { mutableStateOf(false) }
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedImageIndex by remember { mutableIntStateOf(0) }
val attempts by viewModel.attempts.collectAsState()
val context = LocalContext.current
// Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
@@ -54,7 +57,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
contentDescription = "Ascently Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
@@ -176,15 +179,11 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
ProblemCard(
problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
attempts = attempts,
onClick = { onNavigateToProblemDetail(problem.id) },
onImageClick = { imagePaths, index ->
selectedImagePaths = imagePaths
selectedImageIndex = index
showImageViewer = true
},
onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem)
viewModel.updateProblem(updatedProblem, context)
}
)
Spacer(modifier = Modifier.height(8.dp))
@@ -192,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)
@@ -208,10 +198,17 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
fun ProblemCard(
problem: Problem,
gymName: String,
attempts: List<Attempt>,
onClick: () -> Unit,
onImageClick: ((List<String>, Int) -> 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()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(
@@ -240,12 +237,35 @@ fun ProblemCard(
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = problem.difficulty.grade,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
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 = problem.difficulty.grade,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = problem.climbType.getDisplayName(),
@@ -277,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) {
Spacer(modifier = Modifier.height(8.dp))
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.lazy.LazyColumn
@@ -16,12 +16,12 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus
import com.atridad.openclimb.ui.components.ActiveSessionBanner
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.R
import com.atridad.ascently.data.model.ClimbSession
import com.atridad.ascently.data.model.SessionStatus
import com.atridad.ascently.ui.components.ActiveSessionBanner
import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@@ -46,7 +46,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
contentDescription = "Ascently Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.ui.screens
package com.atridad.ascently.ui.screens
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -15,9 +15,10 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.R
import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.health.HealthConnectCard
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.io.File
import java.time.Instant
import kotlinx.coroutines.launch
@@ -37,12 +38,17 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
val isTesting by syncService.isTesting.collectAsState()
val lastSyncTime by syncService.lastSyncTime.collectAsState()
val syncError by syncService.syncError.collectAsState()
val isAutoSyncEnabled by syncService.isAutoSyncEnabled.collectAsState()
// State for dialogs
var showResetDialog by remember { mutableStateOf(false) }
var showSyncConfigDialog by remember { mutableStateOf(false) }
var showDisconnectDialog by remember { mutableStateOf(false) }
var showDeleteImagesDialog by remember { mutableStateOf(false) }
var isDeletingImages by remember { mutableStateOf(false) }
// Sync configuration state
var serverUrl by remember { mutableStateOf(syncService.serverURL) }
var authToken by remember { mutableStateOf(syncService.authToken) }
@@ -80,7 +86,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
// Only allow ZIP files
if (!fileName.lowercase().endsWith(".zip")) {
viewModel.setError(
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
)
return@let
}
@@ -123,7 +129,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
contentDescription = "Ascently Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
@@ -275,8 +281,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
}
Spacer(modifier = Modifier.width(16.dp))
Switch(
checked = syncService.isAutoSyncEnabled,
onCheckedChange = { syncService.isAutoSyncEnabled = it }
checked = isAutoSyncEnabled,
onCheckedChange = { enabled ->
syncService.setAutoSyncEnabled(enabled)
}
)
}
}
@@ -328,7 +336,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
ListItem(
headlineContent = { Text("Setup Sync") },
supportingContent = {
Text("Connect to your OpenClimb sync server")
Text("Connect to your Ascently sync server")
},
leadingContent = {
Icon(Icons.Default.CloudSync, contentDescription = null)
@@ -375,7 +383,8 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
}
}
// Data Management Section
item { HealthConnectCard() }
item {
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
@@ -412,7 +421,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
TextButton(
onClick = {
val defaultFileName =
"openclimb_export_${
"ascently_export_${
java.time.LocalDateTime.now()
.toString()
.replace(":", "-")
@@ -475,6 +484,48 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Delete All Images") },
supportingContent = {
Text("Permanently delete all image files from device")
},
leadingContent = {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
trailingContent = {
TextButton(
onClick = { showDeleteImagesDialog = true },
enabled = !isDeletingImages && !uiState.isLoading
) {
if (isDeletingImages) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}
}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
@@ -553,11 +604,11 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
painterResource(
id = R.drawable.ic_mountains
),
contentDescription = "OpenClimb Logo",
contentDescription = "Ascently Logo",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text("OpenClimb")
Text("Ascently")
}
},
supportingContent = { Text("Track your climbing progress") },
@@ -903,16 +954,43 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
confirmButton = {
TextButton(
onClick = {
syncService.clearConfiguration()
serverUrl = ""
authToken = ""
viewModel.syncService.clearConfiguration()
showDisconnectDialog = false
}
) { Text("Disconnect", color = MaterialTheme.colorScheme.error) }
) { Text("Disconnect") }
},
dismissButton = {
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
}
)
}
// Delete All Images dialog
if (showDeleteImagesDialog) {
AlertDialog(
onDismissRequest = { showDeleteImagesDialog = false },
title = { Text("Delete All Images") },
text = {
Text(
"This will permanently delete ALL image files from your device.\n\nProblems will keep their references but the actual image files will be removed. This cannot be undone.\n\nConsider exporting your data first if you want to keep your images."
)
},
confirmButton = {
TextButton(
onClick = {
isDeletingImages = true
showDeleteImagesDialog = false
coroutineScope.launch {
viewModel.deleteAllImages(context)
isDeletingImages = false
viewModel.setMessage("All images deleted successfully!")
}
}
) { Text("Delete", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showDeleteImagesDialog = false }) { Text("Cancel") }
}
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,31 @@
package com.atridad.openclimb.ui.viewmodel
package com.atridad.ascently.ui.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.sync.SyncService
import com.atridad.openclimb.service.SessionTrackingService
import com.atridad.openclimb.utils.ImageUtils
import com.atridad.openclimb.utils.SessionShareUtils
import com.atridad.openclimb.widget.ClimbStatsWidgetProvider
import com.atridad.ascently.data.health.HealthConnectManager
import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.sync.SyncService
import com.atridad.ascently.service.SessionTrackingService
import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils
import com.atridad.ascently.utils.SessionShareUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ClimbViewModel(private val repository: ClimbRepository, val syncService: SyncService) :
ViewModel() {
class ClimbViewModel(
private val repository: ClimbRepository,
val syncService: SyncService,
private val context: Context
) : ViewModel() {
// Health Connect manager
private val healthConnectManager = HealthConnectManager(context)
// UI State flows
private val _uiState = MutableStateFlow(ClimbUiState())
@@ -106,25 +114,41 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
// Problem operations
fun addProblem(problem: Problem) {
viewModelScope.launch { repository.insertProblem(problem) }
}
fun addProblem(problem: Problem, context: Context) {
viewModelScope.launch {
repository.insertProblem(problem)
val finalProblem = renameTemporaryImages(problem, context)
repository.insertProblem(finalProblem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
}
}
fun updateProblem(problem: Problem) {
viewModelScope.launch { repository.updateProblem(problem) }
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem {
if (problem.imagePaths.isEmpty()) {
return problem
}
val appContext = context ?: return problem
val finalImagePaths = mutableListOf<String>()
problem.imagePaths.forEachIndexed { index, tempPath ->
if (tempPath.startsWith("temp_")) {
val deterministicName = ImageNamingUtils.generateImageFilename(problem.id, index)
val finalPath =
ImageUtils.renameTemporaryImage(appContext, tempPath, problem.id, index)
finalImagePaths.add(finalPath ?: tempPath)
} else {
finalImagePaths.add(tempPath)
}
}
return problem.copy(imagePaths = finalImagePaths)
}
fun updateProblem(problem: Problem, context: Context) {
viewModelScope.launch {
repository.updateProblem(problem)
val finalProblem = renameTemporaryImages(problem, context)
repository.updateProblem(finalProblem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
@@ -148,6 +172,41 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
}
fun deleteAllImages(context: Context) {
viewModelScope.launch {
val imagesDir = ImageUtils.getImagesDirectory(context)
var deletedCount = 0
imagesDir.listFiles()?.forEach { file ->
if (file.isFile && file.extension.lowercase() == "jpg") {
if (file.delete()) {
deletedCount++
}
}
}
val allProblems = repository.getAllProblems().first()
val updatedProblems =
allProblems.map { problem ->
if (problem.imagePaths.isNotEmpty()) {
problem.copy(imagePaths = emptyList())
} else {
problem
}
}
for (updatedProblem in updatedProblems) {
if (updatedProblem.imagePaths !=
allProblems.find { it.id == updatedProblem.id }?.imagePaths
) {
repository.insertProblemWithoutSync(updatedProblem)
}
}
println("Deleted $deletedCount image files and cleared image references")
}
}
fun getProblemById(id: String): Flow<Problem?> = flow { emit(repository.getProblemById(id)) }
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
@@ -201,7 +260,7 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
viewModelScope.launch {
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context)
) {
android.util.Log.d("ClimbViewModel", "Notification permission not granted")
@@ -240,14 +299,13 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
ClimbStatsWidgetProvider.updateAllWidgets(context)
android.util.Log.d("ClimbViewModel", "Session started successfully")
_uiState.value = _uiState.value.copy(message = "Session started successfully!")
}
}
fun endSession(context: Context, sessionId: String) {
viewModelScope.launch {
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context)
) {
_uiState.value =
@@ -268,7 +326,7 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
syncToHealthConnect(completedSession)
_uiState.value = _uiState.value.copy(message = "Session completed!")
}
@@ -295,7 +353,6 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
viewModelScope.launch {
repository.insertAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
}
}
@@ -359,7 +416,7 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
if (!file.name.lowercase().endsWith(".zip")) {
throw Exception(
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
)
}
@@ -410,6 +467,10 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
_uiState.value = _uiState.value.copy(error = message)
}
fun setMessage(message: String) {
_uiState.value = _uiState.value.copy(message = message)
}
fun resetAllData() {
viewModelScope.launch {
try {
@@ -429,6 +490,90 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
}
}
private fun syncToHealthConnect(session: ClimbSession) {
viewModelScope.launch {
try {
val gym = repository.getGymById(session.gymId)
val gymName = gym?.name ?: "Unknown Gym"
val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size
val result = healthConnectManager.autoSyncSession(session, gymName, attemptCount)
result
.onSuccess {
_uiState.value =
_uiState.value.copy(
message =
"Session synced to Health Connect successfully!"
)
}
.onFailure { error ->
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(
error =
"Failed to sync to Health Connect: ${error.message}"
)
}
}
} catch (e: Exception) {
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
}
}
}
}
fun manualSyncToHealthConnect(sessionId: String) {
viewModelScope.launch {
try {
val session = repository.getSessionById(sessionId)
if (session == null) {
_uiState.value = _uiState.value.copy(error = "Session not found")
return@launch
}
if (session.status != SessionStatus.COMPLETED) {
_uiState.value =
_uiState.value.copy(error = "Only completed sessions can be synced")
return@launch
}
val gym = repository.getGymById(session.gymId)
val gymName = gym?.name ?: "Unknown Gym"
val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size
val result =
healthConnectManager.syncClimbingSession(session, gymName, attemptCount)
result
.onSuccess {
_uiState.value =
_uiState.value.copy(
message =
"Session synced to Health Connect successfully!"
)
}
.onFailure { error ->
_uiState.value =
_uiState.value.copy(
error =
"Failed to sync to Health Connect: ${error.message}"
)
}
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
}
}
}
fun getHealthConnectManager(): HealthConnectManager = healthConnectManager
// Share operations
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
withContext(Dispatchers.IO) {

View File

@@ -1,19 +1,21 @@
package com.atridad.openclimb.ui.viewmodel
package com.atridad.ascently.ui.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.sync.SyncService
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.sync.SyncService
class ClimbViewModelFactory(
private val repository: ClimbRepository,
private val syncService: SyncService
private val syncService: SyncService,
private val context: Context
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
return ClimbViewModel(repository, syncService) as T
return ClimbViewModel(repository, syncService, context) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}

View File

@@ -1,4 +1,4 @@
package com.atridad.openclimb.utils
package com.atridad.ascently.utils
import java.time.Instant
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.util.*
@@ -13,18 +13,16 @@ object ImageNamingUtils {
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
/** Generates a deterministic filename for a problem image */
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
// Create a deterministic hash from problemId + timestamp + index
val input = "${problemId}_${timestamp}_${imageIndex}"
fun generateImageFilename(problemId: String, imageIndex: Int): String {
val input = "${problemId}_${imageIndex}"
val hash = createHash(input)
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
}
/** Generates a deterministic filename using current timestamp */
fun generateImageFilename(problemId: String, imageIndex: Int): String {
val timestamp = DateFormatUtils.nowISO8601()
return generateImageFilename(problemId, timestamp, imageIndex)
/** Legacy method for backward compatibility */
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
return generateImageFilename(problemId, imageIndex)
}
/** Extracts problem ID from an image filename */
@@ -41,9 +39,7 @@ object ImageNamingUtils {
return null
}
// We can't extract the original problem ID from the hash,
// but we can validate the format
return parts[1] // Return the hash as identifier
return parts[1]
}
/** Validates if a filename follows our naming convention */
@@ -63,15 +59,11 @@ object ImageNamingUtils {
/** Migrates an existing filename to our naming convention */
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
// If it's already using our convention, keep it
if (isValidImageFilename(oldFilename)) {
return oldFilename
}
// Generate new deterministic name
// Use a timestamp based on the old filename to maintain some consistency
val timestamp = DateFormatUtils.nowISO8601()
return generateImageFilename(problemId, timestamp, imageIndex)
return generateImageFilename(problemId, imageIndex)
}
/** Creates a deterministic hash from input string */
@@ -90,7 +82,7 @@ object ImageNamingUtils {
val renameMap = mutableMapOf<String, String>()
existingFilenames.forEachIndexed { index, oldFilename ->
val newFilename = migrateFilename(oldFilename, problemId, index)
val newFilename = generateImageFilename(problemId, index)
if (newFilename != oldFilename) {
renameMap[oldFilename] = newFilename
}
@@ -98,4 +90,37 @@ object ImageNamingUtils {
return renameMap
}
/** Generates the canonical filename for a problem image */
fun getCanonicalImageFilename(problemId: String, imageIndex: Int): String {
return generateImageFilename(problemId, imageIndex)
}
/** Creates a mapping of existing server filenames to canonical filenames */
fun createServerMigrationMap(
problemId: String,
serverImageFilenames: List<String>,
localImageCount: Int
): Map<String, String> {
val migrationMap = mutableMapOf<String, String>()
for (imageIndex in 0 until localImageCount) {
val canonicalName = getCanonicalImageFilename(problemId, imageIndex)
if (serverImageFilenames.contains(canonicalName)) {
continue
}
for (serverFilename in serverImageFilenames) {
if (isValidImageFilename(serverFilename) &&
!migrationMap.values.contains(serverFilename)
) {
migrationMap[serverFilename] = canonicalName
break
}
}
}
return migrationMap
}
}

View File

@@ -1,11 +1,14 @@
package com.atridad.openclimb.utils
package com.atridad.ascently.utils
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
@@ -17,7 +20,7 @@ object ImageUtils {
private const val IMAGE_QUALITY = 85
// Creates the images directory if it doesn't exist
private fun getImagesDirectory(context: Context): File {
fun getImagesDirectory(context: Context): File {
val imagesDir = File(context.filesDir, IMAGES_DIR)
if (!imagesDir.exists()) {
imagesDir.mkdirs()
@@ -25,7 +28,57 @@ object ImageUtils {
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(
context: Context,
imageUri: Uri,
@@ -40,26 +93,18 @@ object ImageUtils {
}
?: return null
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap)
// Always require deterministic naming
require(problemId != null && imageIndex != null) {
"Problem ID and image index are required for deterministic image naming"
}
val filename =
if (problemId != null && imageIndex != null) {
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
} else {
"${UUID.randomUUID()}.jpg"
}
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val imageFile = File(getImagesDirectory(context), filename)
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle()
}
compressedBitmap.recycle()
if (!success) return null
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
@@ -73,35 +118,35 @@ object ImageUtils {
return try {
val inputStream = context.contentResolver.openInputStream(imageUri)
inputStream?.use { input ->
val exif = android.media.ExifInterface(input)
val exif = androidx.exifinterface.media.ExifInterface(input)
val orientation =
exif.getAttributeInt(
android.media.ExifInterface.TAG_ORIENTATION,
android.media.ExifInterface.ORIENTATION_NORMAL
androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL
)
val matrix = android.graphics.Matrix()
when (orientation) {
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
}
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
}
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
}
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
}
@@ -212,6 +257,62 @@ object ImageUtils {
}
}
/** Temporarily saves an image during selection process */
fun saveTemporaryImageFromUri(context: Context, imageUri: Uri): String? {
return try {
val originalBitmap =
MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri)
?: return null
val tempFilename = "temp_${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), tempFilename)
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
originalBitmap.recycle()
if (!success) return null
tempFilename
} catch (e: Exception) {
Log.e("ImageUtils", "Error saving temporary image from URI", e)
null
}
}
/** Renames a temporary image */
fun renameTemporaryImage(
context: Context,
tempFilename: String,
problemId: String,
imageIndex: Int
): String? {
return try {
val tempFile = File(getImagesDirectory(context), tempFilename)
if (!tempFile.exists()) {
Log.e("ImageUtils", "Temporary file does not exist: $tempFilename")
return null
}
val deterministicFilename =
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val finalFile = File(getImagesDirectory(context), deterministicFilename)
if (tempFile.renameTo(finalFile)) {
Log.d(
"ImageUtils",
"Renamed temporary image: $tempFilename -> $deterministicFilename"
)
deterministicFilename
} else {
Log.e("ImageUtils", "Failed to rename temporary image: $tempFilename")
null
}
} catch (e: Exception) {
Log.e("ImageUtils", "Error renaming temporary image", e)
null
}
}
/** Saves an image from byte array to app's private storage */
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
return try {
@@ -247,21 +348,40 @@ object ImageUtils {
filename: String
): String? {
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)
// Save compressed image
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// 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)
// Clean up bitmaps
bitmap.recycle()
compressedBitmap.recycle()
// Save compressed image
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// 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()
compressedBitmap.recycle()
} else {
// For smaller images, save raw data to preserve all EXIF information
FileOutputStream(imageFile).use { output -> output.write(imageData) }
}
// Return relative path
"$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.content.Context

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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,818 +0,0 @@
package com.atridad.openclimb.data.sync
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym
import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup
import com.atridad.openclimb.data.migration.ImageMigrationService
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.state.DataStateManager
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.openclimb.utils.ImageNamingUtils
import com.atridad.openclimb.utils.ImageUtils
import java.io.IOException
import java.time.Instant
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
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 migrationService = ImageMigrationService(context, repository)
private val dataStateManager = DataStateManager(context)
private val syncMutex = Mutex()
companion object {
private const val TAG = "SyncService"
}
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
private val httpClient =
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
coerceInputValues = true
}
// 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()
// Configuration keys
private object Keys {
const val SERVER_URL = "sync_server_url"
const val AUTH_TOKEN = "sync_auth_token"
const val LAST_SYNC_TIME = "last_sync_time"
const val IS_CONNECTED = "sync_is_connected"
const val AUTO_SYNC_ENABLED = "auto_sync_enabled"
}
// Configuration properties
var serverURL: String
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
updateConfiguredState()
// Clear connection status when configuration changes
_isConnected.value = false
sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply()
}
var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
updateConfiguredState()
// Clear connection status when configuration changes
_isConnected.value = false
sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply()
}
val isConfigured: Boolean
get() = serverURL.isNotEmpty() && authToken.isNotEmpty()
private fun updateConfiguredState() {
_isConfigured.value = serverURL.isNotEmpty() && authToken.isNotEmpty()
}
var isAutoSyncEnabled: Boolean
get() = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
set(value) {
sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, value).apply()
}
init {
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
updateConfiguredState()
repository.setAutoSyncCallback {
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
}
}
suspend fun downloadData(): ClimbDataBackup =
withContext(Dispatchers.IO) {
if (!isConfigured) {
throw SyncException.NotConfigured
}
val request =
Request.Builder()
.url("$serverURL/sync")
.get()
.addHeader("Authorization", "Bearer $authToken")
.addHeader("Accept", "application/json")
.build()
try {
val response = httpClient.newCall(request).execute()
when (response.code) {
200 -> {
val responseBody =
response.body?.string()
?: throw SyncException.InvalidResponse(
"Empty response body"
)
Log.d(TAG, "Downloaded data from server: ${responseBody.take(500)}...")
try {
val backup = json.decodeFromString<ClimbDataBackup>(responseBody)
Log.d(
TAG,
"Server backup contains: gyms=${backup.gyms.size}, problems=${backup.problems.size}, sessions=${backup.sessions.size}, attempts=${backup.attempts.size}"
)
backup.problems.forEach { problem ->
val imageCount = problem.imagePaths?.size ?: 0
if (imageCount > 0) {
Log.d(
TAG,
"Server problem '${problem.name}' has images: ${problem.imagePaths}"
)
}
}
backup
} catch (e: Exception) {
Log.e(TAG, "Failed to decode download response: ${e.message}")
throw SyncException.DecodingError(
e.message ?: "Failed to decode response"
)
}
}
401 -> throw SyncException.Unauthorized
else -> throw SyncException.ServerError(response.code)
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
suspend fun uploadData(backup: ClimbDataBackup): ClimbDataBackup =
withContext(Dispatchers.IO) {
if (!isConfigured) {
throw SyncException.NotConfigured
}
val jsonBody = json.encodeToString(backup)
Log.d(TAG, "Uploading JSON to server: $jsonBody")
val requestBody = jsonBody.toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverURL/sync")
.put(requestBody)
.addHeader("Authorization", "Bearer $authToken")
.addHeader("Content-Type", "application/json")
.build()
try {
val response = httpClient.newCall(request).execute()
Log.d(TAG, "Upload response code: ${response.code}")
when (response.code) {
200 -> {
val responseBody =
response.body?.string()
?: throw SyncException.InvalidResponse(
"Empty response body"
)
try {
json.decodeFromString<ClimbDataBackup>(responseBody)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode upload response: ${e.message}")
throw SyncException.DecodingError(
e.message ?: "Failed to decode response"
)
}
}
401 -> throw SyncException.Unauthorized
else -> {
val errorBody = response.body?.string() ?: "No error details"
Log.e(TAG, "Server error ${response.code}: $errorBody")
throw SyncException.ServerError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
suspend fun uploadImage(filename: String, imageData: ByteArray) =
withContext(Dispatchers.IO) {
if (!isConfigured) {
throw SyncException.NotConfigured
}
val justFilename = filename.substringAfterLast('/')
val requestBody = imageData.toRequestBody("image/*".toMediaType())
val request =
Request.Builder()
.url("$serverURL/images/upload?filename=$justFilename")
.post(requestBody)
.addHeader("Authorization", "Bearer $authToken")
.build()
try {
val response = httpClient.newCall(request).execute()
when (response.code) {
200 -> Unit
401 -> throw SyncException.Unauthorized
else -> {
val errorBody = response.body?.string() ?: "No error details"
Log.e(TAG, "Image upload error ${response.code}: $errorBody")
throw SyncException.ServerError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
suspend fun downloadImage(filename: String): ByteArray =
withContext(Dispatchers.IO) {
if (!isConfigured) {
throw SyncException.NotConfigured
}
Log.d(TAG, "Downloading image from server: $filename")
val request =
Request.Builder()
.url("$serverURL/images/download?filename=$filename")
.get()
.addHeader("Authorization", "Bearer $authToken")
.build()
try {
val response = httpClient.newCall(request).execute()
Log.d(TAG, "Image download response for $filename: ${response.code}")
when (response.code) {
200 -> {
val imageBytes =
response.body?.bytes()
?: throw SyncException.InvalidResponse(
"Empty image response"
)
Log.d(
TAG,
"Successfully downloaded image $filename: ${imageBytes.size} bytes"
)
imageBytes
}
401 -> throw SyncException.Unauthorized
404 -> {
Log.w(TAG, "Image not found on server: $filename")
throw SyncException.ImageNotFound(filename)
}
else -> {
val errorBody = response.body?.string() ?: "No error details"
Log.e(
TAG,
"Image download error ${response.code} for $filename: $errorBody"
)
throw SyncException.ServerError(response.code)
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error downloading image $filename: ${e.message}")
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
suspend fun syncWithServer() {
if (!isConfigured) {
throw SyncException.NotConfigured
}
if (!_isConnected.value) {
throw SyncException.NotConnected
}
syncMutex.withLock {
_isSyncing.value = true
_syncError.value = null
try {
Log.d(TAG, "Fixing existing image paths before sync")
val pathFixSuccess = fixImagePaths()
if (!pathFixSuccess) {
Log.w(TAG, "Image path fix failed, but continuing with sync")
}
Log.d(TAG, "Performing image migration before sync")
val migrationSuccess = migrateImagesForSync()
if (!migrationSuccess) {
Log.w(TAG, "Image migration failed, but continuing with sync")
}
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 -> {
val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt)
val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt)
Log.d(
TAG,
"Comparing timestamps: local=$localTimestamp, server=$serverTimestamp"
)
if (localTimestamp > serverTimestamp) {
Log.d(TAG, "Local data is newer, replacing server content")
uploadData(localBackup)
syncImagesForBackup(localBackup)
Log.d(TAG, "Server replaced with local data")
} else if (serverTimestamp > localTimestamp) {
Log.d(TAG, "Server data is newer, replacing local content")
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Local data replaced with server data")
} else {
Log.d(TAG, "Data is in sync (timestamps equal), no action needed")
}
}
else -> {
Log.d(TAG, "No data to sync")
}
}
val now = DateFormatUtils.nowISO8601()
_lastSyncTime.value = now
sharedPreferences.edit().putString(Keys.LAST_SYNC_TIME, now).apply()
} catch (e: Exception) {
_syncError.value = e.message
throw e
} finally {
_isSyncing.value = false
}
}
}
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> {
val imagePathMapping = mutableMapOf<String, String>()
Log.d(TAG, "Starting to download images from server")
var totalImages = 0
var downloadedImages = 0
var failedImages = 0
for (problem in backup.problems) {
val imageCount = problem.imagePaths?.size ?: 0
if (imageCount > 0) {
Log.d(
TAG,
"Problem '${problem.name}' has $imageCount images: ${problem.imagePaths}"
)
totalImages += imageCount
}
problem.imagePaths?.forEachIndexed { index, imagePath ->
try {
Log.d(TAG, "Attempting to download image: $imagePath")
val imageData = downloadImage(imagePath)
val serverFilename = imagePath.substringAfterLast('/')
val consistentFilename =
if (ImageNamingUtils.isValidImageFilename(serverFilename)) {
serverFilename
} else {
ImageNamingUtils.generateImageFilename(problem.id, index)
}
val localImagePath =
ImageUtils.saveImageFromBytesWithFilename(
context,
imageData,
consistentFilename
)
if (localImagePath != null) {
imagePathMapping[serverFilename] = localImagePath
downloadedImages++
Log.d(
TAG,
"Downloaded and mapped image: $serverFilename -> $localImagePath"
)
} else {
Log.w(TAG, "Failed to save downloaded image locally: $imagePath")
failedImages++
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
failedImages++
}
}
}
Log.d(
TAG,
"Image download completed: $downloadedImages downloaded, $failedImages failed, $totalImages total"
)
return imagePathMapping
}
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems")
var totalImages = 0
var uploadedImages = 0
var failedImages = 0
for (problem in backup.problems) {
val imageCount = problem.imagePaths?.size ?: 0
totalImages += imageCount
Log.d(TAG, "Problem '${problem.name}' has $imageCount images: ${problem.imagePaths}")
problem.imagePaths?.forEachIndexed { index, imagePath ->
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
Log.d(TAG, "Checking image file: $imagePath -> ${imageFile.absolutePath}")
Log.d(
TAG,
"Image file exists: ${imageFile.exists()}, size: ${if (imageFile.exists()) imageFile.length() else 0} bytes"
)
if (imageFile.exists() && imageFile.length() > 0) {
val imageData = imageFile.readBytes()
val filename = imagePath.substringAfterLast('/')
val consistentFilename =
if (ImageNamingUtils.isValidImageFilename(filename)) {
filename
} else {
val newFilename =
ImageNamingUtils.generateImageFilename(
problem.id,
index
)
val newFile = java.io.File(imageFile.parent, newFilename)
if (imageFile.renameTo(newFile)) {
Log.d(
TAG,
"Renamed local image file: $filename -> $newFilename"
)
newFilename
} else {
Log.w(
TAG,
"Failed to rename local image file, using original"
)
filename
}
}
Log.d(TAG, "Uploading image: $consistentFilename (${imageData.size} bytes)")
uploadImage(consistentFilename, imageData)
uploadedImages++
Log.d(TAG, "Successfully uploaded image: $consistentFilename")
} else {
Log.w(
TAG,
"Image file not found or empty: $imagePath at ${imageFile.absolutePath}"
)
failedImages++
}
} catch (e: Exception) {
Log.e(TAG, "Failed to upload image $imagePath: ${e.message}", e)
failedImages++
}
}
}
Log.d(
TAG,
"Image sync completed: $uploadedImages uploaded, $failedImages failed, $totalImages total"
)
}
private suspend fun createBackupFromRepository(): ClimbDataBackup {
val allGyms = repository.getAllGyms().first()
val allProblems = repository.getAllProblems().first()
val allSessions = repository.getAllSessions().first()
val allAttempts = repository.getAllAttempts().first()
return ClimbDataBackup(
exportedAt = dataStateManager.getLastModified(),
version = "2.0",
formatVersion = "2.0",
gyms = allGyms.map { BackupGym.fromGym(it) },
problems = allProblems.map { BackupProblem.fromProblem(it) },
sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
)
}
private suspend fun importBackupToRepository(
backup: ClimbDataBackup,
imagePathMapping: Map<String, String> = emptyMap()
) {
repository.resetAllData()
backup.gyms.forEach { backupGym ->
try {
val gym = backupGym.toGym()
Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})")
repository.insertGymWithoutSync(gym)
} catch (e: Exception) {
Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}")
throw e
}
}
backup.problems.forEach { backupProblem ->
try {
val updatedProblem =
if (imagePathMapping.isNotEmpty()) {
val newImagePaths =
backupProblem.imagePaths?.map { oldPath ->
val filename = oldPath.substringAfterLast('/')
imagePathMapping[filename]
?: if (ImageNamingUtils.isValidImageFilename(
filename
)
) {
"problem_images/$filename"
} else {
val index =
backupProblem.imagePaths.indexOf(
oldPath
)
val consistentFilename =
ImageNamingUtils.generateImageFilename(
backupProblem.id,
index
)
"problem_images/$consistentFilename"
}
}
?: emptyList()
backupProblem.withUpdatedImagePaths(newImagePaths)
} else {
backupProblem
}
repository.insertProblemWithoutSync(updatedProblem.toProblem())
} catch (e: Exception) {
Log.e(TAG, "Failed to import problem '${backupProblem.name}': ${e.message}")
}
}
backup.sessions.forEach { backupSession ->
try {
repository.insertSessionWithoutSync(backupSession.toClimbSession())
} catch (e: Exception) {
Log.e(TAG, "Failed to import session '${backupSession.id}': ${e.message}")
}
}
backup.attempts.forEach { backupAttempt ->
try {
repository.insertAttemptWithoutSync(backupAttempt.toAttempt())
} catch (e: Exception) {
Log.e(TAG, "Failed to import attempt '${backupAttempt.id}': ${e.message}")
}
}
dataStateManager.setLastModified(backup.exportedAt)
Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}")
}
/** Parses ISO8601 timestamp to milliseconds for comparison */
private fun parseISO8601ToMillis(timestamp: String): Long {
return try {
Instant.parse(timestamp).toEpochMilli()
} catch (e: Exception) {
Log.w(TAG, "Failed to parse timestamp: $timestamp, using 0", e)
0L
}
}
/**
* Fixes existing image paths in the database to include the proper directory structure. This
* corrects paths like "problem_abc_0.jpg" to "problem_images/problem_abc_0.jpg"
*/
suspend fun fixImagePaths(): Boolean {
return try {
Log.d(TAG, "Fixing existing image paths in database")
val allProblems = repository.getAllProblems().first()
var fixedCount = 0
for (problem in allProblems) {
if (problem.imagePaths.isNotEmpty()) {
val originalPaths = problem.imagePaths
val fixedPaths =
problem.imagePaths.map { path ->
if (!path.startsWith("problem_images/") && !path.contains("/")) {
val fixedPath = "problem_images/$path"
Log.d(TAG, "Fixed path: $path -> $fixedPath")
fixedCount++
fixedPath
} else {
path
}
}
if (originalPaths != fixedPaths) {
val updatedProblem = problem.copy(imagePaths = fixedPaths)
repository.insertProblem(updatedProblem)
}
}
}
Log.i(TAG, "Fixed $fixedCount image paths in database")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to fix image paths: ${e.message}", e)
false
}
}
/**
* Performs image migration to ensure all images use consistent naming convention before sync
* operations. This should be called before any sync to avoid filename conflicts.
*/
suspend fun migrateImagesForSync(): Boolean {
return try {
Log.d(TAG, "Starting image migration for sync compatibility")
val result = migrationService.performFullMigration()
when (result) {
is com.atridad.openclimb.data.migration.ImageMigrationResult.AlreadyCompleted -> {
Log.d(TAG, "Image migration already completed")
true
}
is com.atridad.openclimb.data.migration.ImageMigrationResult.Success -> {
Log.i(
TAG,
"Image migration completed: ${result.totalMigrated} images migrated, ${result.errors} errors"
)
true
}
is com.atridad.openclimb.data.migration.ImageMigrationResult.Failed -> {
Log.e(TAG, "Image migration failed: ${result.error}")
false
}
}
} catch (e: Exception) {
Log.e(TAG, "Image migration error: ${e.message}", e)
false
}
}
suspend fun testConnection() {
if (!isConfigured) {
throw SyncException.NotConfigured
}
_isTesting.value = true
_syncError.value = null
try {
withContext(Dispatchers.IO) {
val request =
Request.Builder()
.url("$serverURL/sync")
.get()
.addHeader("Authorization", "Bearer $authToken")
.addHeader("Accept", "application/json")
.build()
val response = httpClient.newCall(request).execute()
when (response.code) {
200 -> {
_isConnected.value = true
sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, true).apply()
}
401 -> throw SyncException.Unauthorized
else -> throw SyncException.ServerError(response.code)
}
}
} catch (e: Exception) {
_isConnected.value = false
sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply()
_syncError.value = e.message
throw e
} finally {
_isTesting.value = false
}
}
suspend fun triggerAutoSync() {
if (!isConfigured || !_isConnected.value || !isAutoSyncEnabled) {
return
}
if (_isSyncing.value) {
Log.d(TAG, "Sync already in progress, skipping auto-sync")
return
}
try {
syncWithServer()
} catch (e: Exception) {
Log.e(TAG, "Auto-sync failed: ${e.message}")
_syncError.value = e.message
}
}
fun clearConfiguration() {
serverURL = ""
authToken = ""
isAutoSyncEnabled = true
_lastSyncTime.value = null
_isConnected.value = false
_syncError.value = null
sharedPreferences.edit().clear().apply()
updateConfiguredState()
}
}
sealed class SyncException(message: String) : Exception(message) {
object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.")
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
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 ImageNotFound(val filename: String) : SyncException("Image not found: $filename")
data class NetworkError(val details: String) : SyncException("Network error: $details")
}

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,182 +0,0 @@
package com.atridad.openclimb.ui.components
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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.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 ImagePicker(
imageUris: List<String>,
onImagesChanged: (List<String>) -> Unit,
modifier: Modifier = Modifier,
maxImages: Int = 5
) {
val context = LocalContext.current
var tempImageUris by remember { mutableStateOf(imageUris) }
// Image picker launcher
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris ->
if (uris.isNotEmpty()) {
val currentCount = tempImageUris.size
val remainingSlots = maxImages - currentCount
val urisToProcess = uris.take(remainingSlots)
// Process images
val newImagePaths = mutableListOf<String>()
urisToProcess.forEach { uri ->
val imagePath = ImageUtils.saveImageFromUri(context, uri)
if (imagePath != null) {
newImagePaths.add(imagePath)
}
}
if (newImagePaths.isNotEmpty()) {
val updatedUris = tempImageUris + newImagePaths
tempImageUris = updatedUris
onImagesChanged(updatedUris)
}
}
}
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Photos (${tempImageUris.size}/$maxImages)",
style = MaterialTheme.typography.titleMedium
)
if (tempImageUris.size < maxImages) {
TextButton(
onClick = {
imagePickerLauncher.launch("image/*")
}
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Add Photos")
}
}
}
if (tempImageUris.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tempImageUris) { imagePath ->
ImageItem(
imagePath = imagePath,
onRemove = {
val updatedUris = tempImageUris.filter { it != imagePath }
tempImageUris = updatedUris
onImagesChanged(updatedUris)
// Delete the image file
ImageUtils.deleteImage(context, imagePath)
}
)
}
}
} else {
Spacer(modifier = Modifier.height(8.dp))
Card(
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Add photos of this problem",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
@Composable
private fun ImageItem(
imagePath: String,
onRemove: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val imageFile = ImageUtils.getImageFile(context, imagePath)
Box(
modifier = modifier.size(80.dp)
) {
AsyncImage(
model = imageFile,
contentDescription = "Problem photo",
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
IconButton(
onClick = onRemove,
modifier = Modifier
.align(Alignment.TopEnd)
.size(24.dp)
) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove photo",
modifier = Modifier
.fillMaxSize()
.padding(2.dp),
tint = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}

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_height="wrap_content"
android:layout_weight="1"
android:text="OpenClimb"
android:text="Ascently"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary" />

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
package com.atridad.openclimb
package com.atridad.ascently
import com.atridad.openclimb.data.format.*
import com.atridad.openclimb.data.model.*
import com.atridad.ascently.data.format.*
import com.atridad.ascently.data.model.*
import org.junit.Assert.*
import org.junit.Test
@@ -454,4 +454,61 @@ class SyncMergeLogicTest {
dateString1 > dateString2
}
}
@Test
fun `test active sessions excluded from sync`() {
// Test scenario: Active sessions should not be included in sync data
// This tests the new behavior where active sessions are excluded from sync
// until they are completed
val allLocalSessions =
listOf(
BackupClimbSession(
id = "active_session_1",
gymId = "gym1",
date = "2024-01-01",
startTime = "2024-01-01T10:00:00",
endTime = null,
duration = null,
status = SessionStatus.ACTIVE,
notes = null,
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T10:00:00"
),
BackupClimbSession(
id = "completed_session_1",
gymId = "gym1",
date = "2023-12-31",
startTime = "2023-12-31T15:00:00",
endTime = "2023-12-31T17:00:00",
duration = 7200000,
status = SessionStatus.COMPLETED,
notes = "Previous session",
createdAt = "2023-12-31T15:00:00",
updatedAt = "2023-12-31T17:00:00"
)
)
// Simulate filtering that would happen in createBackupFromRepository
val sessionsForSync = allLocalSessions.filter { it.status != SessionStatus.ACTIVE }
// Only completed sessions should be included in sync
assertEquals("Should only include completed sessions in sync", 1, sessionsForSync.size)
// Active session should be excluded
assertFalse(
"Should not contain active session in sync",
sessionsForSync.any {
it.id == "active_session_1" && it.status == SessionStatus.ACTIVE
}
)
// Completed session should be included
assertTrue(
"Should contain completed session in sync",
sessionsForSync.any {
it.id == "completed_session_1" && it.status == SessionStatus.COMPLETED
}
)
}
}

View File

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

View File

@@ -11,14 +11,15 @@ androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2025.09.01"
room = "2.8.1"
composeBom = "2025.10.00"
room = "2.8.2"
navigation = "2.9.5"
viewmodel = "2.9.4"
kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2"
coil = "2.7.0"
ksp = "2.2.20-2.0.3"
exifinterface = "1.3.6"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -66,6 +67,7 @@ mockk = { group = "io.mockk", name = "mockk", version = "1.14.6" }
# Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

View File

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

View File

@@ -20,7 +20,7 @@
containerPortal = D24C19602E75002A0045894C /* Project object */;
proxyType = 1;
remoteGlobalIDString = D24C19672E75002A0045894C;
remoteInfo = OpenClimb;
remoteInfo = Ascently;
};
D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
@@ -46,9 +46,9 @@
/* End PBXCopyFilesBuildPhase 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>"; };
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; };
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; };
@@ -56,12 +56,12 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
D28C3C8B2E75111D00F7AEE9 /* Exceptions for "OpenClimb" folder in "OpenClimb" target */ = {
D28C3C8B2E75111D00F7AEE9 /* Exceptions for "Ascently" folder in "Ascently" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = D24C19672E75002A0045894C /* OpenClimb */;
target = D24C19672E75002A0045894C /* Ascently */;
};
D2FE94A42E78FEE1008CDB25 /* Exceptions for "SessionStatusLive" folder in "SessionStatusLiveExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
@@ -73,17 +73,17 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
D24C196A2E75002A0045894C /* OpenClimb */ = {
D24C196A2E75002A0045894C /* Ascently */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
D28C3C8B2E75111D00F7AEE9 /* Exceptions for "OpenClimb" folder in "OpenClimb" target */,
D28C3C8B2E75111D00F7AEE9 /* Exceptions for "Ascently" folder in "Ascently" target */,
);
path = OpenClimb;
path = Ascently;
sourceTree = "<group>";
};
D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */ = {
D2F32FAE2E90B26500B1BC56 /* AscentlyTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = OpenClimbTests;
path = AscentlyTests;
sourceTree = "<group>";
};
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */ = {
@@ -129,9 +129,9 @@
isa = PBXGroup;
children = (
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */,
D24C196A2E75002A0045894C /* OpenClimb */,
D24C196A2E75002A0045894C /* Ascently */,
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */,
D2F32FAE2E90B26500B1BC56 /* AscentlyTests */,
D2FE947F2E78E958008CDB25 /* Frameworks */,
D24C19692E75002A0045894C /* Products */,
);
@@ -140,9 +140,9 @@
D24C19692E75002A0045894C /* Products */ = {
isa = PBXGroup;
children = (
D24C19682E75002A0045894C /* OpenClimb.app */,
D24C19682E75002A0045894C /* Ascently.app */,
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */,
D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */,
D2F32FAD2E90B26500B1BC56 /* AscentlyTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -160,9 +160,9 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
D24C19672E75002A0045894C /* OpenClimb */ = {
D24C19672E75002A0045894C /* Ascently */ = {
isa = PBXNativeTarget;
buildConfigurationList = D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "OpenClimb" */;
buildConfigurationList = D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "Ascently" */;
buildPhases = (
D24C19642E75002A0045894C /* Sources */,
D24C19652E75002A0045894C /* Frameworks */,
@@ -175,18 +175,18 @@
D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
D24C196A2E75002A0045894C /* OpenClimb */,
D24C196A2E75002A0045894C /* Ascently */,
);
name = OpenClimb;
name = Ascently;
packageProductDependencies = (
);
productName = OpenClimb;
productReference = D24C19682E75002A0045894C /* OpenClimb.app */;
productName = Ascently;
productReference = D24C19682E75002A0045894C /* Ascently.app */;
productType = "com.apple.product-type.application";
};
D2F32FAC2E90B26500B1BC56 /* OpenClimbTests */ = {
D2F32FAC2E90B26500B1BC56 /* AscentlyTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "OpenClimbTests" */;
buildConfigurationList = D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "AscentlyTests" */;
buildPhases = (
D2F32FA92E90B26500B1BC56 /* Sources */,
D2F32FAA2E90B26500B1BC56 /* Frameworks */,
@@ -198,13 +198,13 @@
D2F32FB22E90B26500B1BC56 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */,
D2F32FAE2E90B26500B1BC56 /* AscentlyTests */,
);
name = OpenClimbTests;
name = AscentlyTests;
packageProductDependencies = (
);
productName = OpenClimbTests;
productReference = D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */;
productName = AscentlyTests;
productReference = D2F32FAD2E90B26500B1BC56 /* AscentlyTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */ = {
@@ -251,7 +251,7 @@
};
};
};
buildConfigurationList = D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */;
buildConfigurationList = D24C19632E75002A0045894C /* Build configuration list for PBXProject "Ascently" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
@@ -265,9 +265,9 @@
projectDirPath = "";
projectRoot = "";
targets = (
D24C19672E75002A0045894C /* OpenClimb */,
D24C19672E75002A0045894C /* Ascently */,
D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */,
D2F32FAC2E90B26500B1BC56 /* OpenClimbTests */,
D2F32FAC2E90B26500B1BC56 /* AscentlyTests */,
);
};
/* End PBXProject section */
@@ -323,7 +323,7 @@
/* Begin PBXTargetDependency section */
D2F32FB22E90B26500B1BC56 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D24C19672E75002A0045894C /* OpenClimb */;
target = D24C19672E75002A0045894C /* Ascently */;
targetProxy = D2F32FB12E90B26500B1BC56 /* PBXContainerItemProxy */;
};
D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */ = {
@@ -462,19 +462,20 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
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_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenClimb/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenClimb;
INFOPLIST_FILE = Ascently/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ascently;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "OpenClimb 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_NSCameraUsageDescription = "Ascently needs camera access to take photos of climbing problems.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Ascently needs access to your photo library to save and display climbing problem images.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -485,8 +486,9 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -495,8 +497,11 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
TVOS_DEPLOYMENT_TARGET = 18.6;
WATCHOS_DEPLOYMENT_TARGET = 11.6;
XROS_DEPLOYMENT_TARGET = 2.6;
};
name = Debug;
};
@@ -505,19 +510,20 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
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_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenClimb/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenClimb;
INFOPLIST_FILE = Ascently/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ascently;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "OpenClimb 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_NSCameraUsageDescription = "Ascently needs camera access to take photos of climbing problems.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Ascently needs access to your photo library to save and display climbing problem images.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -528,8 +534,9 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -538,8 +545,11 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
TVOS_DEPLOYMENT_TARGET = 18.6;
WATCHOS_DEPLOYMENT_TARGET = 11.6;
XROS_DEPLOYMENT_TARGET = 2.6;
};
name = Release;
};
@@ -552,7 +562,7 @@
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
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)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -560,7 +570,7 @@
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
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;
};
@@ -573,7 +583,7 @@
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
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)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -581,7 +591,7 @@
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
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;
};
@@ -592,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -603,8 +613,8 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -622,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -633,8 +643,8 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -648,7 +658,7 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */ = {
D24C19632E75002A0045894C /* Build configuration list for PBXProject "Ascently" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D24C19712E75002A0045894C /* Debug */,
@@ -657,7 +667,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "OpenClimb" */ = {
D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "Ascently" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D24C19742E75002A0045894C /* Debug */,
@@ -666,7 +676,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "OpenClimbTests" */ = {
D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "AscentlyTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D2F32FB32E90B26500B1BC56 /* Debug */,

View File

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

View File

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

View File

@@ -4,7 +4,12 @@
<dict>
<key>SchemeUserState</key>
<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>
<key>orderHint</key>
<integer>1</integer>

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

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

After

Width:  |  Height:  |  Size: 913 B

View File

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

After

Width:  |  Height:  |  Size: 878 B

View File

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

After

Width:  |  Height:  |  Size: 981 B

View File

@@ -0,0 +1,56 @@
{
"images": [
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "1x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "1x"
},
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "2x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "3x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

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