Compare commits
11 Commits
IOS_1.3.0
...
SYNC_2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
d5cf14d466
|
|||
|
09b4055985
|
|||
|
30d2b3938e
|
|||
|
405fb06d5d
|
|||
|
77f8110d85
|
|||
|
53fa74cc83
|
|||
|
e7c46634da
|
|||
|
40efd6636f
|
|||
|
719181aa16
|
|||
|
790b7075c5
|
|||
|
ad8723b8fe
|
6
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
15
README.md
@@ -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
@@ -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.
|
||||
@@ -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 = 35
|
||||
versionName = "1.8.0"
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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() }
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.atridad.openclimb.data.model
|
||||
package com.atridad.ascently.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
@@ -1,27 +1,25 @@
|
||||
package com.atridad.openclimb.data.repository
|
||||
package com.atridad.ascently.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||
import com.atridad.openclimb.data.format.BackupAttempt
|
||||
import com.atridad.openclimb.data.format.BackupClimbSession
|
||||
import com.atridad.openclimb.data.format.BackupGym
|
||||
import com.atridad.openclimb.data.format.BackupProblem
|
||||
import com.atridad.openclimb.data.format.ClimbDataBackup
|
||||
import com.atridad.openclimb.data.format.DeletedItem
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.data.state.DataStateManager
|
||||
import com.atridad.openclimb.utils.DateFormatUtils
|
||||
import com.atridad.openclimb.utils.ZipExportImportUtils
|
||||
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||
import com.atridad.ascently.data.format.BackupAttempt
|
||||
import com.atridad.ascently.data.format.BackupClimbSession
|
||||
import com.atridad.ascently.data.format.BackupGym
|
||||
import com.atridad.ascently.data.format.BackupProblem
|
||||
import com.atridad.ascently.data.format.ClimbDataBackup
|
||||
import com.atridad.ascently.data.format.DeletedItem
|
||||
import com.atridad.ascently.data.model.*
|
||||
import com.atridad.ascently.data.state.DataStateManager
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import com.atridad.ascently.utils.ZipExportImportUtils
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
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()
|
||||
@@ -159,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
|
||||
)
|
||||
@@ -288,7 +286,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
try {
|
||||
val deletion = json.decodeFromString<DeletedItem>(value)
|
||||
deletions.add(deletion)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// Invalid deletion record, ignore
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.*
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.atridad.openclimb.navigation
|
||||
package com.atridad.ascently.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.atridad.openclimb.ui.components
|
||||
package com.atridad.ascently.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
@@ -25,8 +25,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import coil.compose.AsyncImage
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
import com.atridad.ascently.utils.ImageUtils
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -56,7 +55,7 @@ fun ImagePicker(
|
||||
// Process images
|
||||
val newImagePaths = mutableListOf<String>()
|
||||
urisToProcess.forEach { uri ->
|
||||
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
||||
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
|
||||
if (imagePath != null) {
|
||||
newImagePaths.add(imagePath)
|
||||
}
|
||||
@@ -76,7 +75,7 @@ fun ImagePicker(
|
||||
success ->
|
||||
if (success) {
|
||||
cameraImageUri?.let { uri ->
|
||||
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
||||
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
|
||||
if (imagePath != null) {
|
||||
val updatedUris = tempImageUris + imagePath
|
||||
tempImageUris = updatedUris
|
||||
@@ -259,8 +258,8 @@ private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifie
|
||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||
|
||||
Box(modifier = modifier.size(80.dp)) {
|
||||
AsyncImage(
|
||||
model = imageFile,
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Problem photo",
|
||||
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
@@ -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
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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(
|
||||
@@ -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
|
||||
)
|
||||
@@ -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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.atridad.openclimb.ui.theme
|
||||
package com.atridad.ascently.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.atridad.openclimb.utils
|
||||
package com.atridad.ascently.utils
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.atridad.openclimb.utils
|
||||
package com.atridad.ascently.utils
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.atridad.openclimb
|
||||
package com.atridad.ascently
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@@ -19,6 +19,7 @@ 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" }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = 17;
|
||||
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.3.0;
|
||||
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 = 17;
|
||||
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.3.0;
|
||||
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 = 17;
|
||||
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.3.0;
|
||||
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 = 17;
|
||||
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.3.0;
|
||||
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 */,
|
||||
BIN
ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,8 +1,7 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OpenClimbApp: App {
|
||||
struct AscentlyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
56
ios/Ascently/Assets.xcassets/AppLogo.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
ios/Ascently/Assets.xcassets/AppLogo.imageset/app_logo_256.png
vendored
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
ios/Ascently/Assets.xcassets/AppLogo.imageset/app_logo_256_dark.png
vendored
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
39
ios/Ascently/Components/AsyncImageView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||