Compare commits
20 Commits
IOS_1.2.5
...
5a49b9f0b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
5a49b9f0b2
|
|||
| 7601e7bb03 | |||
| 082cb79630 | |||
| 79b87a088d | |||
| 1a8f41ecde | |||
|
175dad8342
|
|||
|
4559d5e2f2
|
|||
|
d57149d29c
|
|||
|
d5cf14d466
|
|||
|
09b4055985
|
|||
|
30d2b3938e
|
|||
|
405fb06d5d
|
|||
|
77f8110d85
|
|||
|
53fa74cc83
|
|||
|
e7c46634da
|
|||
|
40efd6636f
|
|||
|
719181aa16
|
|||
|
790b7075c5
|
|||
|
ad8723b8fe
|
|||
|
6a39d23f28
|
38
.github/workflows/deploy_docs.yml
vendored
Normal file
38
.github/workflows/deploy_docs.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Ascently - Docs Deploy
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths: ["docs/**"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths: ["docs/**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.REPO_HOST }}
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./docs
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-docs:${{ github.sha }}
|
||||||
|
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-docs:latest
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: OpenClimb Docker Deploy
|
name: Ascently - Sync Deploy
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
@@ -27,12 +27,12 @@ jobs:
|
|||||||
password: ${{ secrets.DEPLOY_TOKEN }}
|
password: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push sync-server
|
- name: Build and push sync-server
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./sync
|
context: ./sync
|
||||||
file: ./sync/Dockerfile
|
file: ./sync/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:${{ github.sha }}
|
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-sync:${{ github.sha }}
|
||||||
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:latest
|
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-sync:latest
|
||||||
21
PRIVACY.md
21
PRIVACY.md
@@ -1,21 +0,0 @@
|
|||||||
# Privacy Policy
|
|
||||||
|
|
||||||
**Last updated: September 29, 2025**
|
|
||||||
|
|
||||||
This Privacy Policy describes our policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
|
|
||||||
|
|
||||||
## No Data Collection
|
|
||||||
|
|
||||||
I do not collect any personal information, analytics, or data of any kind. This software is designed to be self-hosted or run entirely offline.
|
|
||||||
|
|
||||||
All data generated by or used with this software remains on your local machine or self-hosted environment under your control. I have no access to it.
|
|
||||||
|
|
||||||
## No Tracking or Analytics
|
|
||||||
|
|
||||||
This software does not use cookies, tracking pixels, or any other analytics or tracking mechanisms. Your usage of the software is completely private.
|
|
||||||
|
|
||||||
## Contact Us
|
|
||||||
|
|
||||||
If you have any questions about this Privacy Policy, you can contact me:
|
|
||||||
|
|
||||||
* **By email:** me@atri.dad
|
|
||||||
39
README.md
39
README.md
@@ -1,41 +1,12 @@
|
|||||||
# OpenClimb
|
# Ascently
|
||||||
|
|
||||||
This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS.
|
_Formerly OpenClimb_
|
||||||
|
|
||||||
## Download
|
Ascently is an **offline-first FOSS** app designed to help climbers track their sessions, routes/problems, and overall progress. There is an optional self-hosted sync server and integrations with Apple Health and Health Connect. There are no analytics or tracking baked into any part of this project. I am committed to maintaining a transparent and open-source solution for climbers, ensuring that you have full control over your data and privacy.
|
||||||
|
|
||||||
For Android do one of the following:
|
## Documentation
|
||||||
|
|
||||||
1. Download the latest APK from the Releases page
|
Documentation can be found at [https://ascently.atri.dad](https://ascently.atri.dad)!
|
||||||
2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
|
|
||||||
|
|
||||||
For iOS:
|
|
||||||
|
|
||||||
Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
|
|
||||||
|
|
||||||
## Self-Hosted Sync Server
|
|
||||||
|
|
||||||
You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up using Docker.
|
|
||||||
|
|
||||||
### Quick Start with Docker Compose
|
|
||||||
|
|
||||||
1. Create a `.env` file with your configuration:
|
|
||||||
```
|
|
||||||
IMAGE=git.atri.dad/atridad/openclimb-sync:latest
|
|
||||||
APP_PORT=8080
|
|
||||||
AUTH_TOKEN=your-secure-auth-token-here
|
|
||||||
DATA_FILE=/data/openclimb.json
|
|
||||||
IMAGES_DIR=/data/images
|
|
||||||
ROOT_DIR=./openclimb-data
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Use the provided `docker-compose.yml` in the `sync/` directory:
|
|
||||||
```bash
|
|
||||||
cd sync/
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
The server will be available at `http://localhost:8080`. Configure your clients with your server URL and auth token to start syncing.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
20
android/README.md
Normal file
20
android/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 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.
|
||||||
@@ -9,15 +9,15 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.atridad.openclimb"
|
namespace = "com.atridad.ascently"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.ascently"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 33
|
versionCode = 41
|
||||||
versionName = "1.7.4"
|
versionName = "2.0.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,7 @@ dependencies {
|
|||||||
// Room Database
|
// Room Database
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
|
implementation(libs.androidx.exifinterface)
|
||||||
|
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
@@ -78,6 +79,9 @@ dependencies {
|
|||||||
// Image Loading
|
// Image Loading
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
// Health Connect
|
||||||
|
implementation("androidx.health.connect:connect-client:1.1.0-alpha07")
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.mockk)
|
testImplementation(libs.mockk)
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.atridad.openclimb"
|
namespace = "com.atridad.ascently"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.ascently"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 27
|
versionCode = 27
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
|
|
||||||
<!-- Permission for sync functionality -->
|
<!-- Permission for sync functionality -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Health Connect permissions -->
|
||||||
|
<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 -->
|
<!-- Hardware features -->
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -27,19 +50,29 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.OpenClimb"
|
android:theme="@style/Theme.Ascently"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.OpenClimb.Splash">
|
android:theme="@style/Theme.Ascently.Splash">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb
|
package com.atridad.ascently
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -9,8 +9,9 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.atridad.openclimb.ui.OpenClimbApp
|
import com.atridad.ascently.ui.AscentlyApp
|
||||||
import com.atridad.openclimb.ui.theme.OpenClimbTheme
|
import com.atridad.ascently.ui.theme.AscentlyTheme
|
||||||
|
import com.atridad.ascently.utils.MigrationManager
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private var shortcutAction by mutableStateOf<String?>(null)
|
private var shortcutAction by mutableStateOf<String?>(null)
|
||||||
@@ -23,16 +24,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setTheme(R.style.Theme_OpenClimb)
|
setTheme(R.style.Theme_Ascently)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// Perform migration from OpenClimb to Ascently if needed
|
||||||
|
MigrationManager(this).migrateIfNeeded()
|
||||||
|
|
||||||
shortcutAction = intent?.action
|
shortcutAction = intent?.action
|
||||||
lastUsedGymId = intent?.getStringExtra("LAST_USED_GYM_ID")
|
lastUsedGymId = intent?.getStringExtra("LAST_USED_GYM_ID")
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
OpenClimbTheme {
|
AscentlyTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
OpenClimbApp(
|
AscentlyApp(
|
||||||
shortcutAction = shortcutAction,
|
shortcutAction = shortcutAction,
|
||||||
lastUsedGymId = lastUsedGymId,
|
lastUsedGymId = lastUsedGymId,
|
||||||
onShortcutActionProcessed = { clearShortcutAction() }
|
onShortcutActionProcessed = { clearShortcutAction() }
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.atridad.openclimb.data.database
|
package com.atridad.ascently.data.database
|
||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.data.database
|
package com.atridad.ascently.data.database
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
@@ -7,8 +7,8 @@ import androidx.room.RoomDatabase
|
|||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import androidx.room.migration.Migration
|
import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.atridad.openclimb.data.database.dao.*
|
import com.atridad.ascently.data.database.dao.*
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [Gym::class, Problem::class, ClimbSession::class, Attempt::class],
|
entities = [Gym::class, Problem::class, ClimbSession::class, Attempt::class],
|
||||||
@@ -16,7 +16,7 @@ import com.atridad.openclimb.data.model.*
|
|||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
abstract class OpenClimbDatabase : RoomDatabase() {
|
abstract class AscentlyDatabase : RoomDatabase() {
|
||||||
|
|
||||||
abstract fun gymDao(): GymDao
|
abstract fun gymDao(): GymDao
|
||||||
abstract fun problemDao(): ProblemDao
|
abstract fun problemDao(): ProblemDao
|
||||||
@@ -24,7 +24,7 @@ abstract class OpenClimbDatabase : RoomDatabase() {
|
|||||||
abstract fun attemptDao(): AttemptDao
|
abstract fun attemptDao(): AttemptDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile private var INSTANCE: OpenClimbDatabase? = null
|
@Volatile private var INSTANCE: AscentlyDatabase? = null
|
||||||
|
|
||||||
val MIGRATION_4_5 =
|
val MIGRATION_4_5 =
|
||||||
object : Migration(4, 5) {
|
object : Migration(4, 5) {
|
||||||
@@ -84,14 +84,14 @@ abstract class OpenClimbDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDatabase(context: Context): OpenClimbDatabase {
|
fun getDatabase(context: Context): AscentlyDatabase {
|
||||||
return INSTANCE
|
return INSTANCE
|
||||||
?: synchronized(this) {
|
?: synchronized(this) {
|
||||||
val instance =
|
val instance =
|
||||||
Room.databaseBuilder(
|
Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
OpenClimbDatabase::class.java,
|
AscentlyDatabase::class.java,
|
||||||
"openclimb_database"
|
"ascently_database"
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
|
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
|
||||||
.enableMultiInstanceInvalidation()
|
.enableMultiInstanceInvalidation()
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.atridad.openclimb.data.database.dao
|
package com.atridad.ascently.data.database.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import com.atridad.openclimb.data.model.Attempt
|
import com.atridad.ascently.data.model.Attempt
|
||||||
import com.atridad.openclimb.data.model.AttemptResult
|
import com.atridad.ascently.data.model.AttemptResult
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.atridad.openclimb.data.database.dao
|
package com.atridad.ascently.data.database.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import com.atridad.openclimb.data.model.ClimbSession
|
import com.atridad.ascently.data.model.ClimbSession
|
||||||
import com.atridad.openclimb.data.model.SessionStatus
|
import com.atridad.ascently.data.model.SessionStatus
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.atridad.openclimb.data.database.dao
|
package com.atridad.ascently.data.database.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import com.atridad.openclimb.data.model.ClimbType
|
import com.atridad.ascently.data.model.ClimbType
|
||||||
import com.atridad.openclimb.data.model.Gym
|
import com.atridad.ascently.data.model.Gym
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.atridad.openclimb.data.database.dao
|
package com.atridad.ascently.data.database.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import com.atridad.openclimb.data.model.ClimbType
|
import com.atridad.ascently.data.model.ClimbType
|
||||||
import com.atridad.openclimb.data.model.Problem
|
import com.atridad.ascently.data.model.Problem
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.atridad.openclimb.data.format
|
package com.atridad.ascently.data.format
|
||||||
|
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
// Root structure for OpenClimb backup data
|
// Root structure for Ascently backup data
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ClimbDataBackup(
|
data class ClimbDataBackup(
|
||||||
val exportedAt: String,
|
val exportedAt: String,
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
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 and automatically request permissions if
|
||||||
|
* enabling
|
||||||
|
*/
|
||||||
|
suspend fun setEnabled(enabled: Boolean) {
|
||||||
|
preferences.edit().putBoolean("enabled", enabled).apply()
|
||||||
|
_isEnabled.value = enabled
|
||||||
|
|
||||||
|
if (enabled && _isCompatible.value) {
|
||||||
|
// Automatically request permissions when enabling
|
||||||
|
try {
|
||||||
|
val alreadyHasPermissions = hasAllPermissions()
|
||||||
|
if (!alreadyHasPermissions) {
|
||||||
|
Log.d(TAG, "Health Connect enabled - permissions will be requested by UI")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error checking permissions when enabling Health Connect", e)
|
||||||
|
}
|
||||||
|
} else if (!enabled) {
|
||||||
|
setPermissionsGranted(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (only when auto-sync is enabled) */
|
||||||
|
@SuppressLint("RestrictedApi")
|
||||||
|
suspend fun syncCompletedSession(
|
||||||
|
session: ClimbSession,
|
||||||
|
gymName: String,
|
||||||
|
attemptCount: Int = 0
|
||||||
|
): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
if (!isReady() || !_autoSync.value) {
|
||||||
|
return Result.failure(
|
||||||
|
IllegalStateException("Health Connect not ready or auto-sync disabled")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 completed session if enabled - this is the only way to sync sessions */
|
||||||
|
suspend fun autoSyncCompletedSession(
|
||||||
|
session: ClimbSession,
|
||||||
|
gymName: String,
|
||||||
|
attemptCount: Int = 0
|
||||||
|
): Result<Unit> {
|
||||||
|
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
|
||||||
|
Log.d(TAG, "Auto-syncing completed session '${session.id}' to Health Connect...")
|
||||||
|
syncCompletedSession(session, gymName, attemptCount)
|
||||||
|
} else {
|
||||||
|
val reason =
|
||||||
|
when {
|
||||||
|
session.status != SessionStatus.COMPLETED -> "session not completed"
|
||||||
|
!_autoSync.value -> "auto-sync disabled"
|
||||||
|
!isReady() -> "Health Connect not ready"
|
||||||
|
else -> "unknown reason"
|
||||||
|
}
|
||||||
|
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.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.atridad.openclimb.utils.DateFormatUtils
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -12,7 +12,19 @@ enum class AttemptResult {
|
|||||||
SUCCESS,
|
SUCCESS,
|
||||||
FALL,
|
FALL,
|
||||||
NO_PROGRESS,
|
NO_PROGRESS,
|
||||||
FLASH,
|
FLASH;
|
||||||
|
|
||||||
|
val displayName: String
|
||||||
|
get() =
|
||||||
|
when (this) {
|
||||||
|
SUCCESS -> "Success"
|
||||||
|
FALL -> "Fall"
|
||||||
|
NO_PROGRESS -> "No Progress"
|
||||||
|
FLASH -> "Flash"
|
||||||
|
}
|
||||||
|
|
||||||
|
val isSuccessful: Boolean
|
||||||
|
get() = this == SUCCESS || this == FLASH
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -74,26 +86,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,17 +1,25 @@
|
|||||||
package com.atridad.openclimb.data.model
|
package com.atridad.ascently.data.model
|
||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.atridad.openclimb.utils.DateFormatUtils
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class SessionStatus {
|
enum class SessionStatus {
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
COMPLETED,
|
COMPLETED,
|
||||||
PAUSED
|
PAUSED;
|
||||||
|
|
||||||
|
val displayName: String
|
||||||
|
get() =
|
||||||
|
when (this) {
|
||||||
|
ACTIVE -> "Active"
|
||||||
|
COMPLETED -> "Completed"
|
||||||
|
PAUSED -> "Paused"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.atridad.ascently.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ClimbType {
|
||||||
|
ROPE,
|
||||||
|
BOULDER;
|
||||||
|
|
||||||
|
val displayName: String
|
||||||
|
get() =
|
||||||
|
when (this) {
|
||||||
|
ROPE -> "Rope"
|
||||||
|
BOULDER -> "Bouldering"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package com.atridad.ascently.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DifficultySystem {
|
||||||
|
// Bouldering
|
||||||
|
V_SCALE,
|
||||||
|
FONT,
|
||||||
|
|
||||||
|
// Rope
|
||||||
|
YDS,
|
||||||
|
CUSTOM;
|
||||||
|
|
||||||
|
val displayName: String
|
||||||
|
get() =
|
||||||
|
when (this) {
|
||||||
|
V_SCALE -> "V Scale"
|
||||||
|
FONT -> "Font Scale"
|
||||||
|
YDS -> "YDS (Yosemite)"
|
||||||
|
CUSTOM -> "Custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
val isBoulderingSystem: Boolean
|
||||||
|
get() =
|
||||||
|
when (this) {
|
||||||
|
V_SCALE, FONT -> true
|
||||||
|
YDS -> false
|
||||||
|
CUSTOM -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
val isRopeSystem: Boolean
|
||||||
|
get() =
|
||||||
|
when (this) {
|
||||||
|
YDS -> true
|
||||||
|
V_SCALE, FONT -> false
|
||||||
|
CUSTOM -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
val availableGrades: List<String>
|
||||||
|
get() =
|
||||||
|
when (this) {
|
||||||
|
V_SCALE ->
|
||||||
|
listOf(
|
||||||
|
"VB",
|
||||||
|
"V0",
|
||||||
|
"V1",
|
||||||
|
"V2",
|
||||||
|
"V3",
|
||||||
|
"V4",
|
||||||
|
"V5",
|
||||||
|
"V6",
|
||||||
|
"V7",
|
||||||
|
"V8",
|
||||||
|
"V9",
|
||||||
|
"V10",
|
||||||
|
"V11",
|
||||||
|
"V12",
|
||||||
|
"V13",
|
||||||
|
"V14",
|
||||||
|
"V15",
|
||||||
|
"V16",
|
||||||
|
"V17"
|
||||||
|
)
|
||||||
|
FONT ->
|
||||||
|
listOf(
|
||||||
|
"3",
|
||||||
|
"4A",
|
||||||
|
"4B",
|
||||||
|
"4C",
|
||||||
|
"5A",
|
||||||
|
"5B",
|
||||||
|
"5C",
|
||||||
|
"6A",
|
||||||
|
"6A+",
|
||||||
|
"6B",
|
||||||
|
"6B+",
|
||||||
|
"6C",
|
||||||
|
"6C+",
|
||||||
|
"7A",
|
||||||
|
"7A+",
|
||||||
|
"7B",
|
||||||
|
"7B+",
|
||||||
|
"7C",
|
||||||
|
"7C+",
|
||||||
|
"8A",
|
||||||
|
"8A+",
|
||||||
|
"8B",
|
||||||
|
"8B+",
|
||||||
|
"8C",
|
||||||
|
"8C+"
|
||||||
|
)
|
||||||
|
YDS ->
|
||||||
|
listOf(
|
||||||
|
"5.0",
|
||||||
|
"5.1",
|
||||||
|
"5.2",
|
||||||
|
"5.3",
|
||||||
|
"5.4",
|
||||||
|
"5.5",
|
||||||
|
"5.6",
|
||||||
|
"5.7",
|
||||||
|
"5.8",
|
||||||
|
"5.9",
|
||||||
|
"5.10a",
|
||||||
|
"5.10b",
|
||||||
|
"5.10c",
|
||||||
|
"5.10d",
|
||||||
|
"5.11a",
|
||||||
|
"5.11b",
|
||||||
|
"5.11c",
|
||||||
|
"5.11d",
|
||||||
|
"5.12a",
|
||||||
|
"5.12b",
|
||||||
|
"5.12c",
|
||||||
|
"5.12d",
|
||||||
|
"5.13a",
|
||||||
|
"5.13b",
|
||||||
|
"5.13c",
|
||||||
|
"5.13d",
|
||||||
|
"5.14a",
|
||||||
|
"5.14b",
|
||||||
|
"5.14c",
|
||||||
|
"5.14d",
|
||||||
|
"5.15a",
|
||||||
|
"5.15b",
|
||||||
|
"5.15c",
|
||||||
|
"5.15d"
|
||||||
|
)
|
||||||
|
CUSTOM -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun systemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
|
||||||
|
when (climbType) {
|
||||||
|
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem }
|
||||||
|
ClimbType.ROPE -> entries.filter { it.isRopeSystem }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DifficultyGrade(val system: DifficultySystem, val grade: String, val numericValue: Int) {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
system: DifficultySystem,
|
||||||
|
grade: String
|
||||||
|
) : this(system = system, grade = grade, numericValue = calculateNumericValue(system, grade))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private fun calculateNumericValue(system: DifficultySystem, grade: String): Int {
|
||||||
|
return when (system) {
|
||||||
|
DifficultySystem.V_SCALE -> {
|
||||||
|
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
DifficultySystem.FONT -> {
|
||||||
|
val fontMapping: Map<String, Int> =
|
||||||
|
mapOf(
|
||||||
|
"3" to 3,
|
||||||
|
"4A" to 4,
|
||||||
|
"4B" to 5,
|
||||||
|
"4C" to 6,
|
||||||
|
"5A" to 7,
|
||||||
|
"5B" to 8,
|
||||||
|
"5C" to 9,
|
||||||
|
"6A" to 10,
|
||||||
|
"6A+" to 11,
|
||||||
|
"6B" to 12,
|
||||||
|
"6B+" to 13,
|
||||||
|
"6C" to 14,
|
||||||
|
"6C+" to 15,
|
||||||
|
"7A" to 16,
|
||||||
|
"7A+" to 17,
|
||||||
|
"7B" to 18,
|
||||||
|
"7B+" to 19,
|
||||||
|
"7C" to 20,
|
||||||
|
"7C+" to 21,
|
||||||
|
"8A" to 22,
|
||||||
|
"8A+" to 23,
|
||||||
|
"8B" to 24,
|
||||||
|
"8B+" to 25,
|
||||||
|
"8C" to 26,
|
||||||
|
"8C+" to 27
|
||||||
|
)
|
||||||
|
fontMapping[grade] ?: 0
|
||||||
|
}
|
||||||
|
DifficultySystem.YDS -> {
|
||||||
|
val ydsMapping: Map<String, Int> =
|
||||||
|
mapOf(
|
||||||
|
"5.0" to 50,
|
||||||
|
"5.1" to 51,
|
||||||
|
"5.2" to 52,
|
||||||
|
"5.3" to 53,
|
||||||
|
"5.4" to 54,
|
||||||
|
"5.5" to 55,
|
||||||
|
"5.6" to 56,
|
||||||
|
"5.7" to 57,
|
||||||
|
"5.8" to 58,
|
||||||
|
"5.9" to 59,
|
||||||
|
"5.10a" to 60,
|
||||||
|
"5.10b" to 61,
|
||||||
|
"5.10c" to 62,
|
||||||
|
"5.10d" to 63,
|
||||||
|
"5.11a" to 64,
|
||||||
|
"5.11b" to 65,
|
||||||
|
"5.11c" to 66,
|
||||||
|
"5.11d" to 67,
|
||||||
|
"5.12a" to 68,
|
||||||
|
"5.12b" to 69,
|
||||||
|
"5.12c" to 70,
|
||||||
|
"5.12d" to 71,
|
||||||
|
"5.13a" to 72,
|
||||||
|
"5.13b" to 73,
|
||||||
|
"5.13c" to 74,
|
||||||
|
"5.13d" to 75,
|
||||||
|
"5.14a" to 76,
|
||||||
|
"5.14b" to 77,
|
||||||
|
"5.14c" to 78,
|
||||||
|
"5.14d" to 79,
|
||||||
|
"5.15a" to 80,
|
||||||
|
"5.15b" to 81,
|
||||||
|
"5.15c" to 82,
|
||||||
|
"5.15d" to 83
|
||||||
|
)
|
||||||
|
ydsMapping[grade] ?: 0
|
||||||
|
}
|
||||||
|
DifficultySystem.CUSTOM -> grade.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Compare this grade with another grade of the same system Returns negative if this grade is
|
||||||
|
* easier, positive if harder, 0 if equal
|
||||||
|
*/
|
||||||
|
fun compareTo(other: DifficultyGrade): Int {
|
||||||
|
if (system != other.system) return 0
|
||||||
|
|
||||||
|
return when (system) {
|
||||||
|
DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade)
|
||||||
|
DifficultySystem.FONT -> compareFontGrades(grade, other.grade)
|
||||||
|
DifficultySystem.YDS -> compareYDSGrades(grade, other.grade)
|
||||||
|
DifficultySystem.CUSTOM -> grade.compareTo(other.grade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
|
||||||
|
if (grade1 == "VB" && grade2 != "VB") return -1
|
||||||
|
if (grade2 == "VB" && grade1 != "VB") return 1
|
||||||
|
if (grade1 == "VB") return 0
|
||||||
|
|
||||||
|
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
|
||||||
|
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
|
||||||
|
return num1.compareTo(num2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareFontGrades(grade1: String, grade2: String): Int {
|
||||||
|
return grade1.compareTo(grade2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareYDSGrades(grade1: String, grade2: String): Int {
|
||||||
|
return grade1.compareTo(grade2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.atridad.openclimb.data.model
|
package com.atridad.ascently.data.model
|
||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.atridad.openclimb.utils.DateFormatUtils
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Entity(tableName = "gyms")
|
@Entity(tableName = "gyms")
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.atridad.openclimb.data.model
|
package com.atridad.ascently.data.model
|
||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.atridad.openclimb.utils.DateFormatUtils
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
package com.atridad.openclimb.data.repository
|
package com.atridad.ascently.data.repository
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||||
import com.atridad.openclimb.data.format.BackupAttempt
|
import com.atridad.ascently.data.format.BackupAttempt
|
||||||
import com.atridad.openclimb.data.format.BackupClimbSession
|
import com.atridad.ascently.data.format.BackupClimbSession
|
||||||
import com.atridad.openclimb.data.format.BackupGym
|
import com.atridad.ascently.data.format.BackupGym
|
||||||
import com.atridad.openclimb.data.format.BackupProblem
|
import com.atridad.ascently.data.format.BackupProblem
|
||||||
import com.atridad.openclimb.data.format.ClimbDataBackup
|
import com.atridad.ascently.data.format.ClimbDataBackup
|
||||||
import com.atridad.openclimb.data.format.DeletedItem
|
import com.atridad.ascently.data.format.DeletedItem
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import com.atridad.openclimb.data.state.DataStateManager
|
import com.atridad.ascently.data.state.DataStateManager
|
||||||
import com.atridad.openclimb.utils.DateFormatUtils
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import com.atridad.openclimb.utils.ZipExportImportUtils
|
import com.atridad.ascently.utils.ZipExportImportUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
|
class ClimbRepository(database: AscentlyDatabase, private val context: Context) {
|
||||||
private val gymDao = database.gymDao()
|
private val gymDao = database.gymDao()
|
||||||
private val problemDao = database.problemDao()
|
private val problemDao = database.problemDao()
|
||||||
private val sessionDao = database.climbSessionDao()
|
private val sessionDao = database.climbSessionDao()
|
||||||
@@ -159,7 +157,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
|||||||
.filter { imagePath ->
|
.filter { imagePath ->
|
||||||
try {
|
try {
|
||||||
val imageFile =
|
val imageFile =
|
||||||
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
com.atridad.ascently.utils.ImageUtils.getImageFile(
|
||||||
context,
|
context,
|
||||||
imagePath
|
imagePath
|
||||||
)
|
)
|
||||||
@@ -288,7 +286,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
|||||||
try {
|
try {
|
||||||
val deletion = json.decodeFromString<DeletedItem>(value)
|
val deletion = json.decodeFromString<DeletedItem>(value)
|
||||||
deletions.add(deletion)
|
deletions.add(deletion)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// Invalid deletion record, ignore
|
// Invalid deletion record, ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.atridad.openclimb.data.state
|
package com.atridad.ascently.data.state
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.atridad.openclimb.utils.DateFormatUtils
|
import androidx.core.content.edit
|
||||||
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the overall data state timestamp for sync purposes. This tracks when any data in the
|
* Manages the overall data state timestamp for sync purposes. This tracks when any data in the
|
||||||
@@ -13,7 +14,7 @@ class DataStateManager(context: Context) {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "DataStateManager"
|
private const val TAG = "DataStateManager"
|
||||||
private const val PREFS_NAME = "openclimb_data_state"
|
private const val PREFS_NAME = "ascently_data_state"
|
||||||
private const val KEY_LAST_MODIFIED = "last_modified_timestamp"
|
private const val KEY_LAST_MODIFIED = "last_modified_timestamp"
|
||||||
private const val KEY_INITIALIZED = "state_initialized"
|
private const val KEY_INITIALIZED = "state_initialized"
|
||||||
}
|
}
|
||||||
@@ -35,7 +36,7 @@ class DataStateManager(context: Context) {
|
|||||||
*/
|
*/
|
||||||
fun updateDataState() {
|
fun updateDataState() {
|
||||||
val now = DateFormatUtils.nowISO8601()
|
val now = DateFormatUtils.nowISO8601()
|
||||||
prefs.edit().putString(KEY_LAST_MODIFIED, now).apply()
|
prefs.edit { putString(KEY_LAST_MODIFIED, now) }
|
||||||
Log.d(TAG, "Data state updated to: $now")
|
Log.d(TAG, "Data state updated to: $now")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,21 +49,6 @@ class DataStateManager(context: Context) {
|
|||||||
?: DateFormatUtils.nowISO8601()
|
?: DateFormatUtils.nowISO8601()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the data state timestamp to a specific value. Used when importing data from server to
|
|
||||||
* sync the state.
|
|
||||||
*/
|
|
||||||
fun setLastModified(timestamp: String) {
|
|
||||||
prefs.edit().putString(KEY_LAST_MODIFIED, timestamp).apply()
|
|
||||||
Log.d(TAG, "Data state set to: $timestamp")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resets the data state (for testing or complete data wipe). */
|
|
||||||
fun reset() {
|
|
||||||
prefs.edit().clear().apply()
|
|
||||||
Log.d(TAG, "Data state reset")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Checks if the data state has been initialized. */
|
/** Checks if the data state has been initialized. */
|
||||||
private fun isInitialized(): Boolean {
|
private fun isInitialized(): Boolean {
|
||||||
return prefs.getBoolean(KEY_INITIALIZED, false)
|
return prefs.getBoolean(KEY_INITIALIZED, false)
|
||||||
@@ -70,11 +56,6 @@ class DataStateManager(context: Context) {
|
|||||||
|
|
||||||
/** Marks the data state as initialized. */
|
/** Marks the data state as initialized. */
|
||||||
private fun markAsInitialized() {
|
private fun markAsInitialized() {
|
||||||
prefs.edit().putBoolean(KEY_INITIALIZED, true).apply()
|
prefs.edit { putBoolean(KEY_INITIALIZED, true) }
|
||||||
}
|
|
||||||
|
|
||||||
/** Gets debug information about the current state. */
|
|
||||||
fun getDebugInfo(): String {
|
|
||||||
return "DataState(lastModified=${getLastModified()}, initialized=${isInitialized()})"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.navigation
|
package com.atridad.ascently.navigation
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
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.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
@@ -8,10 +8,10 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.atridad.openclimb.MainActivity
|
import com.atridad.ascently.MainActivity
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.ascently.R
|
||||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
import com.atridad.ascently.data.repository.ClimbRepository
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -52,7 +52,7 @@ class SessionTrackingService : Service() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
val database = OpenClimbDatabase.getDatabase(this)
|
val database = AscentlyDatabase.getDatabase(this)
|
||||||
repository = ClimbRepository(database, this)
|
repository = ClimbRepository(database, this)
|
||||||
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
@@ -75,8 +75,8 @@ class SessionTrackingService : Service() {
|
|||||||
sessionId != null -> repository.getSessionById(sessionId)
|
sessionId != null -> repository.getSessionById(sessionId)
|
||||||
else -> repository.getActiveSession()
|
else -> repository.getActiveSession()
|
||||||
}
|
}
|
||||||
if (targetSession != null && targetSession.status == com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
|
if (targetSession != null && targetSession.status == com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
|
||||||
val completed = with(com.atridad.openclimb.data.model.ClimbSession) { targetSession.complete() }
|
val completed = with(com.atridad.ascently.data.model.ClimbSession) { targetSession.complete() }
|
||||||
repository.updateSession(completed)
|
repository.updateSession(completed)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -89,10 +89,6 @@ class SessionTrackingService : Service() {
|
|||||||
return START_REDELIVER_INTENT
|
return START_REDELIVER_INTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
||||||
super.onTaskRemoved(rootIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun startSessionTracking(sessionId: String) {
|
private fun startSessionTracking(sessionId: String) {
|
||||||
@@ -131,7 +127,7 @@ class SessionTrackingService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val session = repository.getSessionById(sessionId)
|
val session = repository.getSessionById(sessionId)
|
||||||
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
|
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
|
||||||
stopSessionTracking()
|
stopSessionTracking()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -153,7 +149,7 @@ class SessionTrackingService : Service() {
|
|||||||
return try {
|
return try {
|
||||||
val activeNotifications = notificationManager.activeNotifications
|
val activeNotifications = notificationManager.activeNotifications
|
||||||
activeNotifications.any { it.id == NOTIFICATION_ID }
|
activeNotifications.any { it.id == NOTIFICATION_ID }
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +175,7 @@ class SessionTrackingService : Service() {
|
|||||||
val session = runBlocking {
|
val session = runBlocking {
|
||||||
repository.getSessionById(sessionId)
|
repository.getSessionById(sessionId)
|
||||||
}
|
}
|
||||||
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
|
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
|
||||||
stopSessionTracking()
|
stopSessionTracking()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.ui
|
package com.atridad.ascently.ui
|
||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -17,21 +17,21 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
import com.atridad.ascently.data.repository.ClimbRepository
|
||||||
import com.atridad.openclimb.data.sync.SyncService
|
import com.atridad.ascently.data.sync.SyncService
|
||||||
import com.atridad.openclimb.navigation.Screen
|
import com.atridad.ascently.navigation.Screen
|
||||||
import com.atridad.openclimb.navigation.bottomNavigationItems
|
import com.atridad.ascently.navigation.bottomNavigationItems
|
||||||
import com.atridad.openclimb.ui.components.NotificationPermissionDialog
|
import com.atridad.ascently.ui.components.NotificationPermissionDialog
|
||||||
import com.atridad.openclimb.ui.screens.*
|
import com.atridad.ascently.ui.screens.*
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory
|
||||||
import com.atridad.openclimb.utils.AppShortcutManager
|
import com.atridad.ascently.utils.AppShortcutManager
|
||||||
import com.atridad.openclimb.utils.NotificationPermissionUtils
|
import com.atridad.ascently.utils.NotificationPermissionUtils
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OpenClimbApp(
|
fun AscentlyApp(
|
||||||
shortcutAction: String? = null,
|
shortcutAction: String? = null,
|
||||||
lastUsedGymId: String? = null,
|
lastUsedGymId: String? = null,
|
||||||
onShortcutActionProcessed: () -> Unit = {}
|
onShortcutActionProcessed: () -> Unit = {}
|
||||||
@@ -39,13 +39,13 @@ fun OpenClimbApp(
|
|||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
var lastUsedGym by remember { mutableStateOf<com.atridad.openclimb.data.model.Gym?>(null) }
|
var lastUsedGym by remember { mutableStateOf<com.atridad.ascently.data.model.Gym?>(null) }
|
||||||
|
|
||||||
val database = remember { OpenClimbDatabase.getDatabase(context) }
|
val database = remember { AscentlyDatabase.getDatabase(context) }
|
||||||
val repository = remember { ClimbRepository(database, context) }
|
val repository = remember { ClimbRepository(database, context) }
|
||||||
val syncService = remember { SyncService(context, repository) }
|
val syncService = remember { SyncService(context, repository) }
|
||||||
val viewModel: ClimbViewModel =
|
val viewModel: ClimbViewModel =
|
||||||
viewModel(factory = ClimbViewModelFactory(repository, syncService))
|
viewModel(factory = ClimbViewModelFactory(repository, syncService, context))
|
||||||
|
|
||||||
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
|
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
|
||||||
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
|
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
|
||||||
@@ -115,7 +115,7 @@ fun OpenClimbApp(
|
|||||||
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
|
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
|
||||||
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
|
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
|
||||||
android.util.Log.d(
|
android.util.Log.d(
|
||||||
"OpenClimbApp",
|
"AscentlyApp",
|
||||||
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
|
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -125,12 +125,12 @@ fun OpenClimbApp(
|
|||||||
context
|
context
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
android.util.Log.d("OpenClimbApp", "Showing notification permission dialog")
|
android.util.Log.d("AscentlyApp", "Showing notification permission dialog")
|
||||||
showNotificationPermissionDialog = true
|
showNotificationPermissionDialog = true
|
||||||
} else {
|
} else {
|
||||||
if (gyms.size == 1) {
|
if (gyms.size == 1) {
|
||||||
android.util.Log.d(
|
android.util.Log.d(
|
||||||
"OpenClimbApp",
|
"AscentlyApp",
|
||||||
"Starting session with single gym: ${gyms.first().name}"
|
"Starting session with single gym: ${gyms.first().name}"
|
||||||
)
|
)
|
||||||
viewModel.startSession(context, gyms.first().id)
|
viewModel.startSession(context, gyms.first().id)
|
||||||
@@ -141,13 +141,13 @@ fun OpenClimbApp(
|
|||||||
|
|
||||||
if (targetGym != null) {
|
if (targetGym != null) {
|
||||||
android.util.Log.d(
|
android.util.Log.d(
|
||||||
"OpenClimbApp",
|
"AscentlyApp",
|
||||||
"Starting session with target gym: ${targetGym.name}"
|
"Starting session with target gym: ${targetGym.name}"
|
||||||
)
|
)
|
||||||
viewModel.startSession(context, targetGym.id)
|
viewModel.startSession(context, targetGym.id)
|
||||||
} else {
|
} else {
|
||||||
android.util.Log.d(
|
android.util.Log.d(
|
||||||
"OpenClimbApp",
|
"AscentlyApp",
|
||||||
"No target gym found, navigating to selection"
|
"No target gym found, navigating to selection"
|
||||||
)
|
)
|
||||||
navController.navigate(Screen.AddEditSession())
|
navController.navigate(Screen.AddEditSession())
|
||||||
@@ -156,7 +156,7 @@ fun OpenClimbApp(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
android.util.Log.d(
|
android.util.Log.d(
|
||||||
"OpenClimbApp",
|
"AscentlyApp",
|
||||||
"Active session already exists: ${activeSession?.id}"
|
"Active session already exists: ${activeSession?.id}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@ fun OpenClimbApp(
|
|||||||
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
|
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = { OpenClimbBottomNavigation(navController = navController) },
|
bottomBar = { AscentlyBottomNavigation(navController = navController) },
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
fabConfig?.let { config ->
|
fabConfig?.let { config ->
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
@@ -363,7 +363,7 @@ fun OpenClimbApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OpenClimbBottomNavigation(navController: NavHostController) {
|
fun AscentlyBottomNavigation(navController: NavHostController) {
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.ui.components
|
package com.atridad.ascently.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -10,9 +10,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.data.model.ClimbSession
|
import com.atridad.ascently.data.model.ClimbSession
|
||||||
import com.atridad.openclimb.data.model.Gym
|
import com.atridad.ascently.data.model.Gym
|
||||||
import com.atridad.openclimb.ui.theme.CustomIcons
|
import com.atridad.ascently.ui.theme.CustomIcons
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.ui.components
|
package com.atridad.ascently.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package com.atridad.ascently.ui.components
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
|
||||||
|
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
|
||||||
|
val thumbnailListState = rememberLazyListState()
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Handle back button press
|
||||||
|
BackHandler { onDismiss() }
|
||||||
|
|
||||||
|
// Auto-scroll thumbnail list to center current image
|
||||||
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
|
if (imagePaths.size > 1) {
|
||||||
|
thumbnailListState.animateScrollToItem(
|
||||||
|
index = pagerState.currentPage,
|
||||||
|
scrollOffset = -200
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties =
|
||||||
|
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true)
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black).systemBarsPadding()) {
|
||||||
|
// Main image pager
|
||||||
|
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||||
|
OrientationAwareImage(
|
||||||
|
imagePath = imagePaths[page],
|
||||||
|
contentDescription = "Full screen image",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top bar with back button and counter
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.TopStart),
|
||||||
|
color = Color.Black.copy(alpha = 0.6f)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Back button
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Close",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Image counter
|
||||||
|
if (imagePaths.size > 1) {
|
||||||
|
Text(
|
||||||
|
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail strip at bottom (if multiple images)
|
||||||
|
if (imagePaths.size > 1) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
color = Color.Black.copy(alpha = 0.6f)
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
state = thumbnailListState,
|
||||||
|
modifier = Modifier.padding(vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(imagePaths) { index, imagePath ->
|
||||||
|
val isSelected = index == pagerState.currentPage
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(48.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable {
|
||||||
|
coroutineScope.launch {
|
||||||
|
pagerState.animateScrollToPage(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
OrientationAwareImage(
|
||||||
|
imagePath = imagePath,
|
||||||
|
contentDescription = "Thumbnail ${index + 1}",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
|
||||||
|
// Selection indicator
|
||||||
|
if (isSelected) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Color.White.copy(alpha = 0.3f),
|
||||||
|
RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Color.Transparent,
|
||||||
|
RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
Color.White.copy(alpha = 0.2f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package com.atridad.ascently.ui.components
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Environment
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.CameraAlt
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.atridad.ascently.utils.ImageUtils
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImagePicker(
|
||||||
|
imageUris: List<String>,
|
||||||
|
onImagesChanged: (List<String>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
maxImages: Int = 5
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var tempImageUris by remember { mutableStateOf(imageUris) }
|
||||||
|
var showImageSourceDialog by remember { mutableStateOf(false) }
|
||||||
|
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
|
// Image picker launcher
|
||||||
|
val imagePickerLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetMultipleContents()
|
||||||
|
) { uris ->
|
||||||
|
if (uris.isNotEmpty()) {
|
||||||
|
val currentCount = tempImageUris.size
|
||||||
|
val remainingSlots = maxImages - currentCount
|
||||||
|
val urisToProcess = uris.take(remainingSlots)
|
||||||
|
|
||||||
|
// Process images
|
||||||
|
val newImagePaths = mutableListOf<String>()
|
||||||
|
urisToProcess.forEach { uri ->
|
||||||
|
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
|
||||||
|
if (imagePath != null) {
|
||||||
|
newImagePaths.add(imagePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newImagePaths.isNotEmpty()) {
|
||||||
|
val updatedUris = tempImageUris + newImagePaths
|
||||||
|
tempImageUris = updatedUris
|
||||||
|
onImagesChanged(updatedUris)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera launcher
|
||||||
|
val cameraLauncher =
|
||||||
|
rememberLauncherForActivityResult(contract = ActivityResultContracts.TakePicture()) {
|
||||||
|
success ->
|
||||||
|
if (success) {
|
||||||
|
cameraImageUri?.let { uri ->
|
||||||
|
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
|
||||||
|
if (imagePath != null) {
|
||||||
|
val updatedUris = tempImageUris + imagePath
|
||||||
|
tempImageUris = updatedUris
|
||||||
|
onImagesChanged(updatedUris)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera permission launcher
|
||||||
|
val cameraPermissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission()
|
||||||
|
) { isGranted ->
|
||||||
|
if (isGranted) {
|
||||||
|
// Create image file for camera
|
||||||
|
val imageFile = createImageFile(context)
|
||||||
|
val uri =
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
imageFile
|
||||||
|
)
|
||||||
|
cameraImageUri = uri
|
||||||
|
cameraLauncher.launch(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Photos (${tempImageUris.size}/$maxImages)",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tempImageUris.size < maxImages) {
|
||||||
|
TextButton(onClick = { showImageSourceDialog = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Add Photos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempImageUris.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(tempImageUris) { imagePath ->
|
||||||
|
ImageItem(
|
||||||
|
imagePath = imagePath,
|
||||||
|
onRemove = {
|
||||||
|
val updatedUris = tempImageUris.filter { it != imagePath }
|
||||||
|
tempImageUris = updatedUris
|
||||||
|
onImagesChanged(updatedUris)
|
||||||
|
|
||||||
|
// Delete the image file
|
||||||
|
ImageUtils.deleteImage(context, imagePath)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(100.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||||
|
alpha = 0.3f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Add photos of this problem",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image Source Selection Dialog
|
||||||
|
if (showImageSourceDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showImageSourceDialog = false },
|
||||||
|
title = { Text("Add Photo") },
|
||||||
|
text = { Text("Choose how you'd like to add a photo") },
|
||||||
|
confirmButton = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showImageSourceDialog = false
|
||||||
|
imagePickerLauncher.launch("image/*")
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PhotoLibrary,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Gallery")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showImageSourceDialog = false
|
||||||
|
when (ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.CAMERA
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
PackageManager.PERMISSION_GRANTED -> {
|
||||||
|
// Create image file for camera
|
||||||
|
val imageFile = createImageFile(context)
|
||||||
|
val uri =
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
imageFile
|
||||||
|
)
|
||||||
|
cameraImageUri = uri
|
||||||
|
cameraLauncher.launch(uri)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
cameraPermissionLauncher.launch(
|
||||||
|
Manifest.permission.CAMERA
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CameraAlt,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Camera")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showImageSourceDialog = false }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createImageFile(context: android.content.Context): File {
|
||||||
|
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
|
val imageFileName = "JPEG_${timeStamp}_"
|
||||||
|
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||||
|
return File.createTempFile(imageFileName, ".jpg", storageDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
|
||||||
|
Box(modifier = modifier.size(80.dp)) {
|
||||||
|
OrientationAwareImage(
|
||||||
|
imagePath = imagePath,
|
||||||
|
contentDescription = "Problem photo",
|
||||||
|
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(onClick = onRemove, modifier = Modifier.align(Alignment.TopEnd).size(24.dp)) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Remove photo",
|
||||||
|
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.ui.components
|
package com.atridad.ascently.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -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,152 @@
|
|||||||
|
package com.atridad.ascently.ui.components
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import com.atridad.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.size(32.dp).align(Alignment.Center))
|
||||||
|
} 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.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package com.atridad.ascently.ui.health
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.atridad.ascently.data.health.HealthConnectManager
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HealthConnectCard(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val healthConnectManager = remember { HealthConnectManager(context) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// State tracking
|
||||||
|
var isHealthConnectAvailable by remember { mutableStateOf(false) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Collect flows
|
||||||
|
val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false)
|
||||||
|
val hasPermissions by healthConnectManager.hasPermissions.collectAsState(initial = false)
|
||||||
|
|
||||||
|
val isCompatible by healthConnectManager.isCompatible.collectAsState(initial = true)
|
||||||
|
|
||||||
|
// Permission launcher
|
||||||
|
val permissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = healthConnectManager.getPermissionRequestContract()
|
||||||
|
) { _ ->
|
||||||
|
coroutineScope.launch {
|
||||||
|
val allGranted = healthConnectManager.hasAllPermissions()
|
||||||
|
if (!allGranted) {
|
||||||
|
errorMessage =
|
||||||
|
"Some Health Connect permissions were not granted. Please grant all permissions to enable syncing."
|
||||||
|
} else {
|
||||||
|
errorMessage = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Health Connect availability on first load
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
healthConnectManager.isHealthConnectAvailable().collect { available ->
|
||||||
|
isHealthConnectAvailable = available
|
||||||
|
isLoading = false
|
||||||
|
|
||||||
|
if (!available && isCompatible) {
|
||||||
|
errorMessage = "Health Connect is not available on this device"
|
||||||
|
} else if (!isCompatible) {
|
||||||
|
errorMessage =
|
||||||
|
"Health Connect API compatibility issue. Please update your device or the app."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = "Error checking Health Connect availability: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Header with icon and title
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.HealthAndSafety,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint =
|
||||||
|
if (isHealthConnectAvailable && isEnabled && hasPermissions) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Health Connect",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
when {
|
||||||
|
isLoading -> "Checking availability..."
|
||||||
|
!isCompatible -> "API Issue"
|
||||||
|
!isHealthConnectAvailable -> "Not available"
|
||||||
|
isEnabled && hasPermissions -> "Connected"
|
||||||
|
isEnabled && !hasPermissions -> "Needs permissions"
|
||||||
|
else -> "Disabled"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color =
|
||||||
|
when {
|
||||||
|
isLoading ->
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.7f
|
||||||
|
)
|
||||||
|
|
||||||
|
!isCompatible -> MaterialTheme.colorScheme.error
|
||||||
|
!isHealthConnectAvailable -> MaterialTheme.colorScheme.error
|
||||||
|
isEnabled && hasPermissions ->
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
|
||||||
|
isEnabled && !hasPermissions ->
|
||||||
|
MaterialTheme.colorScheme.tertiary
|
||||||
|
|
||||||
|
else ->
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.7f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main toggle switch
|
||||||
|
Switch(
|
||||||
|
checked = isEnabled,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
coroutineScope.launch {
|
||||||
|
if (enabled && isHealthConnectAvailable) {
|
||||||
|
healthConnectManager.setEnabled(true)
|
||||||
|
try {
|
||||||
|
val permissionSet =
|
||||||
|
healthConnectManager.getRequiredPermissions()
|
||||||
|
if (permissionSet.isNotEmpty()) {
|
||||||
|
permissionLauncher.launch(permissionSet)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errorMessage = "Error requesting permissions: ${e.message}"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
healthConnectManager.setEnabled(false)
|
||||||
|
errorMessage = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = isHealthConnectAvailable && !isLoading && isCompatible
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnabled) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Climbing sessions will be automatically added to Health Connect when completed.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasPermissions) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
MaterialTheme.colorScheme.errorContainer.copy(
|
||||||
|
alpha = 0.3f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Permissions needed",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
"Grant Health Connect permissions to sync your climbing sessions",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color =
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val permissionSet =
|
||||||
|
healthConnectManager
|
||||||
|
.getRequiredPermissions()
|
||||||
|
if (permissionSet.isNotEmpty()) {
|
||||||
|
permissionLauncher.launch(permissionSet)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errorMessage =
|
||||||
|
"Error requesting permissions: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) { Text("Grant Permissions") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
"Sync your climbing sessions to Samsung Health, Google Fit, and other fitness apps through Health Connect.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
errorMessage?.let { error ->
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
MaterialTheme.colorScheme.errorContainer.copy(
|
||||||
|
alpha = 0.5f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.ui.screens
|
package com.atridad.ascently.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -17,9 +17,9 @@ import androidx.compose.ui.semantics.Role
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import com.atridad.openclimb.ui.components.ImagePicker
|
import com.atridad.ascently.ui.components.ImagePicker
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
selectedClimbTypes
|
selectedClimbTypes
|
||||||
.flatMap { climbType -> DifficultySystem.getSystemsForClimbType(climbType) }
|
.flatMap { climbType -> DifficultySystem.systemsForClimbType(climbType) }
|
||||||
.distinct()
|
.distinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
onCheckedChange = null
|
onCheckedChange = null
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(climbType.getDisplayName())
|
Text(climbType.displayName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
|
|||||||
onCheckedChange = null
|
onCheckedChange = null
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(system.getDisplayName())
|
Text(system.displayName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@ fun AddEditProblemScreen(
|
|||||||
|
|
||||||
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
|
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
|
||||||
val availableDifficultySystems =
|
val availableDifficultySystems =
|
||||||
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
|
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
|
||||||
selectedGym?.difficultySystems?.contains(system) != false
|
selectedGym?.difficultySystems?.contains(system) != false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +323,7 @@ fun AddEditProblemScreen(
|
|||||||
|
|
||||||
// Reset grade when difficulty system changes (unless it's a valid grade for the new system)
|
// Reset grade when difficulty system changes (unless it's a valid grade for the new system)
|
||||||
LaunchedEffect(selectedDifficultySystem) {
|
LaunchedEffect(selectedDifficultySystem) {
|
||||||
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
val availableGrades = selectedDifficultySystem.availableGrades
|
||||||
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
|
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
|
||||||
difficultyGrade = ""
|
difficultyGrade = ""
|
||||||
}
|
}
|
||||||
@@ -385,9 +385,9 @@ fun AddEditProblemScreen(
|
|||||||
notes = notes.ifBlank { null }
|
notes = notes.ifBlank { null }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing && problemId != null) {
|
||||||
viewModel.updateProblem(
|
viewModel.updateProblem(
|
||||||
problem.copy(id = problemId!!)
|
problem.copy(id = problemId)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
viewModel.addProblem(problem)
|
viewModel.addProblem(problem)
|
||||||
@@ -503,7 +503,7 @@ fun AddEditProblemScreen(
|
|||||||
availableClimbTypes.forEach { climbType ->
|
availableClimbTypes.forEach { climbType ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedClimbType = climbType },
|
onClick = { selectedClimbType = climbType },
|
||||||
label = { Text(climbType.getDisplayName()) },
|
label = { Text(climbType.displayName) },
|
||||||
selected = selectedClimbType == climbType
|
selected = selectedClimbType == climbType
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -536,7 +536,7 @@ fun AddEditProblemScreen(
|
|||||||
items(availableDifficultySystems) { system ->
|
items(availableDifficultySystems) { system ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedDifficultySystem = system },
|
onClick = { selectedDifficultySystem = system },
|
||||||
label = { Text(system.getDisplayName()) },
|
label = { Text(system.displayName) },
|
||||||
selected = selectedDifficultySystem == system
|
selected = selectedDifficultySystem == system
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -568,7 +568,7 @@ fun AddEditProblemScreen(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
val availableGrades = selectedDifficultySystem.availableGrades
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.ui.screens
|
package com.atridad.ascently.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -9,16 +9,16 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.ascently.R
|
||||||
import com.atridad.openclimb.data.model.AttemptResult
|
import com.atridad.ascently.data.model.AttemptResult
|
||||||
import com.atridad.openclimb.data.model.ClimbType
|
import com.atridad.ascently.data.model.ClimbType
|
||||||
import com.atridad.openclimb.data.model.DifficultySystem
|
import com.atridad.ascently.data.model.DifficultySystem
|
||||||
import com.atridad.openclimb.ui.components.BarChart
|
import com.atridad.ascently.ui.components.BarChart
|
||||||
import com.atridad.openclimb.ui.components.BarChartDataPoint
|
import com.atridad.ascently.ui.components.BarChartDataPoint
|
||||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
import com.atridad.ascently.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
||||||
@@ -39,7 +39,7 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "OpenClimb Logo",
|
contentDescription = "Ascently Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -200,7 +200,9 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.menuAnchor(
|
Modifier.menuAnchor(
|
||||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
type =
|
||||||
|
ExposedDropdownMenuAnchorType
|
||||||
|
.PrimaryNotEditable,
|
||||||
enabled = true
|
enabled = true
|
||||||
)
|
)
|
||||||
.width(120.dp),
|
.width(120.dp),
|
||||||
@@ -251,11 +253,8 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
|
|||||||
systemFiltered.filter { dataPoint ->
|
systemFiltered.filter { dataPoint ->
|
||||||
try {
|
try {
|
||||||
val attemptDate =
|
val attemptDate =
|
||||||
LocalDateTime.parse(
|
DateFormatUtils.parseToLocalDateTime(dataPoint.date)
|
||||||
dataPoint.date,
|
attemptDate?.isAfter(sevenDaysAgo) == true
|
||||||
DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
|
||||||
)
|
|
||||||
attemptDate.isAfter(sevenDaysAgo)
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// If date parsing fails, include the data point
|
// If date parsing fails, include the data point
|
||||||
true
|
true
|
||||||
@@ -394,9 +393,9 @@ data class GradeDistributionDataPoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun calculateGradeDistribution(
|
fun calculateGradeDistribution(
|
||||||
sessions: List<com.atridad.openclimb.data.model.ClimbSession>,
|
sessions: List<com.atridad.ascently.data.model.ClimbSession>,
|
||||||
problems: List<com.atridad.openclimb.data.model.Problem>,
|
problems: List<com.atridad.ascently.data.model.Problem>,
|
||||||
attempts: List<com.atridad.openclimb.data.model.Attempt>
|
attempts: List<com.atridad.ascently.data.model.Attempt>
|
||||||
): List<GradeDistributionDataPoint> {
|
): List<GradeDistributionDataPoint> {
|
||||||
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
|
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.ui.screens
|
package com.atridad.ascently.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.Delete
|
|||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
import androidx.compose.material.icons.filled.Share
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -29,16 +28,13 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.lifecycle.viewModelScope
|
import com.atridad.ascently.data.model.*
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.ascently.ui.components.FullscreenImageViewer
|
||||||
import com.atridad.openclimb.ui.components.FullscreenImageViewer
|
import com.atridad.ascently.ui.components.ImageDisplaySection
|
||||||
import com.atridad.openclimb.ui.components.ImageDisplaySection
|
import com.atridad.ascently.ui.theme.CustomIcons
|
||||||
import com.atridad.openclimb.ui.theme.CustomIcons
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -220,7 +216,6 @@ fun SessionDetailScreen(
|
|||||||
val problems by viewModel.problems.collectAsState()
|
val problems by viewModel.problems.collectAsState()
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
|
|
||||||
var isGeneratingShare by remember { mutableStateOf(false) }
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
var showAddAttemptDialog by remember { mutableStateOf(false) }
|
var showAddAttemptDialog by remember { mutableStateOf(false) }
|
||||||
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) }
|
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) }
|
||||||
@@ -233,7 +228,7 @@ fun SessionDetailScreen(
|
|||||||
val successfulAttempts =
|
val successfulAttempts =
|
||||||
attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
|
attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
|
||||||
val uniqueProblems = attempts.map { it.problemId }.distinct()
|
val uniqueProblems = attempts.map { it.problemId }.distinct()
|
||||||
val attemptedProblems = problems.filter { it.id in uniqueProblems }
|
|
||||||
val completedProblems = successfulAttempts.map { it.problemId }.distinct()
|
val completedProblems = successfulAttempts.map { it.problemId }.distinct()
|
||||||
|
|
||||||
val attemptsWithProblems =
|
val attemptsWithProblems =
|
||||||
@@ -260,38 +255,8 @@ fun SessionDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// Share button
|
// No manual actions needed - Health Connect syncs automatically when
|
||||||
if (session?.duration != null) { // Only show for completed sessions
|
// sessions complete
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
isGeneratingShare = true
|
|
||||||
viewModel.viewModelScope.launch {
|
|
||||||
val shareFile =
|
|
||||||
viewModel.generateSessionShareCard(
|
|
||||||
context,
|
|
||||||
sessionId
|
|
||||||
)
|
|
||||||
isGeneratingShare = false
|
|
||||||
shareFile?.let { file ->
|
|
||||||
viewModel.shareSessionCard(context, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isGeneratingShare
|
|
||||||
) {
|
|
||||||
if (isGeneratingShare) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Share,
|
|
||||||
contentDescription = "Share Session"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show stop icon for active sessions, delete icon for completed
|
// Show stop icon for active sessions, delete icon for completed
|
||||||
// sessions
|
// sessions
|
||||||
@@ -563,7 +528,7 @@ fun ProblemDetailScreen(
|
|||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onNavigateToEdit: (String) -> Unit
|
onNavigateToEdit: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
var showImageViewer by remember { mutableStateOf(false) }
|
var showImageViewer by remember { mutableStateOf(false) }
|
||||||
var selectedImageIndex by remember { mutableIntStateOf(0) }
|
var selectedImageIndex by remember { mutableIntStateOf(0) }
|
||||||
@@ -638,7 +603,7 @@ fun ProblemDetailScreen(
|
|||||||
problem?.let { p ->
|
problem?.let { p ->
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}",
|
"${p.difficulty.system.displayName}: ${p.difficulty.grade}",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
@@ -647,7 +612,7 @@ fun ProblemDetailScreen(
|
|||||||
|
|
||||||
problem?.let { p ->
|
problem?.let { p ->
|
||||||
Text(
|
Text(
|
||||||
text = p.climbType.getDisplayName(),
|
text = p.climbType.displayName,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -827,7 +792,7 @@ fun ProblemDetailScreen(
|
|||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
problem?.let { p ->
|
problem?.let { p ->
|
||||||
viewModel.deleteProblem(p, context)
|
viewModel.deleteProblem(p)
|
||||||
onNavigateBack()
|
onNavigateBack()
|
||||||
}
|
}
|
||||||
showDeleteDialog = false
|
showDeleteDialog = false
|
||||||
@@ -1209,19 +1174,10 @@ fun GymDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
val dateTime =
|
|
||||||
try {
|
|
||||||
LocalDateTime.parse(session.date)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val formattedDate =
|
val formattedDate =
|
||||||
dateTime?.format(
|
DateFormatUtils.formatDateForDisplay(
|
||||||
DateTimeFormatter.ofPattern(
|
session.date
|
||||||
"MMM dd, yyyy"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
?: session.date
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"$formattedDate • ${sessionAttempts.size} attempts"
|
"$formattedDate • ${sessionAttempts.size} attempts"
|
||||||
@@ -1436,7 +1392,7 @@ fun SessionAttemptCard(
|
|||||||
|
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
|
"${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -1511,14 +1467,7 @@ fun SessionAttemptCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDate(dateString: String): String {
|
private fun formatDate(dateString: String): String {
|
||||||
return try {
|
return DateFormatUtils.formatDateForDisplay(dateString)
|
||||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
|
||||||
val date = LocalDateTime.parse(dateString, formatter)
|
|
||||||
val displayFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy")
|
|
||||||
date.format(displayFormatter)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
dateString.take(10) // Fallback to just the date part
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -1557,7 +1506,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
// Auto-select difficulty system if there's only one available for the selected climb type
|
// Auto-select difficulty system if there's only one available for the selected climb type
|
||||||
LaunchedEffect(selectedClimbType, gym.difficultySystems) {
|
LaunchedEffect(selectedClimbType, gym.difficultySystems) {
|
||||||
val availableSystems =
|
val availableSystems =
|
||||||
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
|
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
|
||||||
gym.difficultySystems.contains(system)
|
gym.difficultySystems.contains(system)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1577,7 +1526,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
|
|
||||||
// Reset grade when difficulty system changes
|
// Reset grade when difficulty system changes
|
||||||
LaunchedEffect(selectedDifficultySystem) {
|
LaunchedEffect(selectedDifficultySystem) {
|
||||||
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
val availableGrades = selectedDifficultySystem.availableGrades
|
||||||
if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) {
|
if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) {
|
||||||
newProblemGrade = ""
|
newProblemGrade = ""
|
||||||
}
|
}
|
||||||
@@ -1694,7 +1643,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
|
"${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography
|
MaterialTheme.typography
|
||||||
.bodyMedium,
|
.bodyMedium,
|
||||||
@@ -1703,7 +1652,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
MaterialTheme
|
MaterialTheme
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurface.copy(
|
.onSurface.copy(
|
||||||
alpha = 0.8f
|
alpha = 0.9f
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
MaterialTheme
|
MaterialTheme
|
||||||
@@ -1780,7 +1729,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
onClick = { selectedClimbType = climbType },
|
onClick = { selectedClimbType = climbType },
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
climbType.getDisplayName(),
|
climbType.displayName,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1811,7 +1760,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
)
|
)
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
val availableSystems =
|
val availableSystems =
|
||||||
DifficultySystem.getSystemsForClimbType(
|
DifficultySystem.systemsForClimbType(
|
||||||
selectedClimbType
|
selectedClimbType
|
||||||
)
|
)
|
||||||
.filter { system ->
|
.filter { system ->
|
||||||
@@ -1822,7 +1771,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
onClick = { selectedDifficultySystem = system },
|
onClick = { selectedDifficultySystem = system },
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
system.getDisplayName(),
|
system.displayName,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1899,8 +1848,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
val availableGrades =
|
val availableGrades = selectedDifficultySystem.availableGrades
|
||||||
selectedDifficultySystem.getAvailableGrades()
|
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.ui.screens
|
package com.atridad.ascently.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -10,10 +10,10 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.ascently.R
|
||||||
import com.atridad.openclimb.data.model.Gym
|
import com.atridad.ascently.data.model.Gym
|
||||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
import com.atridad.ascently.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -28,7 +28,7 @@ fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Uni
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "OpenClimb Logo",
|
contentDescription = "Ascently Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -87,7 +87,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
|||||||
gym.supportedClimbTypes.forEach { climbType ->
|
gym.supportedClimbTypes.forEach { climbType ->
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
label = { Text(climbType.getDisplayName()) },
|
label = { Text(climbType.displayName) },
|
||||||
modifier = Modifier.padding(end = 4.dp)
|
modifier = Modifier.padding(end = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
|
|||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
"Systems: ${gym.difficultySystems.joinToString(", ") { it.displayName }}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -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.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Image
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.ascently.R
|
||||||
import com.atridad.openclimb.data.model.ClimbType
|
import com.atridad.ascently.data.model.Attempt
|
||||||
import com.atridad.openclimb.data.model.Gym
|
import com.atridad.ascently.data.model.AttemptResult
|
||||||
import com.atridad.openclimb.data.model.Problem
|
import com.atridad.ascently.data.model.ClimbType
|
||||||
import com.atridad.openclimb.ui.components.FullscreenImageViewer
|
import com.atridad.ascently.data.model.Gym
|
||||||
import com.atridad.openclimb.ui.components.ImageDisplay
|
import com.atridad.ascently.data.model.Problem
|
||||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
import com.atridad.ascently.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
|
fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
|
||||||
val problems by viewModel.problems.collectAsState()
|
val problems by viewModel.problems.collectAsState()
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
var showImageViewer by remember { mutableStateOf(false) }
|
val attempts by viewModel.attempts.collectAsState()
|
||||||
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
val context = LocalContext.current
|
||||||
var selectedImageIndex by remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
|
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
|
||||||
@@ -54,7 +57,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "OpenClimb Logo",
|
contentDescription = "Ascently Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -101,7 +104,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
items(ClimbType.entries) { climbType ->
|
items(ClimbType.entries) { climbType ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedClimbType = climbType },
|
onClick = { selectedClimbType = climbType },
|
||||||
label = { Text(climbType.getDisplayName()) },
|
label = { Text(climbType.displayName) },
|
||||||
selected = selectedClimbType == climbType
|
selected = selectedClimbType == climbType
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -176,12 +179,8 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
ProblemCard(
|
ProblemCard(
|
||||||
problem = problem,
|
problem = problem,
|
||||||
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
|
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
|
||||||
|
attempts = attempts,
|
||||||
onClick = { onNavigateToProblemDetail(problem.id) },
|
onClick = { onNavigateToProblemDetail(problem.id) },
|
||||||
onImageClick = { imagePaths, index ->
|
|
||||||
selectedImagePaths = imagePaths
|
|
||||||
selectedImageIndex = index
|
|
||||||
showImageViewer = true
|
|
||||||
},
|
|
||||||
onToggleActive = {
|
onToggleActive = {
|
||||||
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
||||||
viewModel.updateProblem(updatedProblem)
|
viewModel.updateProblem(updatedProblem)
|
||||||
@@ -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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -208,10 +198,17 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
|||||||
fun ProblemCard(
|
fun ProblemCard(
|
||||||
problem: Problem,
|
problem: Problem,
|
||||||
gymName: String,
|
gymName: String,
|
||||||
|
attempts: List<Attempt>,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onImageClick: ((List<String>, Int) -> Unit)? = null,
|
|
||||||
onToggleActive: (() -> Unit)? = null
|
onToggleActive: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
|
val isCompleted =
|
||||||
|
attempts.any { attempt ->
|
||||||
|
attempt.problemId == problem.id &&
|
||||||
|
(attempt.result == AttemptResult.SUCCESS ||
|
||||||
|
attempt.result == AttemptResult.FLASH)
|
||||||
|
}
|
||||||
|
|
||||||
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
@@ -240,15 +237,38 @@ fun ProblemCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
Text(
|
Row(
|
||||||
text = problem.difficulty.grade,
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
fontWeight = FontWeight.Bold,
|
) {
|
||||||
color = MaterialTheme.colorScheme.primary
|
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(
|
||||||
text = problem.climbType.getDisplayName(),
|
text = problem.climbType.displayName,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -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) {
|
if (!problem.isActive) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
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.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -16,14 +16,13 @@ import androidx.compose.ui.res.painterResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.ascently.R
|
||||||
import com.atridad.openclimb.data.model.ClimbSession
|
import com.atridad.ascently.data.model.ClimbSession
|
||||||
import com.atridad.openclimb.data.model.SessionStatus
|
import com.atridad.ascently.data.model.SessionStatus
|
||||||
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
import com.atridad.ascently.ui.components.ActiveSessionBanner
|
||||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
import com.atridad.ascently.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
import java.time.LocalDateTime
|
import com.atridad.ascently.utils.DateFormatUtils
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -46,7 +45,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "OpenClimb Logo",
|
contentDescription = "Ascently Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -247,10 +246,5 @@ fun EmptyStateMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDate(dateString: String): String {
|
private fun formatDate(dateString: String): String {
|
||||||
return try {
|
return DateFormatUtils.formatDateForDisplay(dateString)
|
||||||
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
|
|
||||||
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
|
|
||||||
} catch (_: Exception) {
|
|
||||||
dateString
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.ui.screens
|
package com.atridad.ascently.ui.screens
|
||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -15,9 +15,10 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.ascently.R
|
||||||
import com.atridad.openclimb.ui.components.SyncIndicator
|
import com.atridad.ascently.ui.components.SyncIndicator
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.ascently.ui.health.HealthConnectCard
|
||||||
|
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -37,22 +38,27 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
val isTesting by syncService.isTesting.collectAsState()
|
val isTesting by syncService.isTesting.collectAsState()
|
||||||
val lastSyncTime by syncService.lastSyncTime.collectAsState()
|
val lastSyncTime by syncService.lastSyncTime.collectAsState()
|
||||||
val syncError by syncService.syncError.collectAsState()
|
val syncError by syncService.syncError.collectAsState()
|
||||||
|
val isAutoSyncEnabled by syncService.isAutoSyncEnabled.collectAsState()
|
||||||
|
|
||||||
// State for dialogs
|
// State for dialogs
|
||||||
var showResetDialog by remember { mutableStateOf(false) }
|
var showResetDialog by remember { mutableStateOf(false) }
|
||||||
var showSyncConfigDialog by remember { mutableStateOf(false) }
|
var showSyncConfigDialog by remember { mutableStateOf(false) }
|
||||||
var showDisconnectDialog by remember { mutableStateOf(false) }
|
var showDisconnectDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var showDeleteImagesDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var isDeletingImages by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Sync configuration state
|
// Sync configuration state
|
||||||
var serverUrl by remember { mutableStateOf(syncService.serverURL) }
|
var serverUrl by remember { mutableStateOf(syncService.serverUrl) }
|
||||||
var authToken by remember { mutableStateOf(syncService.authToken) }
|
var authToken by remember { mutableStateOf(syncService.authToken) }
|
||||||
|
|
||||||
val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) }
|
val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) }
|
||||||
val appVersion = packageInfo.versionName
|
val appVersion = packageInfo.versionName
|
||||||
|
|
||||||
// Update local state when sync service configuration changes
|
// Update local state when sync service configuration changes
|
||||||
LaunchedEffect(syncService.serverURL, syncService.authToken) {
|
LaunchedEffect(syncService.serverUrl, syncService.authToken) {
|
||||||
serverUrl = syncService.serverURL
|
serverUrl = syncService.serverUrl
|
||||||
authToken = syncService.authToken
|
authToken = syncService.authToken
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +86,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
// Only allow ZIP files
|
// Only allow ZIP files
|
||||||
if (!fileName.lowercase().endsWith(".zip")) {
|
if (!fileName.lowercase().endsWith(".zip")) {
|
||||||
viewModel.setError(
|
viewModel.setError(
|
||||||
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
|
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
|
||||||
)
|
)
|
||||||
return@let
|
return@let
|
||||||
}
|
}
|
||||||
@@ -123,7 +129,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_mountains),
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
contentDescription = "OpenClimb Logo",
|
contentDescription = "Ascently Logo",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -177,7 +183,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Column {
|
Column {
|
||||||
Text("Server: ${syncService.serverURL}")
|
Text("Server: ${syncService.serverUrl}")
|
||||||
lastSyncTime?.let { time ->
|
lastSyncTime?.let { time ->
|
||||||
Text(
|
Text(
|
||||||
"Last sync: ${
|
"Last sync: ${
|
||||||
@@ -275,8 +281,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Switch(
|
Switch(
|
||||||
checked = syncService.isAutoSyncEnabled,
|
checked = isAutoSyncEnabled,
|
||||||
onCheckedChange = { syncService.isAutoSyncEnabled = it }
|
onCheckedChange = { enabled ->
|
||||||
|
syncService.setAutoSyncEnabled(enabled)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +336,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Setup Sync") },
|
headlineContent = { Text("Setup Sync") },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text("Connect to your OpenClimb sync server")
|
Text("Connect to your Ascently sync server")
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(Icons.Default.CloudSync, contentDescription = null)
|
Icon(Icons.Default.CloudSync, contentDescription = null)
|
||||||
@@ -375,7 +383,8 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data Management Section
|
item { HealthConnectCard() }
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
@@ -412,7 +421,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val defaultFileName =
|
val defaultFileName =
|
||||||
"openclimb_export_${
|
"ascently_export_${
|
||||||
java.time.LocalDateTime.now()
|
java.time.LocalDateTime.now()
|
||||||
.toString()
|
.toString()
|
||||||
.replace(":", "-")
|
.replace(":", "-")
|
||||||
@@ -475,6 +484,48 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
MaterialTheme.colorScheme.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(
|
Card(
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors =
|
colors =
|
||||||
@@ -553,11 +604,11 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
painterResource(
|
painterResource(
|
||||||
id = R.drawable.ic_mountains
|
id = R.drawable.ic_mountains
|
||||||
),
|
),
|
||||||
contentDescription = "OpenClimb Logo",
|
contentDescription = "Ascently Logo",
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text("OpenClimb")
|
Text("Ascently")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
supportingContent = { Text("Track your climbing progress") },
|
supportingContent = { Text("Track your climbing progress") },
|
||||||
@@ -812,7 +863,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
syncService.serverURL = serverUrl.trim()
|
syncService.serverUrl = serverUrl.trim()
|
||||||
syncService.authToken = authToken.trim()
|
syncService.authToken = authToken.trim()
|
||||||
viewModel.testSyncConnection()
|
viewModel.testSyncConnection()
|
||||||
while (syncService.isTesting.value) {
|
while (syncService.isTesting.value) {
|
||||||
@@ -854,7 +905,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
syncService.serverURL = serverUrl.trim()
|
syncService.serverUrl = serverUrl.trim()
|
||||||
syncService.authToken = authToken.trim()
|
syncService.authToken = authToken.trim()
|
||||||
viewModel.testSyncConnection()
|
viewModel.testSyncConnection()
|
||||||
while (syncService.isTesting.value) {
|
while (syncService.isTesting.value) {
|
||||||
@@ -881,7 +932,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
serverUrl = syncService.serverURL
|
serverUrl = syncService.serverUrl
|
||||||
authToken = syncService.authToken
|
authToken = syncService.authToken
|
||||||
showSyncConfigDialog = false
|
showSyncConfigDialog = false
|
||||||
}
|
}
|
||||||
@@ -903,16 +954,43 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
syncService.clearConfiguration()
|
viewModel.syncService.clearConfiguration()
|
||||||
serverUrl = ""
|
|
||||||
authToken = ""
|
|
||||||
showDisconnectDialog = false
|
showDisconnectDialog = false
|
||||||
}
|
}
|
||||||
) { Text("Disconnect", color = MaterialTheme.colorScheme.error) }
|
) { Text("Disconnect") }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
|
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()
|
||||||
|
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
|
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.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
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 android.app.Activity
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -88,7 +88,7 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OpenClimbTheme(
|
fun AscentlyTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
// Dynamic color is available on Android 12+ and provides full Material You theming
|
// Dynamic color is available on Android 12+ and provides full Material You theming
|
||||||
// When enabled, it adapts to the user's system wallpaper colors
|
// When enabled, it adapts to the user's system wallpaper colors
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.ui.theme
|
package com.atridad.ascently.ui.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -1,23 +1,27 @@
|
|||||||
package com.atridad.openclimb.ui.viewmodel
|
package com.atridad.ascently.ui.viewmodel
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.ascently.data.health.HealthConnectManager
|
||||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
import com.atridad.ascently.data.model.*
|
||||||
import com.atridad.openclimb.data.sync.SyncService
|
import com.atridad.ascently.data.repository.ClimbRepository
|
||||||
import com.atridad.openclimb.service.SessionTrackingService
|
import com.atridad.ascently.data.sync.SyncService
|
||||||
import com.atridad.openclimb.utils.ImageUtils
|
import com.atridad.ascently.service.SessionTrackingService
|
||||||
import com.atridad.openclimb.utils.SessionShareUtils
|
import com.atridad.ascently.utils.ImageUtils
|
||||||
import com.atridad.openclimb.widget.ClimbStatsWidgetProvider
|
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
class ClimbViewModel(private val repository: ClimbRepository, val syncService: SyncService) :
|
class ClimbViewModel(
|
||||||
ViewModel() {
|
private val repository: ClimbRepository,
|
||||||
|
val syncService: SyncService,
|
||||||
|
private val context: Context
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// Health Connect manager
|
||||||
|
private val healthConnectManager = HealthConnectManager(context)
|
||||||
|
|
||||||
// UI State flows
|
// UI State flows
|
||||||
private val _uiState = MutableStateFlow(ClimbUiState())
|
private val _uiState = MutableStateFlow(ClimbUiState())
|
||||||
@@ -70,119 +74,157 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Gym operations
|
// Gym operations
|
||||||
fun addGym(gym: Gym) {
|
fun addGym(gym: Gym, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.insertGym(gym) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addGym(gym: Gym, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertGym(gym)
|
repository.insertGym(gym)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateGym(gym: Gym) {
|
fun updateGym(gym: Gym, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.updateGym(gym) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateGym(gym: Gym, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateGym(gym)
|
repository.updateGym(gym)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteGym(gym: Gym) {
|
fun deleteGym(gym: Gym, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.deleteGym(gym) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteGym(gym: Gym, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteGym(gym)
|
repository.deleteGym(gym)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
|
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
|
||||||
|
|
||||||
// Problem operations
|
// Problem operations
|
||||||
fun addProblem(problem: Problem) {
|
fun addProblem(problem: Problem, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.insertProblem(problem) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addProblem(problem: Problem, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertProblem(problem)
|
val finalProblem = renameTemporaryImages(problem)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
repository.insertProblem(finalProblem)
|
||||||
// Auto-sync now happens automatically via repository callback
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProblem(problem: Problem) {
|
private suspend fun renameTemporaryImages(problem: Problem): Problem {
|
||||||
viewModelScope.launch { repository.updateProblem(problem) }
|
if (problem.imagePaths.isEmpty()) {
|
||||||
|
return problem
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalImagePaths = mutableListOf<String>()
|
||||||
|
|
||||||
|
problem.imagePaths.forEachIndexed { index, tempPath ->
|
||||||
|
if (tempPath.startsWith("temp_")) {
|
||||||
|
val finalPath =
|
||||||
|
ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index)
|
||||||
|
finalImagePaths.add(finalPath ?: tempPath)
|
||||||
|
} else {
|
||||||
|
finalImagePaths.add(tempPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return problem.copy(imagePaths = finalImagePaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProblem(problem: Problem, context: Context) {
|
fun updateProblem(problem: Problem, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateProblem(problem)
|
val finalProblem = renameTemporaryImages(problem)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
repository.updateProblem(finalProblem)
|
||||||
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteProblem(problem: Problem, context: Context) {
|
fun deleteProblem(problem: Problem, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Delete associated images
|
|
||||||
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
|
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
|
||||||
|
|
||||||
repository.deleteProblem(problem)
|
repository.deleteProblem(problem)
|
||||||
|
cleanupOrphanedImages()
|
||||||
cleanupOrphanedImages(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun cleanupOrphanedImages(context: Context) {
|
private suspend fun cleanupOrphanedImages() {
|
||||||
val allProblems = repository.getAllProblems().first()
|
val allProblems = repository.getAllProblems().first()
|
||||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||||
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
|
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun deleteAllImages() {
|
||||||
|
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 getProblemById(id: String): Flow<Problem?> = flow { emit(repository.getProblemById(id)) }
|
||||||
|
|
||||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
|
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
|
||||||
|
|
||||||
// Session operations
|
// Session operations
|
||||||
fun addSession(session: ClimbSession) {
|
fun addSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.insertSession(session) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addSession(session: ClimbSession, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertSession(session)
|
repository.insertSession(session)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSession(session: ClimbSession) {
|
fun updateSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.updateSession(session) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateSession(session: ClimbSession, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateSession(session)
|
repository.updateSession(session)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteSession(session: ClimbSession) {
|
fun deleteSession(session: ClimbSession, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.deleteSession(session) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteSession(session: ClimbSession, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteSession(session)
|
repository.deleteSession(session)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +243,7 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
|
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
|
||||||
|
|
||||||
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
|
if (!com.atridad.ascently.utils.NotificationPermissionUtils
|
||||||
.isNotificationPermissionGranted(context)
|
.isNotificationPermissionGranted(context)
|
||||||
) {
|
) {
|
||||||
android.util.Log.d("ClimbViewModel", "Notification permission not granted")
|
android.util.Log.d("ClimbViewModel", "Notification permission not granted")
|
||||||
@@ -240,14 +282,13 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
|||||||
|
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
|
||||||
android.util.Log.d("ClimbViewModel", "Session started successfully")
|
|
||||||
_uiState.value = _uiState.value.copy(message = "Session started successfully!")
|
_uiState.value = _uiState.value.copy(message = "Session started successfully!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun endSession(context: Context, sessionId: String) {
|
fun endSession(context: Context, sessionId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
|
if (!com.atridad.ascently.utils.NotificationPermissionUtils
|
||||||
.isNotificationPermissionGranted(context)
|
.isNotificationPermissionGranted(context)
|
||||||
) {
|
) {
|
||||||
_uiState.value =
|
_uiState.value =
|
||||||
@@ -268,7 +309,7 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
|||||||
|
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
|
||||||
// Auto-sync now happens automatically via repository callback
|
syncToHealthConnect(completedSession)
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(message = "Session completed!")
|
_uiState.value = _uiState.value.copy(message = "Session completed!")
|
||||||
}
|
}
|
||||||
@@ -287,37 +328,30 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attempt operations
|
// Attempt operations
|
||||||
fun addAttempt(attempt: Attempt) {
|
fun addAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.insertAttempt(attempt) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addAttempt(attempt: Attempt, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertAttempt(attempt)
|
repository.insertAttempt(attempt)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
// Auto-sync now happens automatically via repository callback
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteAttempt(attempt: Attempt) {
|
fun deleteAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.deleteAttempt(attempt) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteAttempt(attempt: Attempt, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteAttempt(attempt)
|
repository.deleteAttempt(attempt)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateAttempt(attempt: Attempt) {
|
fun updateAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
|
||||||
viewModelScope.launch { repository.updateAttempt(attempt) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateAttempt(attempt: Attempt, context: Context) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateAttempt(attempt)
|
repository.updateAttempt(attempt)
|
||||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
if (updateWidgets) {
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +393,7 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
|||||||
|
|
||||||
if (!file.name.lowercase().endsWith(".zip")) {
|
if (!file.name.lowercase().endsWith(".zip")) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
|
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +444,10 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
|||||||
_uiState.value = _uiState.value.copy(error = message)
|
_uiState.value = _uiState.value.copy(error = message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMessage(message: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(message = message)
|
||||||
|
}
|
||||||
|
|
||||||
fun resetAllData() {
|
fun resetAllData() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -429,32 +467,39 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Share operations
|
private fun syncToHealthConnect(session: ClimbSession) {
|
||||||
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
|
viewModelScope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
try {
|
||||||
try {
|
val gym = repository.getGymById(session.gymId)
|
||||||
val session = repository.getSessionById(sessionId) ?: return@withContext null
|
val gymName = gym?.name ?: "Unknown Gym"
|
||||||
val attempts = repository.getAttemptsBySession(sessionId).first()
|
|
||||||
val problems =
|
|
||||||
repository.getAllProblems().first().filter { problem ->
|
|
||||||
attempts.any { it.problemId == problem.id }
|
|
||||||
}
|
|
||||||
val gym = repository.getGymById(session.gymId) ?: return@withContext null
|
|
||||||
|
|
||||||
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
|
val attempts = repository.getAttemptsBySession(session.id).first()
|
||||||
SessionShareUtils.generateShareCard(context, session, gym, stats)
|
val attemptCount = attempts.size
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value =
|
val result =
|
||||||
_uiState.value.copy(
|
healthConnectManager.autoSyncCompletedSession(
|
||||||
error = "Failed to generate share card: ${e.message}"
|
session,
|
||||||
)
|
gymName,
|
||||||
null
|
attemptCount
|
||||||
|
)
|
||||||
|
|
||||||
|
result.onFailure { error ->
|
||||||
|
if (healthConnectManager.isReadySync()) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"ClimbViewModel",
|
||||||
|
"Health Connect sync failed: ${error.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (healthConnectManager.isReadySync()) {
|
||||||
|
android.util.Log.w("ClimbViewModel", "Health Connect sync error: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
fun shareSessionCard(context: Context, imageFile: File) {
|
|
||||||
SessionShareUtils.shareSessionCard(context, imageFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getHealthConnectManager(): HealthConnectManager = healthConnectManager
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ClimbUiState(
|
data class ClimbUiState(
|
||||||
@@ -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.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
import com.atridad.ascently.data.repository.ClimbRepository
|
||||||
import com.atridad.openclimb.data.sync.SyncService
|
import com.atridad.ascently.data.sync.SyncService
|
||||||
|
|
||||||
class ClimbViewModelFactory(
|
class ClimbViewModelFactory(
|
||||||
private val repository: ClimbRepository,
|
private val repository: ClimbRepository,
|
||||||
private val syncService: SyncService
|
private val syncService: SyncService,
|
||||||
|
private val context: Context
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
|
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
|
||||||
return ClimbViewModel(repository, syncService) as T
|
return ClimbViewModel(repository, syncService, context) as T
|
||||||
}
|
}
|
||||||
throw IllegalArgumentException("Unknown ViewModel class")
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.atridad.openclimb.utils
|
package com.atridad.ascently.utils
|
||||||
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@@ -43,4 +45,33 @@ object DateFormatUtils {
|
|||||||
fun millisToISO8601(millis: Long): String {
|
fun millisToISO8601(millis: Long): String {
|
||||||
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
|
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a UTC ISO 8601 date string for display in local timezone This fixes the timezone
|
||||||
|
* display issue where UTC dates were shown as local dates
|
||||||
|
*/
|
||||||
|
fun formatDateForDisplay(dateString: String, pattern: String = "MMM dd, yyyy"): String {
|
||||||
|
return try {
|
||||||
|
val instant = parseISO8601(dateString)
|
||||||
|
if (instant != null) {
|
||||||
|
val localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault())
|
||||||
|
localDateTime.format(DateTimeFormatter.ofPattern(pattern))
|
||||||
|
} else {
|
||||||
|
// Fallback for malformed dates
|
||||||
|
dateString.take(10)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
dateString.take(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a UTC ISO 8601 date string to LocalDateTime in system timezone */
|
||||||
|
fun parseToLocalDateTime(dateString: String): LocalDateTime? {
|
||||||
|
return try {
|
||||||
|
val instant = parseISO8601(dateString)
|
||||||
|
instant?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.utils
|
package com.atridad.ascently.utils
|
||||||
|
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -13,18 +13,17 @@ object ImageNamingUtils {
|
|||||||
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
|
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
|
||||||
|
|
||||||
/** Generates a deterministic filename for a problem image */
|
/** Generates a deterministic filename for a problem image */
|
||||||
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
|
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
||||||
// Create a deterministic hash from problemId + timestamp + index
|
val input = "${problemId}_${imageIndex}"
|
||||||
val input = "${problemId}_${timestamp}_${imageIndex}"
|
|
||||||
val hash = createHash(input)
|
val hash = createHash(input)
|
||||||
|
|
||||||
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
|
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generates a deterministic filename using current timestamp */
|
/** Legacy method for backward compatibility */
|
||||||
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
@Suppress("UNUSED_PARAMETER")
|
||||||
val timestamp = DateFormatUtils.nowISO8601()
|
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
|
||||||
return generateImageFilename(problemId, timestamp, imageIndex)
|
return generateImageFilename(problemId, imageIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extracts problem ID from an image filename */
|
/** Extracts problem ID from an image filename */
|
||||||
@@ -41,9 +40,7 @@ object ImageNamingUtils {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// We can't extract the original problem ID from the hash,
|
return parts[1]
|
||||||
// but we can validate the format
|
|
||||||
return parts[1] // Return the hash as identifier
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates if a filename follows our naming convention */
|
/** Validates if a filename follows our naming convention */
|
||||||
@@ -63,15 +60,11 @@ object ImageNamingUtils {
|
|||||||
|
|
||||||
/** Migrates an existing filename to our naming convention */
|
/** Migrates an existing filename to our naming convention */
|
||||||
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
|
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
|
||||||
// If it's already using our convention, keep it
|
|
||||||
if (isValidImageFilename(oldFilename)) {
|
if (isValidImageFilename(oldFilename)) {
|
||||||
return oldFilename
|
return oldFilename
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new deterministic name
|
return generateImageFilename(problemId, imageIndex)
|
||||||
// Use a timestamp based on the old filename to maintain some consistency
|
|
||||||
val timestamp = DateFormatUtils.nowISO8601()
|
|
||||||
return generateImageFilename(problemId, timestamp, imageIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a deterministic hash from input string */
|
/** Creates a deterministic hash from input string */
|
||||||
@@ -90,7 +83,7 @@ object ImageNamingUtils {
|
|||||||
val renameMap = mutableMapOf<String, String>()
|
val renameMap = mutableMapOf<String, String>()
|
||||||
|
|
||||||
existingFilenames.forEachIndexed { index, oldFilename ->
|
existingFilenames.forEachIndexed { index, oldFilename ->
|
||||||
val newFilename = migrateFilename(oldFilename, problemId, index)
|
val newFilename = generateImageFilename(problemId, index)
|
||||||
if (newFilename != oldFilename) {
|
if (newFilename != oldFilename) {
|
||||||
renameMap[oldFilename] = newFilename
|
renameMap[oldFilename] = newFilename
|
||||||
}
|
}
|
||||||
@@ -98,4 +91,70 @@ object ImageNamingUtils {
|
|||||||
|
|
||||||
return renameMap
|
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 */
|
||||||
|
/** Validates that a collection of filenames follow our naming convention */
|
||||||
|
fun validateFilenames(filenames: List<String>): ImageValidationResult {
|
||||||
|
val validImages = mutableListOf<String>()
|
||||||
|
val invalidImages = mutableListOf<String>()
|
||||||
|
|
||||||
|
for (filename in filenames) {
|
||||||
|
if (isValidImageFilename(filename)) {
|
||||||
|
validImages.add(filename)
|
||||||
|
} else {
|
||||||
|
invalidImages.add(filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImageValidationResult(
|
||||||
|
totalImages = filenames.size,
|
||||||
|
validImages = validImages,
|
||||||
|
invalidImages = invalidImages
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createServerMigrationMap(
|
||||||
|
problemId: String,
|
||||||
|
serverImageFilenames: List<String>,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of image filename validation */
|
||||||
|
data class ImageValidationResult(
|
||||||
|
val totalImages: Int,
|
||||||
|
val validImages: List<String>,
|
||||||
|
val invalidImages: List<String>
|
||||||
|
) {
|
||||||
|
val isAllValid: Boolean
|
||||||
|
get() = invalidImages.isEmpty()
|
||||||
|
|
||||||
|
val validPercentage: Double
|
||||||
|
get() = if (totalImages > 0) (validImages.size.toDouble() / totalImages) * 100.0 else 100.0
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.atridad.openclimb.utils
|
package com.atridad.ascently.utils
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -17,7 +20,7 @@ object ImageUtils {
|
|||||||
private const val IMAGE_QUALITY = 85
|
private const val IMAGE_QUALITY = 85
|
||||||
|
|
||||||
// Creates the images directory if it doesn't exist
|
// 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)
|
val imagesDir = File(context.filesDir, IMAGES_DIR)
|
||||||
if (!imagesDir.exists()) {
|
if (!imagesDir.exists()) {
|
||||||
imagesDir.mkdirs()
|
imagesDir.mkdirs()
|
||||||
@@ -25,7 +28,57 @@ object ImageUtils {
|
|||||||
return imagesDir
|
return imagesDir
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saves an image from a URI with compression and proper orientation */
|
/** Saves an image from a URI while preserving EXIF orientation data */
|
||||||
|
private fun saveImageWithExif(
|
||||||
|
context: Context,
|
||||||
|
imageUri: Uri,
|
||||||
|
originalBitmap: Bitmap,
|
||||||
|
outputFile: File
|
||||||
|
): Boolean {
|
||||||
|
return try {
|
||||||
|
// Get EXIF data from original image
|
||||||
|
val originalExif =
|
||||||
|
context.contentResolver.openInputStream(imageUri)?.use { input ->
|
||||||
|
ExifInterface(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress and save the bitmap
|
||||||
|
val compressedBitmap = compressImage(originalBitmap)
|
||||||
|
FileOutputStream(outputFile).use { output ->
|
||||||
|
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy EXIF data to the saved file
|
||||||
|
originalExif?.let { sourceExif ->
|
||||||
|
val destExif = ExifInterface(outputFile.absolutePath)
|
||||||
|
|
||||||
|
// Copy orientation and other important EXIF attributes
|
||||||
|
val orientationValue = sourceExif.getAttribute(ExifInterface.TAG_ORIENTATION)
|
||||||
|
orientationValue?.let { destExif.setAttribute(ExifInterface.TAG_ORIENTATION, it) }
|
||||||
|
|
||||||
|
// Copy other useful EXIF data
|
||||||
|
sourceExif.getAttribute(ExifInterface.TAG_DATETIME)?.let {
|
||||||
|
destExif.setAttribute(ExifInterface.TAG_DATETIME, it)
|
||||||
|
}
|
||||||
|
sourceExif.getAttribute(ExifInterface.TAG_GPS_LATITUDE)?.let {
|
||||||
|
destExif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, it)
|
||||||
|
}
|
||||||
|
sourceExif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE)?.let {
|
||||||
|
destExif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
destExif.saveAttributes()
|
||||||
|
}
|
||||||
|
|
||||||
|
compressedBitmap.recycle()
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves an image from a URI with compression */
|
||||||
fun saveImageFromUri(
|
fun saveImageFromUri(
|
||||||
context: Context,
|
context: Context,
|
||||||
imageUri: Uri,
|
imageUri: Uri,
|
||||||
@@ -40,26 +93,18 @@ object ImageUtils {
|
|||||||
}
|
}
|
||||||
?: return null
|
?: return null
|
||||||
|
|
||||||
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
// Always require deterministic naming
|
||||||
val compressedBitmap = compressImage(orientedBitmap)
|
require(problemId != null && imageIndex != null) {
|
||||||
|
"Problem ID and image index are required for deterministic image naming"
|
||||||
|
}
|
||||||
|
|
||||||
val filename =
|
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
||||||
if (problemId != null && imageIndex != null) {
|
|
||||||
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
|
||||||
} else {
|
|
||||||
"${UUID.randomUUID()}.jpg"
|
|
||||||
}
|
|
||||||
val imageFile = File(getImagesDirectory(context), filename)
|
val imageFile = File(getImagesDirectory(context), filename)
|
||||||
|
|
||||||
FileOutputStream(imageFile).use { output ->
|
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
|
||||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
originalBitmap.recycle()
|
originalBitmap.recycle()
|
||||||
if (orientedBitmap != originalBitmap) {
|
|
||||||
orientedBitmap.recycle()
|
if (!success) return null
|
||||||
}
|
|
||||||
compressedBitmap.recycle()
|
|
||||||
|
|
||||||
"$IMAGES_DIR/$filename"
|
"$IMAGES_DIR/$filename"
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -73,35 +118,35 @@ object ImageUtils {
|
|||||||
return try {
|
return try {
|
||||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||||
inputStream?.use { input ->
|
inputStream?.use { input ->
|
||||||
val exif = android.media.ExifInterface(input)
|
val exif = androidx.exifinterface.media.ExifInterface(input)
|
||||||
val orientation =
|
val orientation =
|
||||||
exif.getAttributeInt(
|
exif.getAttributeInt(
|
||||||
android.media.ExifInterface.TAG_ORIENTATION,
|
androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
|
||||||
android.media.ExifInterface.ORIENTATION_NORMAL
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL
|
||||||
)
|
)
|
||||||
|
|
||||||
val matrix = android.graphics.Matrix()
|
val matrix = android.graphics.Matrix()
|
||||||
when (orientation) {
|
when (orientation) {
|
||||||
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
||||||
matrix.postRotate(90f)
|
matrix.postRotate(90f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
||||||
matrix.postRotate(180f)
|
matrix.postRotate(180f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
||||||
matrix.postRotate(270f)
|
matrix.postRotate(270f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||||
matrix.postScale(-1f, 1f)
|
matrix.postScale(-1f, 1f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||||
matrix.postScale(1f, -1f)
|
matrix.postScale(1f, -1f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
matrix.postRotate(90f)
|
matrix.postRotate(90f)
|
||||||
matrix.postScale(-1f, 1f)
|
matrix.postScale(-1f, 1f)
|
||||||
}
|
}
|
||||||
android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
|
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
matrix.postRotate(-90f)
|
matrix.postRotate(-90f)
|
||||||
matrix.postScale(-1f, 1f)
|
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 */
|
/** Saves an image from byte array to app's private storage */
|
||||||
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
|
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
|
||||||
return try {
|
return try {
|
||||||
@@ -247,21 +348,40 @@ object ImageUtils {
|
|||||||
filename: String
|
filename: String
|
||||||
): String? {
|
): String? {
|
||||||
return try {
|
return try {
|
||||||
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
|
|
||||||
|
|
||||||
val compressedBitmap = compressImage(bitmap)
|
|
||||||
|
|
||||||
// Use the provided filename instead of generating a new UUID
|
|
||||||
val imageFile = File(getImagesDirectory(context), filename)
|
val imageFile = File(getImagesDirectory(context), filename)
|
||||||
|
|
||||||
// Save compressed image
|
// Check if image is too large and needs compression
|
||||||
FileOutputStream(imageFile).use { output ->
|
if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold
|
||||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
// 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
|
// Save compressed image
|
||||||
bitmap.recycle()
|
FileOutputStream(imageFile).use { output ->
|
||||||
compressedBitmap.recycle()
|
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
|
// Return relative path
|
||||||
"$IMAGES_DIR/$filename"
|
"$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.Manifest
|
||||||
import android.content.Context
|
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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -7,11 +7,9 @@ import android.graphics.drawable.GradientDrawable
|
|||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.graphics.createBitmap
|
import androidx.core.graphics.createBitmap
|
||||||
import androidx.core.graphics.toColorInt
|
import androidx.core.graphics.toColorInt
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
object SessionShareUtils {
|
object SessionShareUtils {
|
||||||
@@ -382,7 +380,7 @@ object SessionShareUtils {
|
|||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
textAlign = Paint.Align.CENTER
|
textAlign = Paint.Align.CENTER
|
||||||
}
|
}
|
||||||
canvas.drawText("OpenClimb", width / 2f, height - 40f, brandingPaint)
|
canvas.drawText("Ascently", width / 2f, height - 40f, brandingPaint)
|
||||||
|
|
||||||
// Save to file
|
// Save to file
|
||||||
val shareDir = File(context.cacheDir, "shares")
|
val shareDir = File(context.cacheDir, "shares")
|
||||||
@@ -457,14 +455,7 @@ object SessionShareUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun formatSessionDate(dateString: String): String {
|
private fun formatSessionDate(dateString: String): String {
|
||||||
return try {
|
return DateFormatUtils.formatDateForDisplay(dateString, "MMMM dd, yyyy")
|
||||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
|
||||||
val date = LocalDateTime.parse(dateString, formatter)
|
|
||||||
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
|
|
||||||
date.format(displayFormatter)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
dateString.take(10)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shareSessionCard(context: Context, imageFile: File) {
|
fun shareSessionCard(context: Context, imageFile: File) {
|
||||||
@@ -481,7 +472,7 @@ object SessionShareUtils {
|
|||||||
action = Intent.ACTION_SEND
|
action = Intent.ACTION_SEND
|
||||||
type = "image/png"
|
type = "image/png"
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! #OpenClimb")
|
putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! #Ascently")
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,7 +503,7 @@ object SessionShareUtils {
|
|||||||
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
|
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
|
||||||
}
|
}
|
||||||
DifficultySystem.FONT -> {
|
DifficultySystem.FONT -> {
|
||||||
val list = DifficultySystem.FONT.getAvailableGrades()
|
val list = DifficultySystem.FONT.availableGrades
|
||||||
val idx = list.indexOf(grade.uppercase())
|
val idx = list.indexOf(grade.uppercase())
|
||||||
if (idx >= 0) idx.toDouble()
|
if (idx >= 0) idx.toDouble()
|
||||||
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
|
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.utils
|
package com.atridad.ascently.utils
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -7,23 +7,23 @@ import android.content.pm.ShortcutManager
|
|||||||
import android.graphics.drawable.Icon
|
import android.graphics.drawable.Icon
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import com.atridad.openclimb.MainActivity
|
import com.atridad.ascently.MainActivity
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.ascently.R
|
||||||
|
|
||||||
object AppShortcutManager {
|
object AppShortcutManager {
|
||||||
|
|
||||||
const val SHORTCUT_START_SESSION = "start_session"
|
const val SHORTCUT_START_SESSION = "start_session"
|
||||||
const val SHORTCUT_END_SESSION = "end_session"
|
const val SHORTCUT_END_SESSION = "end_session"
|
||||||
|
|
||||||
const val ACTION_START_SESSION = "com.atridad.openclimb.action.START_SESSION"
|
const val ACTION_START_SESSION = "com.atridad.ascently.action.START_SESSION"
|
||||||
const val ACTION_END_SESSION = "com.atridad.openclimb.action.END_SESSION"
|
const val ACTION_END_SESSION = "com.atridad.ascently.action.END_SESSION"
|
||||||
|
|
||||||
/** Updates the app shortcuts based on current session state */
|
/** Updates the app shortcuts based on current session state */
|
||||||
fun updateShortcuts(
|
fun updateShortcuts(
|
||||||
context: Context,
|
context: Context,
|
||||||
hasActiveSession: Boolean,
|
hasActiveSession: Boolean,
|
||||||
hasGyms: Boolean,
|
hasGyms: Boolean,
|
||||||
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
|
lastUsedGym: com.atridad.ascently.data.model.Gym? = null
|
||||||
) {
|
) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||||
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
|
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
|
||||||
@@ -45,7 +45,7 @@ object AppShortcutManager {
|
|||||||
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
||||||
private fun createStartSessionShortcut(
|
private fun createStartSessionShortcut(
|
||||||
context: Context,
|
context: Context,
|
||||||
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
|
lastUsedGym: com.atridad.ascently.data.model.Gym? = null
|
||||||
): ShortcutInfo {
|
): ShortcutInfo {
|
||||||
val startIntent =
|
val startIntent =
|
||||||
Intent(context, MainActivity::class.java).apply {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.atridad.openclimb.utils
|
package com.atridad.ascently.utils
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.atridad.openclimb.data.format.BackupProblem
|
import com.atridad.ascently.data.format.BackupProblem
|
||||||
import com.atridad.openclimb.data.format.ClimbDataBackup
|
import com.atridad.ascently.data.format.ClimbDataBackup
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -33,14 +33,14 @@ object ZipExportImportUtils {
|
|||||||
context.getExternalFilesDir(
|
context.getExternalFilesDir(
|
||||||
android.os.Environment.DIRECTORY_DOCUMENTS
|
android.os.Environment.DIRECTORY_DOCUMENTS
|
||||||
),
|
),
|
||||||
"OpenClimb"
|
"Ascently"
|
||||||
)
|
)
|
||||||
if (!exportDir.exists()) {
|
if (!exportDir.exists()) {
|
||||||
exportDir.mkdirs()
|
exportDir.mkdirs()
|
||||||
}
|
}
|
||||||
|
|
||||||
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
|
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
|
||||||
val zipFile = File(exportDir, "openclimb_export_$timestamp.zip")
|
val zipFile = File(exportDir, "ascently_export_$timestamp.zip")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
|
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
|
||||||
@@ -182,8 +182,8 @@ object ZipExportImportUtils {
|
|||||||
referencedImagePaths: Set<String>
|
referencedImagePaths: Set<String>
|
||||||
): String {
|
): String {
|
||||||
return buildString {
|
return buildString {
|
||||||
appendLine("OpenClimb Export Metadata")
|
appendLine("Ascently Export Metadata")
|
||||||
appendLine("=======================")
|
appendLine("========================")
|
||||||
appendLine("Export Date: ${exportData.exportedAt}")
|
appendLine("Export Date: ${exportData.exportedAt}")
|
||||||
appendLine("Version: ${exportData.version}")
|
appendLine("Version: ${exportData.version}")
|
||||||
appendLine("Gyms: ${exportData.gyms.size}")
|
appendLine("Gyms: ${exportData.gyms.size}")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb.widget
|
package com.atridad.ascently.widget
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
@@ -7,10 +7,10 @@ import android.content.ComponentName
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import com.atridad.openclimb.MainActivity
|
import com.atridad.ascently.MainActivity
|
||||||
import com.atridad.openclimb.R
|
import com.atridad.ascently.R
|
||||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
import com.atridad.ascently.data.database.AscentlyDatabase
|
||||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
import com.atridad.ascently.data.repository.ClimbRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -45,7 +45,7 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
|||||||
) {
|
) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
val database = OpenClimbDatabase.getDatabase(context)
|
val database = AscentlyDatabase.getDatabase(context)
|
||||||
val repository = ClimbRepository(database, context)
|
val repository = ClimbRepository(database, context)
|
||||||
|
|
||||||
// Fetch stats data
|
// Fetch stats data
|
||||||
@@ -65,10 +65,10 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
|||||||
attempts.any { attempt ->
|
attempts.any { attempt ->
|
||||||
attempt.problemId == problem.id &&
|
attempt.problemId == problem.id &&
|
||||||
(attempt.result ==
|
(attempt.result ==
|
||||||
com.atridad.openclimb.data.model
|
com.atridad.ascently.data.model
|
||||||
.AttemptResult.SUCCESS ||
|
.AttemptResult.SUCCESS ||
|
||||||
attempt.result ==
|
attempt.result ==
|
||||||
com.atridad.openclimb.data.model
|
com.atridad.ascently.data.model
|
||||||
.AttemptResult.FLASH)
|
.AttemptResult.FLASH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,17 +0,0 @@
|
|||||||
package com.atridad.openclimb.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class ClimbType {
|
|
||||||
ROPE,
|
|
||||||
BOULDER;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the display name
|
|
||||||
*/
|
|
||||||
fun getDisplayName(): String = when (this) {
|
|
||||||
ROPE -> "Rope"
|
|
||||||
BOULDER -> "Bouldering"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
package com.atridad.openclimb.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class DifficultySystem {
|
|
||||||
// Bouldering
|
|
||||||
V_SCALE,
|
|
||||||
FONT,
|
|
||||||
|
|
||||||
// Rope
|
|
||||||
YDS,
|
|
||||||
CUSTOM;
|
|
||||||
|
|
||||||
/** Get the display name for the UI */
|
|
||||||
fun getDisplayName(): String =
|
|
||||||
when (this) {
|
|
||||||
V_SCALE -> "V Scale"
|
|
||||||
FONT -> "Font Scale"
|
|
||||||
YDS -> "YDS (Yosemite)"
|
|
||||||
CUSTOM -> "Custom"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if this system is for bouldering */
|
|
||||||
fun isBoulderingSystem(): Boolean =
|
|
||||||
when (this) {
|
|
||||||
V_SCALE, FONT -> true
|
|
||||||
YDS -> false
|
|
||||||
CUSTOM -> true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if this system is for rope climbing */
|
|
||||||
fun isRopeSystem(): Boolean =
|
|
||||||
when (this) {
|
|
||||||
YDS -> true
|
|
||||||
V_SCALE, FONT -> false
|
|
||||||
CUSTOM -> true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get available grades for this system */
|
|
||||||
fun getAvailableGrades(): List<String> =
|
|
||||||
when (this) {
|
|
||||||
V_SCALE ->
|
|
||||||
listOf(
|
|
||||||
"VB",
|
|
||||||
"V0",
|
|
||||||
"V1",
|
|
||||||
"V2",
|
|
||||||
"V3",
|
|
||||||
"V4",
|
|
||||||
"V5",
|
|
||||||
"V6",
|
|
||||||
"V7",
|
|
||||||
"V8",
|
|
||||||
"V9",
|
|
||||||
"V10",
|
|
||||||
"V11",
|
|
||||||
"V12",
|
|
||||||
"V13",
|
|
||||||
"V14",
|
|
||||||
"V15",
|
|
||||||
"V16",
|
|
||||||
"V17"
|
|
||||||
)
|
|
||||||
FONT ->
|
|
||||||
listOf(
|
|
||||||
"3",
|
|
||||||
"4A",
|
|
||||||
"4B",
|
|
||||||
"4C",
|
|
||||||
"5A",
|
|
||||||
"5B",
|
|
||||||
"5C",
|
|
||||||
"6A",
|
|
||||||
"6A+",
|
|
||||||
"6B",
|
|
||||||
"6B+",
|
|
||||||
"6C",
|
|
||||||
"6C+",
|
|
||||||
"7A",
|
|
||||||
"7A+",
|
|
||||||
"7B",
|
|
||||||
"7B+",
|
|
||||||
"7C",
|
|
||||||
"7C+",
|
|
||||||
"8A",
|
|
||||||
"8A+",
|
|
||||||
"8B",
|
|
||||||
"8B+",
|
|
||||||
"8C",
|
|
||||||
"8C+"
|
|
||||||
)
|
|
||||||
YDS ->
|
|
||||||
listOf(
|
|
||||||
"5.0",
|
|
||||||
"5.1",
|
|
||||||
"5.2",
|
|
||||||
"5.3",
|
|
||||||
"5.4",
|
|
||||||
"5.5",
|
|
||||||
"5.6",
|
|
||||||
"5.7",
|
|
||||||
"5.8",
|
|
||||||
"5.9",
|
|
||||||
"5.10a",
|
|
||||||
"5.10b",
|
|
||||||
"5.10c",
|
|
||||||
"5.10d",
|
|
||||||
"5.11a",
|
|
||||||
"5.11b",
|
|
||||||
"5.11c",
|
|
||||||
"5.11d",
|
|
||||||
"5.12a",
|
|
||||||
"5.12b",
|
|
||||||
"5.12c",
|
|
||||||
"5.12d",
|
|
||||||
"5.13a",
|
|
||||||
"5.13b",
|
|
||||||
"5.13c",
|
|
||||||
"5.13d",
|
|
||||||
"5.14a",
|
|
||||||
"5.14b",
|
|
||||||
"5.14c",
|
|
||||||
"5.14d",
|
|
||||||
"5.15a",
|
|
||||||
"5.15b",
|
|
||||||
"5.15c",
|
|
||||||
"5.15d"
|
|
||||||
)
|
|
||||||
CUSTOM -> emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** Get all difficulty systems based on type */
|
|
||||||
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
|
|
||||||
when (climbType) {
|
|
||||||
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }
|
|
||||||
ClimbType.ROPE -> entries.filter { it.isRopeSystem() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DifficultyGrade(val system: DifficultySystem, val grade: String, val numericValue: Int) {
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
system: DifficultySystem,
|
|
||||||
grade: String
|
|
||||||
) : this(system = system, grade = grade, numericValue = calculateNumericValue(system, grade))
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private fun calculateNumericValue(system: DifficultySystem, grade: String): Int {
|
|
||||||
return when (system) {
|
|
||||||
DifficultySystem.V_SCALE -> {
|
|
||||||
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
|
|
||||||
}
|
|
||||||
DifficultySystem.YDS -> {
|
|
||||||
when {
|
|
||||||
grade.startsWith("5.10") ->
|
|
||||||
10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
grade.startsWith("5.11") ->
|
|
||||||
14 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
grade.startsWith("5.12") ->
|
|
||||||
18 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
grade.startsWith("5.13") ->
|
|
||||||
22 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
grade.startsWith("5.14") ->
|
|
||||||
26 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
grade.startsWith("5.15") ->
|
|
||||||
30 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
|
||||||
else -> grade.removePrefix("5.").toIntOrNull() ?: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DifficultySystem.FONT -> {
|
|
||||||
when {
|
|
||||||
grade.startsWith("6A") -> 6
|
|
||||||
grade.startsWith("6B") -> 7
|
|
||||||
grade.startsWith("6C") -> 8
|
|
||||||
grade.startsWith("7A") -> 9
|
|
||||||
grade.startsWith("7B") -> 10
|
|
||||||
grade.startsWith("7C") -> 11
|
|
||||||
grade.startsWith("8A") -> 12
|
|
||||||
grade.startsWith("8B") -> 13
|
|
||||||
grade.startsWith("8C") -> 14
|
|
||||||
else -> grade.toIntOrNull() ?: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DifficultySystem.CUSTOM -> grade.hashCode().rem(100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Compare this grade with another grade of the same system Returns negative if this grade is
|
|
||||||
* easier, positive if harder, 0 if equal
|
|
||||||
*/
|
|
||||||
fun compareTo(other: DifficultyGrade): Int {
|
|
||||||
if (system != other.system) return 0
|
|
||||||
|
|
||||||
return when (system) {
|
|
||||||
DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade)
|
|
||||||
DifficultySystem.FONT -> compareFontGrades(grade, other.grade)
|
|
||||||
DifficultySystem.YDS -> compareYDSGrades(grade, other.grade)
|
|
||||||
DifficultySystem.CUSTOM -> grade.compareTo(other.grade)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
|
|
||||||
if (grade1 == "VB" && grade2 != "VB") return -1
|
|
||||||
if (grade2 == "VB" && grade1 != "VB") return 1
|
|
||||||
if (grade1 == "VB" && grade2 == "VB") return 0
|
|
||||||
|
|
||||||
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
|
|
||||||
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
|
|
||||||
return num1.compareTo(num2)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun compareFontGrades(grade1: String, grade2: String): Int {
|
|
||||||
return grade1.compareTo(grade2)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun compareYDSGrades(grade1: String, grade2: String): Int {
|
|
||||||
return grade1.compareTo(grade2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,182 +0,0 @@
|
|||||||
package com.atridad.openclimb.ui.components
|
|
||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import com.atridad.openclimb.utils.ImageUtils
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ImagePicker(
|
|
||||||
imageUris: List<String>,
|
|
||||||
onImagesChanged: (List<String>) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
maxImages: Int = 5
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
var tempImageUris by remember { mutableStateOf(imageUris) }
|
|
||||||
|
|
||||||
// Image picker launcher
|
|
||||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.GetMultipleContents()
|
|
||||||
) { uris ->
|
|
||||||
if (uris.isNotEmpty()) {
|
|
||||||
val currentCount = tempImageUris.size
|
|
||||||
val remainingSlots = maxImages - currentCount
|
|
||||||
val urisToProcess = uris.take(remainingSlots)
|
|
||||||
|
|
||||||
// Process images
|
|
||||||
val newImagePaths = mutableListOf<String>()
|
|
||||||
urisToProcess.forEach { uri ->
|
|
||||||
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
|
||||||
if (imagePath != null) {
|
|
||||||
newImagePaths.add(imagePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newImagePaths.isNotEmpty()) {
|
|
||||||
val updatedUris = tempImageUris + newImagePaths
|
|
||||||
tempImageUris = updatedUris
|
|
||||||
onImagesChanged(updatedUris)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Photos (${tempImageUris.size}/$maxImages)",
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
if (tempImageUris.size < maxImages) {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
imagePickerLauncher.launch("image/*")
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text("Add Photos")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tempImageUris.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
LazyRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
items(tempImageUris) { imagePath ->
|
|
||||||
ImageItem(
|
|
||||||
imagePath = imagePath,
|
|
||||||
onRemove = {
|
|
||||||
val updatedUris = tempImageUris.filter { it != imagePath }
|
|
||||||
tempImageUris = updatedUris
|
|
||||||
onImagesChanged(updatedUris)
|
|
||||||
|
|
||||||
// Delete the image file
|
|
||||||
ImageUtils.deleteImage(context, imagePath)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(100.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Add,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Add photos of this problem",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ImageItem(
|
|
||||||
imagePath: String,
|
|
||||||
onRemove: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier.size(80.dp)
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = imageFile,
|
|
||||||
contentDescription = "Problem photo",
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = onRemove,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.size(24.dp)
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = "Remove photo",
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(2.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="OpenClimb"
|
android:text="Ascently"
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@color/widget_text_primary" />
|
android:textColor="@color/widget_text_primary" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">OpenClimb</string>
|
<string name="app_name">Ascently</string>
|
||||||
<string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string>
|
<string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string>
|
||||||
|
|
||||||
<!-- App Shortcuts -->
|
<!-- App Shortcuts -->
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
|
<style name="Theme.Ascently" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
|
||||||
<style name="Theme.OpenClimb.Splash" parent="Theme.OpenClimb">
|
<style name="Theme.Ascently.Splash" parent="Theme.Ascently">
|
||||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
|
||||||
<item name="android:windowSplashScreenAnimationDuration">200</item>
|
<item name="android:windowSplashScreenAnimationDuration">200</item>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.atridad.openclimb
|
package com.atridad.ascently
|
||||||
|
|
||||||
import com.atridad.openclimb.data.format.*
|
import com.atridad.ascently.data.format.*
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.atridad.openclimb
|
package com.atridad.ascently
|
||||||
|
|
||||||
import com.atridad.openclimb.data.format.*
|
import com.atridad.ascently.data.format.*
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
@@ -18,8 +18,8 @@ class DataModelTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testClimbTypeDisplayNames() {
|
fun testClimbTypeDisplayNames() {
|
||||||
assertEquals("Rope", ClimbType.ROPE.getDisplayName())
|
assertEquals("Rope", ClimbType.ROPE.displayName)
|
||||||
assertEquals("Bouldering", ClimbType.BOULDER.getDisplayName())
|
assertEquals("Bouldering", ClimbType.BOULDER.displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -34,58 +34,58 @@ class DataModelTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDifficultySystemDisplayNames() {
|
fun testDifficultySystemDisplayNames() {
|
||||||
assertEquals("V Scale", DifficultySystem.V_SCALE.getDisplayName())
|
assertEquals("V Scale", DifficultySystem.V_SCALE.displayName)
|
||||||
assertEquals("YDS (Yosemite)", DifficultySystem.YDS.getDisplayName())
|
assertEquals("YDS (Yosemite)", DifficultySystem.YDS.displayName)
|
||||||
assertEquals("Font Scale", DifficultySystem.FONT.getDisplayName())
|
assertEquals("Font Scale", DifficultySystem.FONT.displayName)
|
||||||
assertEquals("Custom", DifficultySystem.CUSTOM.getDisplayName())
|
assertEquals("Custom", DifficultySystem.CUSTOM.displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDifficultySystemClimbTypeCompatibility() {
|
fun testDifficultySystemClimbTypeCompatibility() {
|
||||||
// Test bouldering systems
|
// Test bouldering systems
|
||||||
assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem())
|
assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem)
|
||||||
assertTrue(DifficultySystem.FONT.isBoulderingSystem())
|
assertTrue(DifficultySystem.FONT.isBoulderingSystem)
|
||||||
assertFalse(DifficultySystem.YDS.isBoulderingSystem())
|
assertFalse(DifficultySystem.YDS.isBoulderingSystem)
|
||||||
assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem())
|
assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem)
|
||||||
|
|
||||||
// Test rope systems
|
// Test rope systems
|
||||||
assertTrue(DifficultySystem.YDS.isRopeSystem())
|
assertTrue(DifficultySystem.YDS.isRopeSystem)
|
||||||
assertFalse(DifficultySystem.V_SCALE.isRopeSystem())
|
assertFalse(DifficultySystem.V_SCALE.isRopeSystem)
|
||||||
assertFalse(DifficultySystem.FONT.isRopeSystem())
|
assertFalse(DifficultySystem.FONT.isRopeSystem)
|
||||||
assertTrue(DifficultySystem.CUSTOM.isRopeSystem())
|
assertTrue(DifficultySystem.CUSTOM.isRopeSystem)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDifficultySystemAvailableGrades() {
|
fun testDifficultySystemAvailableGrades() {
|
||||||
val vScaleGrades = DifficultySystem.V_SCALE.getAvailableGrades()
|
val vScaleGrades = DifficultySystem.V_SCALE.availableGrades
|
||||||
assertTrue(vScaleGrades.contains("VB"))
|
assertTrue(vScaleGrades.contains("VB"))
|
||||||
assertTrue(vScaleGrades.contains("V0"))
|
assertTrue(vScaleGrades.contains("V0"))
|
||||||
assertTrue(vScaleGrades.contains("V17"))
|
assertTrue(vScaleGrades.contains("V17"))
|
||||||
assertEquals("VB", vScaleGrades.first())
|
assertEquals("VB", vScaleGrades.first())
|
||||||
|
|
||||||
val ydsGrades = DifficultySystem.YDS.getAvailableGrades()
|
val ydsGrades = DifficultySystem.YDS.availableGrades
|
||||||
assertTrue(ydsGrades.contains("5.0"))
|
assertTrue(ydsGrades.contains("5.0"))
|
||||||
assertTrue(ydsGrades.contains("5.15d"))
|
assertTrue(ydsGrades.contains("5.15d"))
|
||||||
assertTrue(ydsGrades.contains("5.10a"))
|
assertTrue(ydsGrades.contains("5.10a"))
|
||||||
|
|
||||||
val fontGrades = DifficultySystem.FONT.getAvailableGrades()
|
val fontGrades = DifficultySystem.FONT.availableGrades
|
||||||
assertTrue(fontGrades.contains("3"))
|
assertTrue(fontGrades.contains("3"))
|
||||||
assertTrue(fontGrades.contains("8C+"))
|
assertTrue(fontGrades.contains("8C+"))
|
||||||
assertTrue(fontGrades.contains("6A"))
|
assertTrue(fontGrades.contains("6A"))
|
||||||
|
|
||||||
val customGrades = DifficultySystem.CUSTOM.getAvailableGrades()
|
val customGrades = DifficultySystem.CUSTOM.availableGrades
|
||||||
assertTrue(customGrades.isEmpty())
|
assertTrue(customGrades.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDifficultySystemsForClimbType() {
|
fun testDifficultySystemsForClimbType() {
|
||||||
val boulderSystems = DifficultySystem.getSystemsForClimbType(ClimbType.BOULDER)
|
val boulderSystems = DifficultySystem.systemsForClimbType(ClimbType.BOULDER)
|
||||||
assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE))
|
assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE))
|
||||||
assertTrue(boulderSystems.contains(DifficultySystem.FONT))
|
assertTrue(boulderSystems.contains(DifficultySystem.FONT))
|
||||||
assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM))
|
assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM))
|
||||||
assertFalse(boulderSystems.contains(DifficultySystem.YDS))
|
assertFalse(boulderSystems.contains(DifficultySystem.YDS))
|
||||||
|
|
||||||
val ropeSystems = DifficultySystem.getSystemsForClimbType(ClimbType.ROPE)
|
val ropeSystems = DifficultySystem.systemsForClimbType(ClimbType.ROPE)
|
||||||
assertTrue(ropeSystems.contains(DifficultySystem.YDS))
|
assertTrue(ropeSystems.contains(DifficultySystem.YDS))
|
||||||
assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM))
|
assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM))
|
||||||
assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE))
|
assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.atridad.openclimb
|
package com.atridad.ascently
|
||||||
|
|
||||||
import com.atridad.openclimb.data.format.*
|
import com.atridad.ascently.data.format.*
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.ascently.data.model.*
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.atridad.openclimb
|
package com.atridad.ascently
|
||||||
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ androidxTestRunner = "1.7.0"
|
|||||||
androidxTestRules = "1.7.0"
|
androidxTestRules = "1.7.0"
|
||||||
lifecycleRuntimeKtx = "2.9.4"
|
lifecycleRuntimeKtx = "2.9.4"
|
||||||
activityCompose = "1.11.0"
|
activityCompose = "1.11.0"
|
||||||
composeBom = "2025.09.01"
|
composeBom = "2025.10.00"
|
||||||
room = "2.8.1"
|
room = "2.8.2"
|
||||||
navigation = "2.9.5"
|
navigation = "2.9.5"
|
||||||
viewmodel = "2.9.4"
|
viewmodel = "2.9.4"
|
||||||
kotlinxSerialization = "1.9.0"
|
kotlinxSerialization = "1.9.0"
|
||||||
kotlinxCoroutines = "1.10.2"
|
kotlinxCoroutines = "1.10.2"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
ksp = "2.2.20-2.0.3"
|
ksp = "2.2.20-2.0.3"
|
||||||
|
exifinterface = "1.3.6"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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
|
# Image Loading
|
||||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
|
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pluginManagement {
|
|||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
@@ -20,5 +21,6 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "OpenClimb"
|
rootProject.name = "Ascently"
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
79
docs/.dockerignore
Normal file
79
docs/.dockerignore
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Node modules
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Cache directories
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# Temporary folders
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
21
docs/.gitignore
vendored
Normal file
21
docs/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
80
docs/Dockerfile
Normal file
80
docs/Dockerfile
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
FROM node:24-alpine AS base
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
python3 \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
libc6-compat \
|
||||||
|
vips-dev \
|
||||||
|
curl
|
||||||
|
|
||||||
|
# Install pnpm globally
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# Configure pnpm
|
||||||
|
RUN pnpm config set store-dir /.pnpm-store
|
||||||
|
RUN pnpm config set network-timeout 300000
|
||||||
|
RUN pnpm config set fetch-retries 10
|
||||||
|
RUN pnpm config set fetch-retry-factor 2
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install dependencies with retry logic
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/.pnpm-store \
|
||||||
|
pnpm install --frozen-lockfile || \
|
||||||
|
(sleep 5 && pnpm install --frozen-lockfile) || \
|
||||||
|
(sleep 10 && pnpm install --frozen-lockfile --no-frozen-lockfile)
|
||||||
|
|
||||||
|
FROM base AS build-deps
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install all dependencies including dev dependencies
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/.pnpm-store \
|
||||||
|
pnpm install --frozen-lockfile || \
|
||||||
|
(sleep 5 && pnpm install --frozen-lockfile) || \
|
||||||
|
(sleep 10 && pnpm install --frozen-lockfile --no-frozen-lockfile)
|
||||||
|
|
||||||
|
FROM build-deps AS builder
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
|
||||||
|
# Install pnpm for runtime
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Copy production dependencies
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy package.json for any runtime needs
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV HOST=0.0.0.0 \
|
||||||
|
PORT=4321 \
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 4321
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
12
docs/README.md
Normal file
12
docs/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Ascently Documentation
|
||||||
|
|
||||||
|
Documentation site for Ascently.
|
||||||
|
|
||||||
|
This was built with [Astro Starlight](https://starlight.astro.build/).
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
```bash
|
||||||
|
cd Ascently/docs
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
61
docs/astro.config.mjs
Normal file
61
docs/astro.config.mjs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
import starlight from "@astrojs/starlight";
|
||||||
|
|
||||||
|
import node from "@astrojs/node";
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
site: "https://docs.ascently.app",
|
||||||
|
|
||||||
|
integrations: [
|
||||||
|
starlight({
|
||||||
|
title: "Ascently",
|
||||||
|
description:
|
||||||
|
"An offline-first FOSS climb tracking app with an optional sync server.",
|
||||||
|
logo: {
|
||||||
|
light: "./src/assets/logo.svg",
|
||||||
|
dark: "./src/assets/logo-dark.svg",
|
||||||
|
},
|
||||||
|
favicon: "/favicon.png",
|
||||||
|
social: [
|
||||||
|
{
|
||||||
|
icon: "seti:git",
|
||||||
|
label: "Gitea",
|
||||||
|
href: "https://git.atri.dad/atridad/Ascently",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "email",
|
||||||
|
label: "Contact",
|
||||||
|
href: "mailto:me@atri.dad",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sidebar: [
|
||||||
|
{
|
||||||
|
label: "Download",
|
||||||
|
link: "/download/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Self-Hosted Sync",
|
||||||
|
items: [
|
||||||
|
{ label: "Overview", slug: "sync/overview" },
|
||||||
|
{ label: "Quick Start", slug: "sync/quick-start" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Reference",
|
||||||
|
autogenerate: { directory: "reference" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Privacy",
|
||||||
|
link: "/privacy/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customCss: ["./src/styles/custom.css"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
adapter: node({
|
||||||
|
mode: "standalone",
|
||||||
|
}),
|
||||||
|
});
|
||||||
8
docs/docker-compose.yml
Normal file
8
docs/docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${IMAGE}
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT}:4321"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
restart: unless-stopped
|
||||||
33
docs/package.json
Normal file
33
docs/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "ascently-docs",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Documentation site for Ascently - FOSS climbing tracking app",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.atri.dad/atridad/Ascently.git",
|
||||||
|
"directory": "docs"
|
||||||
|
},
|
||||||
|
"author": "atridad <me@atri.dad>",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"climbing",
|
||||||
|
"tracking",
|
||||||
|
"documentation",
|
||||||
|
"astro",
|
||||||
|
"starlight"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/node": "^9.5.0",
|
||||||
|
"@astrojs/starlight": "^0.36.0",
|
||||||
|
"astro": "^5.14.5",
|
||||||
|
"sharp": "^0.34.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
4172
docs/pnpm-lock.yaml
generated
Normal file
4172
docs/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
docs/pnpm-workspace.yaml
Normal file
3
docs/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
- sharp
|
||||||
BIN
docs/public/favicon.ico
Normal file
BIN
docs/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
docs/public/favicon.png
Normal file
BIN
docs/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 731 B |
BIN
docs/src/assets/houston.webp
Normal file
BIN
docs/src/assets/houston.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
15
docs/src/assets/logo-dark.svg
Normal file
15
docs/src/assets/logo-dark.svg
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Left mountain (amber/yellow) -->
|
||||||
|
<polygon points="6,24 12,8 18,24"
|
||||||
|
fill="#FFC107"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Right mountain (red) -->
|
||||||
|
<polygon points="14,24 22,4 30,24"
|
||||||
|
fill="#F44336"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 475 B |
15
docs/src/assets/logo-highres.svg
Normal file
15
docs/src/assets/logo-highres.svg
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Left mountain (amber/yellow) -->
|
||||||
|
<polygon points="48,192 96,64 144,192"
|
||||||
|
fill="#FFC107"
|
||||||
|
stroke="#1C1C1C"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Right mountain (red) -->
|
||||||
|
<polygon points="112,192 176,32 240,192"
|
||||||
|
fill="#F44336"
|
||||||
|
stroke="#1C1C1C"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 490 B |
15
docs/src/assets/logo.svg
Normal file
15
docs/src/assets/logo.svg
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Left mountain (amber/yellow) -->
|
||||||
|
<polygon points="6,24 12,8 18,24"
|
||||||
|
fill="#FFC107"
|
||||||
|
stroke="#1C1C1C"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Right mountain (red) -->
|
||||||
|
<polygon points="14,24 22,4 30,24"
|
||||||
|
fill="#F44336"
|
||||||
|
stroke="#1C1C1C"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 475 B |
7
docs/src/content.config.ts
Normal file
7
docs/src/content.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineCollection } from 'astro:content';
|
||||||
|
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||||
|
import { docsSchema } from '@astrojs/starlight/schema';
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||||
|
};
|
||||||
26
docs/src/content/docs/download.md
Normal file
26
docs/src/content/docs/download.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
title: Download
|
||||||
|
description: Get Ascently on your Android or iOS device
|
||||||
|
---
|
||||||
|
|
||||||
|
## Android
|
||||||
|
|
||||||
|
### Option 1: Direct APK Download
|
||||||
|
Download the latest APK from the [Releases page](https://git.atri.dad/atridad/Ascently/releases).
|
||||||
|
|
||||||
|
### Option 2: Obtainium
|
||||||
|
Use Obtainium for automatic updates:
|
||||||
|
|
||||||
|
[<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.ascently%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FAscently%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22Ascently%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Ascently%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
### TestFlight Beta
|
||||||
|
Join the TestFlight beta: [https://testflight.apple.com/join/E2DYRGH8](https://testflight.apple.com/join/E2DYRGH8)
|
||||||
|
|
||||||
|
### App Store
|
||||||
|
App Store release coming soon.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Android 12+** or **iOS 17+**
|
||||||
62
docs/src/content/docs/index.mdx
Normal file
62
docs/src/content/docs/index.mdx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
title: Ascently
|
||||||
|
description: An offline-first FOSS climb tracking app with an optional sync server.
|
||||||
|
template: splash
|
||||||
|
hero:
|
||||||
|
tagline: Track your climbing sessions, routes, and progress.
|
||||||
|
image:
|
||||||
|
file: ../../assets/logo-highres.svg
|
||||||
|
alt: "Ascently app icon"
|
||||||
|
actions:
|
||||||
|
- text: Download
|
||||||
|
link: /download/
|
||||||
|
icon: right-arrow
|
||||||
|
- text: Docs
|
||||||
|
link: /sync/overview/
|
||||||
|
icon: right-arrow
|
||||||
|
variant: minimal
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
## About Ascently
|
||||||
|
|
||||||
|
_Formerly OpenClimb_
|
||||||
|
|
||||||
|
Ascently is an **offline-first FOSS** app designed to help climbers track their sessions, routes/problems, and overall progress. There is an optional self-hosted sync server and integrations with Apple Health and Health Connect. There are no analytics or tracking baked into any part of this project. I am committed to maintaining a transparent and open-source solution for climbers, ensuring that you have full control over your data and privacy.
|
||||||
|
|
||||||
|
<CardGrid stagger>
|
||||||
|
<Card title="Offline-First" icon="laptop">
|
||||||
|
Your data stays on your device. No internet connection required to track your climbing sessions.
|
||||||
|
</Card>
|
||||||
|
<Card title="Cross-Platform" icon="rocket">
|
||||||
|
Built using Jetpack Compose with Material You support on Android and SwiftUI on iOS.
|
||||||
|
</Card>
|
||||||
|
<Card title="Health Integration" icon="heart">
|
||||||
|
Integrates with Apple Health and Health Connect to track your fitness data.
|
||||||
|
</Card>
|
||||||
|
<Card title="Optional Sync" icon="cloud-download">
|
||||||
|
Run your own lightweight sync server to keep data synchronized across devices.
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Android:** Version 12+
|
||||||
|
- **iOS:** Version 17+
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
**Android:**
|
||||||
|
- Download the latest APK from the [Releases page](https://git.atri.dad/atridad/Ascently/releases)
|
||||||
|
- Use [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.ascently%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FAscently%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22Ascently%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Ascently%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D) for automatic updates
|
||||||
|
|
||||||
|
**iOS:**
|
||||||
|
- Join the [TestFlight Beta](https://testflight.apple.com/join/E2DYRGH8)
|
||||||
|
- App Store release coming soon
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built with ❤️ by Atridad Lahiji*
|
||||||
|
|
||||||
|
*Contact: me@atri.dad*
|
||||||
43
docs/src/content/docs/privacy.md
Normal file
43
docs/src/content/docs/privacy.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
title: Privacy Policy
|
||||||
|
description: Ascently's Privacy Policy
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last updated: September 29, 2025**
|
||||||
|
|
||||||
|
This Privacy Policy describes our policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
|
||||||
|
|
||||||
|
## No Data Collection
|
||||||
|
|
||||||
|
I do not collect any personal information, analytics, or data of any kind. This software is designed to be self-hosted or run entirely offline.
|
||||||
|
|
||||||
|
All data generated by or used with this software remains on your local machine or self-hosted environment under your control. I have no access to it.
|
||||||
|
|
||||||
|
## Optional Sync Server
|
||||||
|
|
||||||
|
You may optionally configure a sync server to synchronize your data across multiple devices. When using a sync server:
|
||||||
|
|
||||||
|
- Your data is transmitted only to the server you specify
|
||||||
|
- You are responsible for the server's security and privacy practices
|
||||||
|
- I have no access to or control over your chosen sync server
|
||||||
|
- All data remains encrypted during transmission
|
||||||
|
|
||||||
|
## Optional Health Data Integration
|
||||||
|
|
||||||
|
You may optionally integrate with Apple Health or Android Health Connect to import health and fitness data. When enabled:
|
||||||
|
|
||||||
|
- Health data is accessed only on your local device
|
||||||
|
- No health data is transmitted to me, but Apple or Google may receive data according to their respective privacy policies
|
||||||
|
- You control which health metrics are imported
|
||||||
|
- You can revoke access at any time through your device's health app settings
|
||||||
|
- Please refer to Apple's Privacy Policy or Google's Privacy Policy for information about their data practices
|
||||||
|
|
||||||
|
## No Tracking or Analytics
|
||||||
|
|
||||||
|
This software does not use cookies, tracking pixels, or any other analytics or tracking mechanisms. Your usage of the software is completely private.
|
||||||
|
|
||||||
|
## Contact Us
|
||||||
|
|
||||||
|
If you have any questions about this Privacy Policy, you can contact me:
|
||||||
|
|
||||||
|
* **By email:** me@atri.dad
|
||||||
51
docs/src/content/docs/reference/example.md
Normal file
51
docs/src/content/docs/reference/example.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
title: Sync Server API
|
||||||
|
description: API endpoints for the Ascently sync server
|
||||||
|
---
|
||||||
|
|
||||||
|
The sync server provides a minimal REST API for data synchronization.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All endpoints require an `Authorization: Bearer <your-auth-token>` header.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Data Sync
|
||||||
|
|
||||||
|
**GET /sync**
|
||||||
|
- Download `ascently.json` file
|
||||||
|
- Returns: JSON data file or 404 if no data exists
|
||||||
|
|
||||||
|
**POST /sync**
|
||||||
|
- Upload `ascently.json` file
|
||||||
|
- Body: JSON data
|
||||||
|
- Returns: Success confirmation
|
||||||
|
|
||||||
|
### Images
|
||||||
|
|
||||||
|
**GET /images/{imageName}**
|
||||||
|
- Download an image file
|
||||||
|
- Returns: Image file or 404 if not found
|
||||||
|
|
||||||
|
**POST /images/{imageName}**
|
||||||
|
- Upload an image file
|
||||||
|
- Body: Image data
|
||||||
|
- Returns: Success confirmation
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download data
|
||||||
|
curl -H "Authorization: Bearer your-token" \
|
||||||
|
http://localhost:8080/sync
|
||||||
|
|
||||||
|
# Upload data
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer your-token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @ascently.json \
|
||||||
|
http://localhost:8080/sync
|
||||||
|
```
|
||||||
|
|
||||||
|
See `main.go` in the sync directory for implementation details.
|
||||||
30
docs/src/content/docs/sync/overview.md
Normal file
30
docs/src/content/docs/sync/overview.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
title: Self-Hosted Sync Overview
|
||||||
|
description: Learn about Ascently's optional sync server for cross-device data synchronization
|
||||||
|
---
|
||||||
|
|
||||||
|
You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up using Docker.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
This server uses a single `ascently.json` file for your data and a directory for images. The last client to upload wins, overwriting the old data. Authentication is just a static bearer token.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints require an `Authorization: Bearer <your-auth-token>` header.
|
||||||
|
|
||||||
|
- `GET /sync`: Download `ascently.json`
|
||||||
|
- `POST /sync`: Upload `ascently.json`
|
||||||
|
- `GET /images/{imageName}`: Download an image
|
||||||
|
- `POST /images/{imageName}`: Upload an image
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
The easiest way to get started is with the [Quick Start guide](/sync/quick-start/) using Docker Compose.
|
||||||
|
|
||||||
|
You'll need:
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- A secure authentication token
|
||||||
|
- A place to store your data
|
||||||
|
|
||||||
|
The server will be available at `http://localhost:8080` by default. Configure your clients with your server URL and auth token to start syncing.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user