Compare commits

...

42 Commits

Author SHA1 Message Date
a6508da413 iOS 2.4.2 - Fixed Swipe Action Colours 2025-12-07 01:43:19 -07:00
50b30442e8 oops 2025-12-03 15:45:05 -07:00
b365b967b2 Android 2.4.0 - Backend changes :) 2025-12-03 15:41:45 -07:00
cacd178817 iOS 2.4.1 - Minor Visual Tweaks 2025-12-03 00:10:08 -07:00
922412c2c2 Bumped build 2025-12-02 17:09:18 -07:00
acb1b1f532 2.4.0 - Updated Sync Architecture (Provider pattern) 2025-12-02 17:07:52 -07:00
c8694eacab iOS 2.4.0 - Colour accents and theming 2025-12-02 15:55:48 -07:00
57855b8332 Docs updates
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 5m14s
2025-12-01 17:07:30 -07:00
6342bfed5c 2.3.1 - Dependency Updates, Better Live Notifications, and Calendar Fixes 2025-12-01 11:46:31 -07:00
869ca0fc0d Merge pull request '2.3.0 - Unified logging and app intents' (#6) from logging into main
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 6m59s
Ascently - Sync Deploy / build-and-push (push) Successful in 2m0s
Reviewed-on: #6
2025-11-21 04:01:43 +00:00
33562e9d16 Merge branch 'main' into logging
All checks were successful
Ascently - Docs Deploy / build-and-push (pull_request) Successful in 7m42s
2025-11-21 04:01:16 +00:00
a212f3f3b5 2.3.0 - Unified logging and app intents
All checks were successful
Ascently - Docs Deploy / build-and-push (pull_request) Successful in 8m4s
2025-11-20 21:00:00 -07:00
a99196b9ca Deps for docs 2025-11-19 15:04:47 -07:00
93fb7a41fb iOS 2.2.1 2025-11-18 12:59:26 -07:00
6d67ae6d81 Logging overhaul 2025-11-18 12:58:45 -07:00
071e47f95e Update docs/src/content/docs/privacy.md
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m18s
2025-10-25 09:41:27 +00:00
c6c3e6084b Update docs/src/content/docs/privacy.md
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m19s
2025-10-25 09:33:33 +00:00
c2f95f2793 [Android] 2.2.1 - Better Widget 2025-10-21 10:22:31 -06:00
b7a3c98b2c [Android] 2.2.1 - Better Widget 2025-10-21 10:21:35 -06:00
fed9bab2ea Fixeds QR flashing
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m49s
2025-10-21 08:50:34 -06:00
862622b07b Fixed
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m58s
2025-10-20 00:03:35 -06:00
eba503eb5e Updated docs with QR Codes
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m26s
2025-10-19 23:55:30 -06:00
8c4a78ad50 2.2.0 - Final Builds
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m32s
2025-10-18 23:02:31 -06:00
3b16475dc6 [Mobile] 2.2.0 - Calendar View 2025-10-18 16:26:22 -06:00
105d39689d [Mobile] 2.2.0 - Calendar View 2025-10-18 16:26:17 -06:00
d4023133b7 App version 2.1.1 - Branding updates (Logo change)
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m59s
2025-10-17 09:46:19 -06:00
602b5f8938 Branding updates 2025-10-17 09:46:19 -06:00
8529f76c22 Fixed doc issue
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m50s
2025-10-16 12:36:13 -06:00
879aae0721 Update docs with App Store link
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m9s
2025-10-16 00:39:11 -06:00
6fc86558b2 Fixed docs
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m59s
2025-10-15 18:25:48 -06:00
23de8a6fc6 [All Platforms] 2.1.0 - Sync Optimizations
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m31s
Ascently - Docs Deploy / build-and-push (push) Successful in 3m30s
2025-10-15 18:17:19 -06:00
01d85a4add [Android] 2.0.1 - Refactoring & Minor Optimizations 2025-10-14 23:59:27 -06:00
ef1cf3583a [Android] 2.0.1 - Refactoring & Minor Optimizations 2025-10-14 23:57:46 -06:00
3c7290f7e7 [Android] 2.0.1 - Refactoring & Minor Optimizations 2025-10-14 23:47:02 -06:00
5a49b9f0b2 [Android] 2.0.1 - Refactoring & Minor Optimizations 2025-10-14 23:35:15 -06:00
7601e7bb03 Docs updates
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m43s
Ascently - Docs Deploy / build-and-push (push) Successful in 3m34s
2025-10-14 14:26:56 -06:00
082cb79630 Colour changes
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m29s
2025-10-14 12:06:06 -06:00
79b87a088d Fixed T_T
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m53s
2025-10-14 11:53:51 -06:00
1a8f41ecde Added deploy for Docs
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m56s
Ascently - Docs Deploy / build-and-push (push) Successful in 4m45s
2025-10-14 11:42:40 -06:00
175dad8342 Re-wordin and favicon 2025-10-14 08:48:40 -06:00
4559d5e2f2 Added docs 2025-10-14 01:35:13 -06:00
d57149d29c New testflight details! 2025-10-13 15:44:34 -06:00
169 changed files with 12979 additions and 5268 deletions

38
.github/workflows/deploy_docs.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Ascently - Docs Deploy
on:
push:
branches: [main]
paths: ["docs/**"]
pull_request:
branches: [main]
paths: ["docs/**"]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REPO_HOST }}
username: ${{ github.repository_owner }}
password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./docs
platforms: linux/amd64
push: true
tags: |
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-docs:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/ascently-docs:latest

View File

@@ -1,4 +1,4 @@
name: Ascently Docker Deploy
name: Ascently - Sync Deploy
on:
push:
branches: [main]
@@ -14,7 +14,7 @@ jobs:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
@@ -27,7 +27,7 @@ jobs:
password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push sync-server
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
context: ./sync
file: ./sync/Dockerfile

View File

@@ -1,21 +0,0 @@
# Privacy Policy
**Last updated: September 29, 2025**
This Privacy Policy describes our policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
## No Data Collection
I do not collect any personal information, analytics, or data of any kind. This software is designed to be self-hosted or run entirely offline.
All data generated by or used with this software remains on your local machine or self-hosted environment under your control. I have no access to it.
## No Tracking or Analytics
This software does not use cookies, tracking pixels, or any other analytics or tracking mechanisms. Your usage of the software is completely private.
## Contact Us
If you have any questions about this Privacy Policy, you can contact me:
* **By email:** me@atri.dad

View File

@@ -2,43 +2,11 @@
_Formerly OpenClimb_
This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-first, with an optional sync server and integrations with Apple Health and Health Connect. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS.
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.
## Download
## Documentation
For Android do one of the following:
1. Download the latest APK from the Releases page
2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.ascently%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FAscently%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22Ascently%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Ascently%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
For iOS:
Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
For development builds, sign up for the TestFlight [here](https://testflight.apple.com/join/88RtxV4J)!
## Self-Hosted Sync Server
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/ascently-sync:latest
APP_PORT=8080
AUTH_TOKEN=your-secure-auth-token-here
DATA_FILE=/data/ascently.json
IMAGES_DIR=/data/images
ROOT_DIR=./ascently-data
```
2. Use the provided `docker-compose.yml` in the `sync/` directory:
```bash
cd sync/
docker-compose up -d
```
The server will be available at `http://localhost:8080`. Configure your clients with your server URL and auth token to start syncing.
Documentation can be found at [https://ascently.atri.dad](https://ascently.atri.dad)!
## Requirements

View File

@@ -18,5 +18,3 @@ This is a standard Android Gradle project. The main code lives in `app/src/main/
- `navigation/`: Navigation graph and routes using Jetpack Navigation.
- `service/`: Background service for tracking climbing sessions.
- `utils/`: Helpers for things like date formatting and image handling.
The app is built to be offline-first. All data is stored locally on your device and works without an internet connection.

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.ascently"
minSdk = 31
targetSdk = 36
versionCode = 40
versionName = "2.0.0"
versionCode = 48
versionName = "2.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -38,7 +38,10 @@ android {
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
buildFeatures { compose = true }
buildFeatures {
compose = true
buildConfig = true
}
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }

View File

@@ -27,6 +27,7 @@
<!-- Permissions for notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />

View File

@@ -28,8 +28,8 @@ abstract class AscentlyDatabase : RoomDatabase() {
val MIGRATION_4_5 =
object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
val cursor = database.query("PRAGMA table_info(climb_sessions)")
override fun migrate(db: SupportSQLiteDatabase) {
val cursor = db.query("PRAGMA table_info(climb_sessions)")
val existingColumns = mutableSetOf<String>()
while (cursor.moveToNext()) {
@@ -39,21 +39,21 @@ abstract class AscentlyDatabase : RoomDatabase() {
cursor.close()
if (!existingColumns.contains("startTime")) {
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT")
db.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT")
}
if (!existingColumns.contains("endTime")) {
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT")
db.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT")
}
if (!existingColumns.contains("status")) {
database.execSQL(
db.execSQL(
"ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'"
)
}
database.execSQL(
db.execSQL(
"UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL"
)
database.execSQL(
db.execSQL(
"UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''"
)
}
@@ -61,9 +61,9 @@ abstract class AscentlyDatabase : RoomDatabase() {
val MIGRATION_5_6 =
object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
override fun migrate(db: SupportSQLiteDatabase) {
// Add updatedAt column to attempts table
val cursor = database.query("PRAGMA table_info(attempts)")
val cursor = db.query("PRAGMA table_info(attempts)")
val existingColumns = mutableSetOf<String>()
while (cursor.moveToNext()) {
@@ -73,11 +73,11 @@ abstract class AscentlyDatabase : RoomDatabase() {
cursor.close()
if (!existingColumns.contains("updatedAt")) {
database.execSQL(
db.execSQL(
"ALTER TABLE attempts ADD COLUMN updatedAt TEXT NOT NULL DEFAULT ''"
)
// Set updatedAt to createdAt for existing records
database.execSQL(
db.execSQL(
"UPDATE attempts SET updatedAt = createdAt WHERE updatedAt = ''"
)
}
@@ -88,14 +88,14 @@ abstract class AscentlyDatabase : RoomDatabase() {
return INSTANCE
?: synchronized(this) {
val instance =
Room.databaseBuilder(
context.applicationContext,
AscentlyDatabase::class.java,
"ascently_database"
)
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
.enableMultiInstanceInvalidation()
.fallbackToDestructiveMigration()
Room.databaseBuilder(
context.applicationContext,
AscentlyDatabase::class.java,
"ascently_database"
)
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
.enableMultiInstanceInvalidation()
.fallbackToDestructiveMigration(false)
.build()
INSTANCE = instance
instance

View File

@@ -32,13 +32,12 @@ data class BackupGym(
val supportedClimbTypes: List<ClimbType>,
val difficultySystems: List<DifficultySystem>,
@kotlinx.serialization.SerialName("customDifficultyGrades")
val customDifficultyGrades: List<String> = emptyList(),
val customDifficultyGrades: List<String>? = null,
val notes: String? = null,
val createdAt: String,
val updatedAt: String
) {
companion object {
/** Create BackupGym from native Android Gym model */
fun fromGym(gym: Gym): BackupGym {
return BackupGym(
id = gym.id,
@@ -46,7 +45,7 @@ data class BackupGym(
location = gym.location,
supportedClimbTypes = gym.supportedClimbTypes,
difficultySystems = gym.difficultySystems,
customDifficultyGrades = gym.customDifficultyGrades,
customDifficultyGrades = gym.customDifficultyGrades.ifEmpty { null },
notes = gym.notes,
createdAt = gym.createdAt,
updatedAt = gym.updatedAt
@@ -54,7 +53,6 @@ data class BackupGym(
}
}
/** Convert to native Android Gym model */
fun toGym(): Gym {
return Gym(
id = id,
@@ -62,7 +60,7 @@ data class BackupGym(
location = location,
supportedClimbTypes = supportedClimbTypes,
difficultySystems = difficultySystems,
customDifficultyGrades = customDifficultyGrades,
customDifficultyGrades = customDifficultyGrades ?: emptyList(),
notes = notes,
createdAt = createdAt,
updatedAt = updatedAt
@@ -79,7 +77,7 @@ data class BackupProblem(
val description: String? = null,
val climbType: ClimbType,
val difficulty: DifficultyGrade,
val tags: List<String> = emptyList(),
val tags: List<String>? = null,
val location: String? = null,
val imagePaths: List<String>? = null,
val isActive: Boolean = true,
@@ -89,7 +87,6 @@ data class BackupProblem(
val updatedAt: String
) {
companion object {
/** Create BackupProblem from native Android Problem model */
fun fromProblem(problem: Problem): BackupProblem {
return BackupProblem(
id = problem.id,
@@ -112,7 +109,6 @@ data class BackupProblem(
}
}
/** Convert to native Android Problem model */
fun toProblem(): Problem {
return Problem(
id = id,
@@ -121,7 +117,7 @@ data class BackupProblem(
description = description,
climbType = climbType,
difficulty = difficulty,
tags = tags,
tags = tags ?: emptyList(),
location = location,
imagePaths = imagePaths ?: emptyList(),
isActive = isActive,
@@ -132,7 +128,6 @@ data class BackupProblem(
)
}
/** Create a copy with updated image paths for import processing */
fun withUpdatedImagePaths(newImagePaths: List<String>): BackupProblem {
return copy(imagePaths = newImagePaths.ifEmpty { null })
}
@@ -153,7 +148,6 @@ data class BackupClimbSession(
val updatedAt: String
) {
companion object {
/** Create BackupClimbSession from native Android ClimbSession model */
fun fromClimbSession(session: ClimbSession): BackupClimbSession {
return BackupClimbSession(
id = session.id,
@@ -170,7 +164,6 @@ data class BackupClimbSession(
}
}
/** Convert to native Android ClimbSession model */
fun toClimbSession(): ClimbSession {
return ClimbSession(
id = id,
@@ -203,7 +196,6 @@ data class BackupAttempt(
val updatedAt: String? = null
) {
companion object {
/** Create BackupAttempt from native Android Attempt model */
fun fromAttempt(attempt: Attempt): BackupAttempt {
return BackupAttempt(
id = attempt.id,
@@ -221,7 +213,6 @@ data class BackupAttempt(
}
}
/** Convert to native Android Attempt model */
fun toAttempt(): Attempt {
return Attempt(
id = id,

View File

@@ -3,7 +3,7 @@ package com.atridad.ascently.data.health
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.atridad.ascently.utils.AppLogger
import androidx.activity.result.contract.ActivityResultContract
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController
@@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.flow
class HealthConnectManager(private val context: Context) {
private val preferences: SharedPreferences =
context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE)
context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE)
private val _isEnabled = MutableStateFlow(preferences.getBoolean("enabled", false))
private val _hasPermissions = MutableStateFlow(preferences.getBoolean("permissions", false))
@@ -40,34 +40,32 @@ class HealthConnectManager(private val context: Context) {
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)
)
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)
AppLogger.e(TAG, e) { "Failed to create Health Connect client" }
_isCompatible.value = false
null
}
}
/** Check if Health Connect is available on this device */
fun isHealthConnectAvailable(): Flow<Boolean> = flow {
try {
if (!_isCompatible.value) {
@@ -77,158 +75,100 @@ class HealthConnectManager(private val context: Context) {
val status = HealthConnectClient.getSdkStatus(context)
emit(status == HealthConnectClient.SDK_AVAILABLE)
} catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect availability", e)
AppLogger.e(TAG, e) { "Error checking Health Connect availability" }
_isCompatible.value = false
emit(false)
}
}
/** Enable or disable Health Connect integration */
fun setEnabled(enabled: Boolean) {
suspend fun setEnabled(enabled: Boolean) {
preferences.edit().putBoolean("enabled", enabled).apply()
_isEnabled.value = enabled
if (!enabled) {
if (enabled && _isCompatible.value) {
// Automatically request permissions when enabling
try {
val alreadyHasPermissions = hasAllPermissions()
if (!alreadyHasPermissions) {
AppLogger.d(TAG) { "Health Connect enabled - permissions will be requested by UI" }
}
} catch (e: Exception) {
AppLogger.w(TAG, e) { "Error checking permissions when enabling Health Connect" }
}
} 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()
healthConnectClient!!.permissionController.getGrantedPermissions()
val hasAll =
REQUIRED_PERMISSIONS.all { permission ->
grantedPermissions.contains(permission)
}
REQUIRED_PERMISSIONS.all { permission ->
grantedPermissions.contains(permission)
}
setPermissionsGranted(hasAll)
hasAll
} catch (e: Exception) {
Log.e(TAG, "Error checking permissions", e)
AppLogger.e(TAG, e) { "Error checking permissions" }
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
return false
val isAvailable =
HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
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)
AppLogger.e(TAG, e) { "Error checking Health Connect readiness" }
false
}
}
/** Get permission request contract */
fun getPermissionRequestContract(): ActivityResultContract<Set<String>, Set<String>> {
return PermissionController.createRequestPermissionResultContract()
}
/** Test Health Connect functionality */
fun testHealthConnectSync(): String {
val results = mutableListOf<String>()
results.add("=== Health Connect Debug Test ===")
try {
// Check availability synchronously
val packageManager = context.packageManager
val healthConnectPackages =
listOf(
"com.google.android.apps.healthdata",
"com.android.health.connect",
"androidx.health.connect"
)
val available =
healthConnectPackages.any { packageName ->
try {
packageManager.getPackageInfo(packageName, 0)
true
} catch (e: Exception) {
false
}
}
results.add("Available: $available")
// Check enabled state
results.add("Enabled in settings: ${_isEnabled.value}")
// Check permissions (simplified)
val hasPerms = _hasPermissions.value
results.add("Has permissions: $hasPerms")
// Check compatibility
results.add("API Compatible: ${_isCompatible.value}")
val ready = _isEnabled.value && _isCompatible.value && available && hasPerms
results.add("Ready to sync: $ready")
if (ready) {
results.add("Health Connect is connected!")
} else {
results.add("❌ Health Connect not ready")
if (!available) results.add("- Health Connect not available on device")
if (!_isEnabled.value) results.add("- Not enabled in Ascently settings")
if (!hasPerms) results.add("- Permissions not granted")
if (!_isCompatible.value) results.add("- API compatibility issues")
}
} catch (e: Exception) {
results.add("Test failed with error: ${e.message}")
Log.e(TAG, "Health Connect test failed", e)
}
return results.joinToString("\n")
}
/** Get required permissions as strings */
fun getRequiredPermissions(): Set<String> {
return try {
REQUIRED_PERMISSIONS.map { it.toString() }.toSet()
REQUIRED_PERMISSIONS.map { it }.toSet()
} catch (e: Exception) {
Log.e(TAG, "Error getting required permissions", e)
AppLogger.e(TAG, e) { "Error getting required permissions" }
emptySet()
}
}
/** Sync a completed climbing session to Health Connect */
@SuppressLint("RestrictedApi")
suspend fun syncClimbingSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
suspend fun syncCompletedSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
): Result<Unit> {
return try {
if (!isReady()) {
return Result.failure(IllegalStateException("Health Connect not ready"))
if (!isReady() || !_autoSync.value) {
return Result.failure(
IllegalStateException("Health Connect not ready or auto-sync disabled")
)
}
if (session.status != SessionStatus.COMPLETED) {
return Result.failure(
IllegalArgumentException("Only completed sessions can be synced")
IllegalArgumentException("Only completed sessions can be synced")
)
}
@@ -237,29 +177,29 @@ class HealthConnectManager(private val context: Context) {
if (startTime == null || endTime == null) {
return Result.failure(
IllegalArgumentException("Session must have valid start and end times")
IllegalArgumentException("Session must have valid start and end times")
)
}
Log.d(TAG, "Attempting to sync session '${session.id}' to Health Connect...")
AppLogger.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"
)
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)
AppLogger.w(TAG, e) { "Failed to create exercise session record" }
}
try {
@@ -268,108 +208,105 @@ class HealthConnectManager(private val context: Context) {
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)
)
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)
AppLogger.w(TAG, e) { "Failed to create calories record" }
}
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)
AppLogger.w(TAG, e) { "Failed to create heart rate record" }
}
if (records.isNotEmpty() && healthConnectClient != null) {
Log.d(TAG, "Writing ${records.size} records to Health Connect...")
AppLogger.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"
)
AppLogger.i(TAG) {
"Successfully synced ${records.size} records for session '${session.id}' to Health Connect"
}
preferences
.edit()
.putString("last_sync_success", DateFormatUtils.nowISO8601())
.apply()
.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")
when {
records.isEmpty() -> "No records created"
healthConnectClient == null -> "Health Connect client unavailable"
else -> "Unknown reason"
}
AppLogger.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)
AppLogger.e(TAG, e) { "Error syncing climbing session to Health Connect" }
Result.failure(e)
}
}
/** Auto-sync a session if enabled */
suspend fun autoSyncSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
suspend fun autoSyncCompletedSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
): Result<Unit> {
return if (_autoSync.value && isReady()) {
Log.d(TAG, "Auto-syncing session '${session.id}' to Health Connect...")
syncClimbingSession(session, gymName, attemptCount)
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
AppLogger.d(TAG) { "Auto-syncing completed session '${session.id}' to Health Connect..." }
syncCompletedSession(session, gymName, attemptCount)
} else {
val reason =
when {
!_autoSync.value -> "auto-sync disabled"
!isReady() -> "Health Connect not ready"
else -> "unknown reason"
}
Log.d(TAG, "Auto-sync skipped for session '${session.id}': $reason")
when {
session.status != SessionStatus.COMPLETED -> "session not completed"
!_autoSync.value -> "auto-sync disabled"
!isReady() -> "Health Connect not ready"
else -> "unknown reason"
}
AppLogger.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
}
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
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
}
when {
attemptCount >= 20 -> 155L
attemptCount >= 10 -> 145L
else -> 135L
}
var currentTime = startTime
while (currentTime.isBefore(endTime)) {
@@ -383,44 +320,19 @@ class HealthConnectManager(private val context: Context) {
if (samples.isEmpty()) return null
HeartRateRecord(
startTime = startTime,
startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
samples = samples
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)
AppLogger.e(TAG, e) { "Error creating heart rate record" }
null
}
}
/** Reset all preferences */
fun reset() {
preferences.edit().clear().apply()
_isEnabled.value = false
_hasPermissions.value = false
_autoSync.value = true
}
/** Check if ready for use */
fun isReadySync(): Boolean {
return _isEnabled.value && _hasPermissions.value
}
/** Get last successful sync timestamp */
fun getLastSyncSuccess(): String? {
return preferences.getString("last_sync_success", null)
}
/** Get detailed status */
fun getDetailedStatus(): Map<String, String> {
return mapOf(
"enabled" to _isEnabled.value.toString(),
"hasPermissions" to _hasPermissions.value.toString(),
"autoSync" to _autoSync.value.toString(),
"compatible" to _isCompatible.value.toString(),
"lastSyncSuccess" to (getLastSyncSuccess() ?: "never")
)
}
}

View File

@@ -12,7 +12,7 @@ enum class AttemptResult {
SUCCESS,
FALL,
NO_PROGRESS,
FLASH,
FLASH
}
@Entity(
@@ -74,5 +74,4 @@ data class Attempt(
)
}
}
}

View File

@@ -6,12 +6,11 @@ import kotlinx.serialization.Serializable
enum class ClimbType {
ROPE,
BOULDER;
/**
* Get the display name
*/
fun getDisplayName(): String = when (this) {
ROPE -> "Rope"
BOULDER -> "Bouldering"
}
val displayName: String
get() =
when (this) {
ROPE -> "Rope"
BOULDER -> "Bouldering"
}
}

View File

@@ -12,130 +12,129 @@ enum class DifficultySystem {
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"
}
val displayName: String
get() =
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
}
val isBoulderingSystem: Boolean
get() =
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
}
val isRopeSystem: Boolean
get() =
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()
}
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 {
/** Get all difficulty systems based on type */
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
fun systemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
when (climbType) {
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }
ClimbType.ROPE -> entries.filter { it.isRopeSystem() }
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem }
ClimbType.ROPE -> entries.filter { it.isRopeSystem }
}
}
}
@@ -154,38 +153,78 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
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
}
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.CUSTOM -> grade.hashCode().rem(100)
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
}
}
}

View File

@@ -13,6 +13,7 @@ import com.atridad.ascently.data.format.DeletedItem
import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ZipExportImportUtils
import java.io.File
import kotlinx.coroutines.flow.Flow
@@ -26,7 +27,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
private val attemptDao = database.attemptDao()
private val dataStateManager = DataStateManager(context)
private val deletionPreferences: SharedPreferences =
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
private var autoSyncCallback: (() -> Unit)? = null
@@ -43,11 +44,13 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun updateGym(gym: Gym) {
gymDao.updateGym(gym)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun deleteGym(gym: Gym) {
gymDao.deleteGym(gym)
trackDeletion(gym.id, "gym")
@@ -63,10 +66,12 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
problemDao.insertProblem(problem)
dataStateManager.updateDataState()
}
suspend fun updateProblem(problem: Problem) {
problemDao.updateProblem(problem)
dataStateManager.updateDataState()
}
suspend fun deleteProblem(problem: Problem) {
problemDao.deleteProblem(problem)
trackDeletion(problem.id, "problem")
@@ -77,7 +82,8 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId)
sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
suspend fun insertSession(session: ClimbSession) {
@@ -88,6 +94,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync()
}
}
suspend fun updateSession(session: ClimbSession) {
sessionDao.updateSession(session)
dataStateManager.updateDataState()
@@ -96,12 +103,14 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync()
}
}
suspend fun deleteSession(session: ClimbSession) {
sessionDao.deleteSession(session)
trackDeletion(session.id, "session")
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun getLastUsedGym(): Gym? {
val recentSessions = sessionDao.getRecentSessions(1).first()
return if (recentSessions.isNotEmpty()) {
@@ -114,17 +123,21 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
// Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId)
attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsByProblem(problemId)
attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) {
attemptDao.insertAttempt(attempt)
dataStateManager.updateDataState()
}
suspend fun updateAttempt(attempt: Attempt) {
attemptDao.updateAttempt(attempt)
dataStateManager.updateDataState()
}
suspend fun deleteAttempt(attempt: Attempt) {
attemptDao.deleteAttempt(attempt)
trackDeletion(attempt.id, "attempt")
@@ -141,38 +154,38 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val backupData =
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
version = "2.0",
formatVersion = "2.0",
gyms = allGyms.map { BackupGym.fromGym(it) },
problems = allProblems.map { BackupProblem.fromProblem(it) },
sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
)
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
version = "2.0",
formatVersion = "2.0",
gyms = allGyms.map { BackupGym.fromGym(it) },
problems = allProblems.map { BackupProblem.fromProblem(it) },
sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
)
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths =
referencedImagePaths
.filter { imagePath ->
try {
val imageFile =
com.atridad.ascently.utils.ImageUtils.getImageFile(
context,
imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (_: Exception) {
false
}
}
.toSet()
referencedImagePaths
.filter { imagePath ->
try {
val imageFile =
com.atridad.ascently.utils.ImageUtils.getImageFile(
context,
imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (_: Exception) {
false
}
}
.toSet()
ZipExportImportUtils.createExportZipToUri(
context = context,
uri = uri,
exportData = backupData,
referencedImagePaths = validImagePaths
context = context,
uri = uri,
exportData = backupData,
referencedImagePaths = validImagePaths
)
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
@@ -192,11 +205,11 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
}
val importData =
try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
} catch (e: Exception) {
throw Exception("Invalid data format: ${e.message}")
}
try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
} catch (e: Exception) {
throw Exception("Invalid data format: ${e.message}")
}
validateImportData(importData)
@@ -214,17 +227,17 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
}
val updatedBackupProblems =
ZipExportImportUtils.updateProblemImagePaths(
importData.problems,
importResult.importedImagePaths
)
ZipExportImportUtils.updateProblemImagePaths(
importData.problems,
importResult.importedImagePaths
)
updatedBackupProblems.forEach { backupProblem ->
try {
problemDao.insertProblem(backupProblem.toProblem())
} catch (e: Exception) {
throw Exception(
"Failed to import problem '${backupProblem.name}': ${e.message}"
"Failed to import problem '${backupProblem.name}': ${e.message}"
)
}
}
@@ -251,26 +264,18 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
}
}
/**
* Sets the callback for auto-sync functionality. This should be called by the SyncService to
* register itself for auto-sync triggers.
*/
fun setAutoSyncCallback(callback: (() -> Unit)?) {
autoSyncCallback = callback
}
/**
* Triggers auto-sync if enabled. This is called after any data modification to keep data
* synchronized across devices automatically.
*/
private fun triggerAutoSync() {
autoSyncCallback?.invoke()
}
private fun trackDeletion(itemId: String, itemType: String) {
fun trackDeletion(itemId: String, itemType: String) {
val currentDeletions = getDeletedItems().toMutableList()
val newDeletion =
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
currentDeletions.add(newDeletion)
val json = json.encodeToString(newDeletion)
@@ -300,23 +305,23 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
}
private fun validateDataIntegrity(
gyms: List<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
gyms: List<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
) {
val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
)
}
val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
)
}
@@ -324,10 +329,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
val sessionIds = sessions.map { it.id }.toSet()
val invalidAttempts =
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
if (invalidAttempts.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
)
}
}
@@ -342,9 +347,9 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
}
if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 ||
importData.sessions.size > 10000 ||
importData.attempts.size > 100000
importData.problems.size > 10000 ||
importData.sessions.size > 10000 ||
importData.attempts.size > 100000
) {
throw Exception("Import data is too large: possible corruption or malicious file")
}
@@ -394,10 +399,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0
imagesDir.deleteRecursively()
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files")
AppLogger.i("ClimbRepository") { "Cleared $deletedCount image files" }
}
} catch (e: Exception) {
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}")
AppLogger.w("ClimbRepository", e) { "Failed to clear some images: ${e.message}" }
}
}
}

View File

@@ -2,8 +2,8 @@ package com.atridad.ascently.data.state
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.DateFormatUtils
/**
@@ -20,13 +20,13 @@ class DataStateManager(context: Context) {
}
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
init {
if (!isInitialized()) {
updateDataState()
markAsInitialized()
Log.d(TAG, "DataStateManager initialized with timestamp: ${getLastModified()}")
AppLogger.d(TAG) { "DataStateManager initialized with timestamp: ${getLastModified()}" }
}
}
@@ -37,7 +37,7 @@ class DataStateManager(context: Context) {
fun updateDataState() {
val now = DateFormatUtils.nowISO8601()
prefs.edit { putString(KEY_LAST_MODIFIED, now) }
Log.d(TAG, "Data state updated to: $now")
AppLogger.d(TAG) { "Data state updated to: $now" }
}
/**
@@ -46,7 +46,7 @@ class DataStateManager(context: Context) {
*/
fun getLastModified(): String {
return prefs.getString(KEY_LAST_MODIFIED, DateFormatUtils.nowISO8601())
?: DateFormatUtils.nowISO8601()
?: DateFormatUtils.nowISO8601()
}
/** Checks if the data state has been initialized. */

View File

@@ -0,0 +1,741 @@
package com.atridad.ascently.data.sync
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
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.AppLogger
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class AscentlySyncProvider(
private val context: Context,
private val repository: ClimbRepository
) : SyncProvider {
override val type: SyncProviderType = SyncProviderType.SERVER
private val dataStateManager = DataStateManager(context)
companion object {
private const val TAG = "AscentlySyncProvider"
}
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
coerceInputValues = true
}
private val _isConnected = MutableStateFlow(false)
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _isConfigured = MutableStateFlow(false)
override val isConfigured: StateFlow<Boolean> = _isConfigured.asStateFlow()
private var isOfflineMode = false
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 OFFLINE_MODE = "offline_mode"
}
init {
loadInitialState()
updateConfiguredState()
}
private fun loadInitialState() {
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
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) }
}
@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)
override suspend fun sync() {
if (isOfflineMode) {
AppLogger.d(TAG) { "Sync skipped: Offline mode is enabled." }
return
}
if (!isNetworkAvailable()) {
AppLogger.d(TAG) { "Sync skipped: No internet connection." }
throw SyncException.NetworkError("No internet connection.")
}
if (!_isConfigured.value) {
throw SyncException.NotConfigured
}
if (!_isConnected.value) {
throw SyncException.NotConnected
}
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()
val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
if (hasLocalData && hasServerData && lastSyncTimeStr != null) {
AppLogger.d(TAG) { "Using delta sync for incremental updates" }
performDeltaSync(lastSyncTimeStr)
} else {
when {
!hasLocalData && hasServerData -> {
AppLogger.d(TAG) { "No local data found, performing full restore from server" }
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
AppLogger.d(TAG) { "Full restore completed" }
}
hasLocalData && !hasServerData -> {
AppLogger.d(TAG) { "No server data found, uploading local data to server" }
uploadData(localBackup)
syncImagesForBackup(localBackup)
AppLogger.d(TAG) { "Initial upload completed" }
}
hasLocalData && hasServerData -> {
AppLogger.d(TAG) { "Both local and server data exist, merging (server wins)" }
mergeDataSafely(serverBackup)
AppLogger.d(TAG) { "Merge completed" }
}
else -> {
AppLogger.d(TAG) { "No data to sync" }
}
}
}
val now = DateFormatUtils.nowISO8601()
sharedPreferences.edit { putString(Keys.LAST_SYNC_TIME, now) }
}
override fun disconnect() {
serverUrl = ""
authToken = ""
_isConnected.value = false
sharedPreferences.edit {
remove(Keys.LAST_SYNC_TIME)
putBoolean(Keys.IS_CONNECTED, false)
}
updateConfiguredState()
}
private suspend fun performDeltaSync(lastSyncTimeStr: String) {
AppLogger.d(TAG) { "Starting delta sync with lastSyncTime=$lastSyncTimeStr" }
val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0)
val allGyms = repository.getAllGyms().first()
val modifiedGyms =
allGyms
.filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true }
.map { BackupGym.fromGym(it) }
val allProblems = repository.getAllProblems().first()
val modifiedProblems =
allProblems
.filter { problem ->
parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true
}
.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
}
}
val allSessions = repository.getAllSessions().first()
val modifiedSessions =
allSessions
.filter { session ->
parseISO8601(session.updatedAt)?.after(lastSyncDate) == true
}
.map { BackupClimbSession.fromClimbSession(it) }
val allAttempts = repository.getAllAttempts().first()
val modifiedAttempts =
allAttempts
.filter { attempt ->
parseISO8601(attempt.createdAt)?.after(lastSyncDate) == true
}
.map { BackupAttempt.fromAttempt(it) }
val allDeletions = repository.getDeletedItems()
val modifiedDeletions =
allDeletions.filter { item ->
parseISO8601(item.deletedAt)?.after(lastSyncDate) == true
}
AppLogger.d(TAG) {
"Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}"
}
val deltaRequest =
DeltaSyncRequest(
lastSyncTime = lastSyncTimeStr,
gyms = modifiedGyms,
problems = modifiedProblems,
sessions = modifiedSessions,
attempts = modifiedAttempts,
deletedItems = modifiedDeletions
)
val requestBody =
json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest)
.toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync/delta")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
val deltaResponse =
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(DeltaSyncResponse.serializer(), body)
} else {
throw SyncException.InvalidResponse("Empty response body")
}
} else {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
AppLogger.d(TAG) {
"Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}"
}
applyDeltaResponse(deltaResponse)
syncModifiedImages(modifiedProblems)
}
private fun parseISO8601(dateString: String): Date? {
return try {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
format.parse(dateString)
} catch (_: Exception) {
null
}
}
private suspend fun applyDeltaResponse(response: DeltaSyncResponse) {
// SyncService handles the "isSyncing" state to prevent recursive sync triggers
// when the repository is modified during a sync operation.
try {
// Merge and apply deletions first to prevent resurrection
val allDeletions = repository.getDeletedItems() + response.deletedItems
val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" }
AppLogger.d(TAG) { "Applying ${uniqueDeletions.size} deletion records before merging data" }
applyDeletions(uniqueDeletions)
// Build deleted item lookup set
val deletedItemSet = uniqueDeletions.map { "${it.type}:${it.id}" }.toSet()
// Download images for new/modified problems from server
val imagePathMapping = mutableMapOf<String, String>()
for (problem in response.problems) {
if (deletedItemSet.contains("problem:${problem.id}")) {
continue
}
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
val localImagePath = downloadImage(serverFilename)
if (localImagePath != null) {
imagePathMapping[imagePath] = localImagePath
}
} catch (e: Exception) {
AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
}
}
}
// Merge gyms
val existingGyms = repository.getAllGyms().first()
for (backupGym in response.gyms) {
if (deletedItemSet.contains("gym:${backupGym.id}")) {
continue
}
val existing = existingGyms.find { it.id == backupGym.id }
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
val gym = backupGym.toGym()
if (existing != null) {
repository.updateGym(gym)
} else {
repository.insertGym(gym)
}
}
}
// Merge problems
val existingProblems = repository.getAllProblems().first()
for (backupProblem in response.problems) {
if (deletedItemSet.contains("problem:${backupProblem.id}")) {
continue
}
val updatedImagePaths =
backupProblem.imagePaths?.map { oldPath ->
imagePathMapping[oldPath] ?: oldPath
}
val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths)
val problem = problemToMerge.toProblem()
val existing = existingProblems.find { it.id == backupProblem.id }
if (existing == null || backupProblem.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateProblem(problem)
} else {
repository.insertProblem(problem)
}
}
}
// Merge sessions
val existingSessions = repository.getAllSessions().first()
for (backupSession in response.sessions) {
if (deletedItemSet.contains("session:${backupSession.id}")) {
continue
}
val session = backupSession.toClimbSession()
val existing = existingSessions.find { it.id == backupSession.id }
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateSession(session)
} else {
repository.insertSession(session)
}
}
}
// Merge attempts
val existingAttempts = repository.getAllAttempts().first()
for (backupAttempt in response.attempts) {
if (deletedItemSet.contains("attempt:${backupAttempt.id}")) {
continue
}
val attempt = backupAttempt.toAttempt()
val existing = existingAttempts.find { it.id == backupAttempt.id }
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
if (existing != null) {
repository.updateAttempt(attempt)
} else {
repository.insertAttempt(attempt)
}
}
}
// Apply deletions again for safety
applyDeletions(uniqueDeletions)
// Update deletion records
repository.clearDeletedItems()
uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) }
} catch (e: Exception) {
AppLogger.e(TAG, e) { "Error applying delta response" }
throw e
}
}
private suspend fun applyDeletions(
deletions: List<com.atridad.ascently.data.format.DeletedItem>
) {
val existingGyms = repository.getAllGyms().first()
val existingProblems = repository.getAllProblems().first()
val existingSessions = repository.getAllSessions().first()
val existingAttempts = repository.getAllAttempts().first()
for (item in deletions) {
when (item.type) {
"gym" -> {
existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) }
}
"problem" -> {
existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) }
}
"session" -> {
existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) }
}
"attempt" -> {
existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) }
}
}
}
}
private suspend fun syncModifiedImages(modifiedProblems: List<BackupProblem>) {
if (modifiedProblems.isEmpty()) return
AppLogger.d(TAG) { "Syncing images for ${modifiedProblems.size} modified problems" }
for (backupProblem in modifiedProblems) {
backupProblem.imagePaths?.forEach { imagePath ->
val filename = imagePath.substringAfterLast('/')
uploadImage(imagePath, filename)
}
}
}
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(backup).toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.put(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 }
AppLogger.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) {
AppLogger.w(TAG) { "Image not found on server: $imagePath" }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLogger.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) {
AppLogger.e(TAG, e) { "Network error downloading image $serverFilename" }
null
}
}
}
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
AppLogger.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()) {
AppLogger.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) {
AppLogger.d(TAG) { "Successfully uploaded image: $filename" }
} else {
AppLogger.w(TAG) {
"Failed to upload image $filename. Server responded with ${response.code}"
}
}
}
} catch (e: IOException) {
AppLogger.e(TAG, e) { "Network error uploading image $filename" }
}
}
}
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) {
AppLogger.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")
}
}
override suspend fun testConnection() {
if (!_isConfigured.value) {
_isConnected.value = false
throw SyncException.NotConfigured
}
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) {
throw SyncException.NotConnected
}
} catch (e: Exception) {
_isConnected.value = false
throw SyncException.NetworkError(e.message ?: "Connection error")
} finally {
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) }
}
}
}

View File

@@ -0,0 +1,30 @@
package com.atridad.ascently.data.sync
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.DeletedItem
import kotlinx.serialization.Serializable
/** Request structure for delta sync - sends only changes since last sync */
@Serializable
data class DeltaSyncRequest(
val lastSyncTime: String,
val gyms: List<BackupGym>,
val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>,
val deletedItems: List<DeletedItem>
)
/** Response structure for delta sync - receives only changes from server */
@Serializable
data class DeltaSyncResponse(
val serverTime: String,
val gyms: List<BackupGym>,
val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>,
val deletedItems: List<DeletedItem>
)

View File

@@ -0,0 +1,21 @@
package com.atridad.ascently.data.sync
import java.io.IOException
import java.io.Serializable
sealed class SyncException(message: String) : IOException(message), Serializable {
object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.")
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
object ImageNotFound : SyncException("Image not found on server")
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details")
}

View File

@@ -0,0 +1,18 @@
package com.atridad.ascently.data.sync
import kotlinx.coroutines.flow.StateFlow
interface SyncProvider {
val type: SyncProviderType
val isConfigured: StateFlow<Boolean>
val isConnected: StateFlow<Boolean>
suspend fun sync()
suspend fun testConnection()
fun disconnect()
}
enum class SyncProviderType {
NONE,
SERVER
}

View File

@@ -2,24 +2,9 @@ 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 com.atridad.ascently.utils.AppLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -28,42 +13,21 @@ 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())
private val syncMutex = Mutex()
companion object {
private const val TAG = "SyncService"
}
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
private val httpClient =
OkHttpClient.Builder()
.connectTimeout(45, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS)
.build()
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
explicitNulls = false
}
// Currently we only support one provider, but this allows for future expansion
private val provider: SyncProvider = AscentlySyncProvider(context, repository)
// State
private val _isSyncing = MutableStateFlow(false)
@@ -75,11 +39,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
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()
// Delegate to provider
val isConnected: StateFlow<Boolean> = provider.isConnected
val isConfiguredFlow: StateFlow<Boolean> = provider.isConfigured
private val _isTesting = MutableStateFlow(false)
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
@@ -87,67 +49,40 @@ class SyncService(private val context: Context, private val repository: ClimbRep
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 val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
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()
}
// Proxy properties for Ascently provider configuration
var serverUrl: String
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
get() = (provider as? AscentlySyncProvider)?.serverUrl ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
}
// Legacy accessor expected by some UI code (kept for compatibility)
@Deprecated(
message = "Use serverUrl (kebab case) instead",
replaceWith = ReplaceWith("serverUrl")
)
var serverURL: String
get() = serverUrl
set(value) {
serverUrl = value
(provider as? AscentlySyncProvider)?.serverUrl = value
}
var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
get() = (provider as? AscentlySyncProvider)?.authToken ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
(provider as? AscentlySyncProvider)?.authToken = value
}
fun setAutoSyncEnabled(enabled: Boolean) {
@@ -155,83 +90,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
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) {
if (!isConfiguredFlow.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) }
provider.sync()
// Update last sync time from shared prefs (provider updates it)
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
} catch (e: Exception) {
_syncError.value = e.message
throw e
@@ -241,272 +114,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
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."
}
provider.testConnection()
} catch (e: Exception) {
_isConnected.value = false
_syncError.value = "Connection error: ${e.message}"
throw e
} finally {
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) }
_isTesting.value = false
}
}
fun triggerAutoSync() {
if (!_isConfigured.value || !_isConnected.value || !_isAutoSyncEnabled.value) {
if (!isConfiguredFlow.value || !isConnected.value || !_isAutoSyncEnabled.value) {
return
}
if (_isSyncing.value) {
@@ -515,55 +137,25 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
syncJob?.cancel()
syncJob =
serviceScope.launch {
delay(syncDebounceDelay)
try {
syncWithServer()
} catch (e: Exception) {
Log.e(TAG, "Auto-sync failed", e)
}
if (pendingChanges) {
pendingChanges = false
triggerAutoSync()
}
serviceScope.launch {
delay(syncDebounceDelay)
try {
syncWithServer()
} catch (e: Exception) {
AppLogger.e(TAG, e) { "Auto-sync failed" }
}
if (pendingChanges) {
pendingChanges = false
triggerAutoSync()
}
}
}
fun clearConfiguration() {
syncJob?.cancel()
serverUrl = ""
authToken = ""
provider.disconnect()
setAutoSyncEnabled(true)
_lastSyncTime.value = null
_isConnected.value = false
_syncError.value = null
sharedPreferences.edit { clear() }
updateConfiguredState()
}
}
sealed class SyncException(message: String) : IOException(message), Serializable {
object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.") {
@JvmStatic private fun readResolve(): Any = NotConfigured
}
object NotConnected : SyncException("Not connected to server. Please test connection first.") {
@JvmStatic private fun readResolve(): Any = NotConnected
}
object Unauthorized : SyncException("Unauthorized. Please check your auth token.") {
@JvmStatic private fun readResolve(): Any = Unauthorized
}
object ImageNotFound : SyncException("Image not found on server") {
@JvmStatic private fun readResolve(): Any = ImageNotFound
}
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details")
data class DecodingError(val details: String) :
SyncException("Failed to decode server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details")
}

View File

@@ -6,41 +6,47 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.atridad.ascently.MainActivity
import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.runBlocking
class SessionTrackingService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var notificationJob: Job? = null
private var monitoringJob: Job? = null
private lateinit var repository: ClimbRepository
private lateinit var notificationManager: NotificationManager
companion object {
private const val LOG_TAG = "SessionTrackingService"
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "session_tracking_channel"
const val ACTION_START_SESSION = "start_session"
const val ACTION_STOP_SESSION = "stop_session"
const val EXTRA_SESSION_ID = "session_id"
fun createStartIntent(context: Context, sessionId: String): Intent {
return Intent(context, SessionTrackingService::class.java).apply {
action = ACTION_START_SESSION
putExtra(EXTRA_SESSION_ID, sessionId)
}
}
fun createStopIntent(context: Context, sessionId: String): Intent {
return Intent(context, SessionTrackingService::class.java).apply {
action = ACTION_STOP_SESSION
@@ -48,17 +54,17 @@ class SessionTrackingService : Service() {
}
}
}
override fun onCreate() {
super.onCreate()
val database = AscentlyDatabase.getDatabase(this)
repository = ClimbRepository(database, this)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START_SESSION -> {
@@ -67,16 +73,24 @@ class SessionTrackingService : Service() {
startSessionTracking(sessionId)
}
}
ACTION_STOP_SESSION -> {
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
serviceScope.launch {
try {
val targetSession = when {
sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession()
}
if (targetSession != null && targetSession.status == com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
val completed = with(com.atridad.ascently.data.model.ClimbSession) { targetSession.complete() }
val targetSession =
when {
sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession()
}
if (targetSession != null &&
targetSession.status ==
com.atridad.ascently.data.model.SessionStatus.ACTIVE
) {
val completed =
with(com.atridad.ascently.data.model.ClimbSession) {
targetSession.complete()
}
repository.updateSession(completed)
}
} finally {
@@ -90,61 +104,71 @@ class SessionTrackingService : Service() {
}
override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) {
notificationJob?.cancel()
monitoringJob?.cancel()
try {
createAndShowNotification(sessionId)
// Update widget when session tracking starts
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e(LOG_TAG, e) { "Failed to initialize session tracking notification" }
}
notificationJob = serviceScope.launch {
try {
if (!isNotificationActive()) {
delay(1000L)
createAndShowNotification(sessionId)
}
while (isActive) {
delay(5000L)
updateNotification(sessionId)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
monitoringJob = serviceScope.launch {
try {
while (isActive) {
delay(10000L)
notificationJob =
serviceScope.launch {
try {
if (!isNotificationActive()) {
delay(1000L)
createAndShowNotification(sessionId)
}
while (isActive) {
delay(5000L)
updateNotification(sessionId)
}
val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
break
}
} catch (e: Exception) {
AppLogger.e(LOG_TAG, e) { "Notification updater loop crashed" }
}
}
monitoringJob =
serviceScope.launch {
try {
while (isActive) {
delay(10000L)
if (!isNotificationActive()) {
updateNotification(sessionId)
}
val session = repository.getSessionById(sessionId)
if (session == null ||
session.status !=
com.atridad.ascently.data.model.SessionStatus
.ACTIVE
) {
stopSessionTracking()
break
}
}
} catch (e: Exception) {
AppLogger.e(LOG_TAG, e) { "Session monitoring loop crashed" }
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun stopSessionTracking() {
notificationJob?.cancel()
monitoringJob?.cancel()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
// Update widget when session tracking stops
ClimbStatsWidgetProvider.updateAllWidgets(this)
}
private fun isNotificationActive(): Boolean {
return try {
val activeNotifications = notificationManager.activeNotifications
@@ -153,97 +177,134 @@ class SessionTrackingService : Service() {
false
}
}
private suspend fun updateNotification(sessionId: String) {
try {
createAndShowNotification(sessionId)
// Update widget when notification updates
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e(LOG_TAG, e) { "Failed to update notification; retrying in 10s" }
try {
delay(10000L)
createAndShowNotification(sessionId)
} catch (retryException: Exception) {
retryException.printStackTrace()
AppLogger.e(LOG_TAG, retryException) { "Retrying notification update failed" }
stopSessionTracking()
}
}
}
private fun createAndShowNotification(sessionId: String) {
try {
val session = runBlocking {
repository.getSessionById(sessionId)
}
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
val session = runBlocking { repository.getSessionById(sessionId) }
if (session == null ||
session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE
) {
stopSessionTracking()
return
}
val gym = runBlocking {
repository.getGymById(session.gymId)
}
val gym = runBlocking { repository.getGymById(session.gymId) }
val attempts = runBlocking {
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
}
val duration = session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s"
val notificationBuilder =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true)
.setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent())
.addAction(
R.drawable.ic_mountains,
"Open Session",
createOpenAppIntent()
)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"End Session",
createStopPendingIntent(sessionId)
)
// Use Live Update
if (Build.VERSION.SDK_INT >= 36) {
val startTimeMillis =
session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val zoneId = ZoneId.systemDefault()
start.atZone(zoneId).toInstant().toEpochMilli()
} catch (_: Exception) {
System.currentTimeMillis()
}
}
} catch (_: Exception) {
"Active"
}
} ?: "Active"
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Climbing Session Active")
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts")
.setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true)
.setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent())
.addAction(
R.drawable.ic_mountains,
"Open Session",
createOpenAppIntent()
)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"End Session",
createStopPendingIntent(sessionId)
)
.build()
?: System.currentTimeMillis()
notificationBuilder
.setContentTitle("Climbing Session Active")
.setContentText(
"${gym?.name ?: "Gym"}${attempts.size} attempts"
)
.setWhen(startTimeMillis)
.setUsesChronometer(true)
.setShowWhen(true)
val extras = Bundle()
extras.putBoolean("android.extra.REQUEST_PROMOTED_ONGOING", true)
notificationBuilder.setExtras(extras)
} else {
// Fallback for older versions
val duration =
session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s"
}
} catch (_: Exception) {
"Active"
}
}
?: "Active"
notificationBuilder
.setContentTitle("Climbing Session Active")
.setContentText(
"${gym?.name ?: "Gym"}$duration${attempts.size} attempts"
)
}
val notification = notificationBuilder.build()
startForeground(NOTIFICATION_ID, notification)
notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e(LOG_TAG, e) { "Failed to build session tracking notification" }
throw e
}
}
private fun createOpenAppIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
action = "OPEN_SESSION"
}
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
action = "OPEN_SESSION"
}
return PendingIntent.getActivity(
this,
0,
@@ -251,7 +312,7 @@ class SessionTrackingService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun createStopPendingIntent(sessionId: String): PendingIntent {
val intent = createStopIntent(this, sessionId)
return PendingIntent.getService(
@@ -261,24 +322,26 @@ class SessionTrackingService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"Session Tracking",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Shows active climbing session information"
setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
enableLights(false)
enableVibration(false)
setSound(null, null)
}
val channel =
NotificationChannel(
CHANNEL_ID,
"Session Tracking",
NotificationManager.IMPORTANCE_DEFAULT
)
.apply {
description = "Shows active climbing session information"
setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
enableLights(false)
enableVibration(false)
setSound(null, null)
}
notificationManager.createNotificationChannel(channel)
}
override fun onDestroy() {
super.onDestroy()
notificationJob?.cancel()

View File

@@ -26,15 +26,16 @@ import com.atridad.ascently.ui.components.NotificationPermissionDialog
import com.atridad.ascently.ui.screens.*
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.AppShortcutManager
import com.atridad.ascently.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AscentlyApp(
shortcutAction: String? = null,
lastUsedGymId: String? = null,
onShortcutActionProcessed: () -> Unit = {}
shortcutAction: String? = null,
lastUsedGymId: String? = null,
onShortcutActionProcessed: () -> Unit = {}
) {
val navController = rememberNavController()
val context = LocalContext.current
@@ -45,26 +46,26 @@ fun AscentlyApp(
val repository = remember { ClimbRepository(database, context) }
val syncService = remember { SyncService(context, repository) }
val viewModel: ClimbViewModel =
viewModel(factory = ClimbViewModelFactory(repository, syncService, context))
viewModel(factory = ClimbViewModelFactory(repository, syncService, context))
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
val permissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (!isGranted) {
showNotificationPermissionDialog = false
}
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (!isGranted) {
showNotificationPermissionDialog = false
}
}
LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)
!NotificationPermissionUtils.isNotificationPermissionGranted(context)
) {
showNotificationPermissionDialog = true
}
@@ -86,10 +87,10 @@ fun AscentlyApp(
LaunchedEffect(activeSession, gyms, lastUsedGym) {
AppShortcutManager.updateShortcuts(
context = context,
hasActiveSession = activeSession != null,
hasGyms = gyms.isNotEmpty(),
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
context = context,
hasActiveSession = activeSession != null,
hasGyms = gyms.isNotEmpty(),
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
)
}
@@ -101,6 +102,7 @@ fun AscentlyApp(
launchSingleTop = true
}
}
AppShortcutManager.ACTION_END_SESSION -> {
navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true }
@@ -114,51 +116,36 @@ fun AscentlyApp(
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d(
"AscentlyApp",
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
)
AppLogger.d("AscentlyApp") { "Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}" }
if (activeSession == null) {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(
context
)
!NotificationPermissionUtils.isNotificationPermissionGranted(
context
)
) {
android.util.Log.d("AscentlyApp", "Showing notification permission dialog")
AppLogger.d("AscentlyApp") { "Showing notification permission dialog" }
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) {
android.util.Log.d(
"AscentlyApp",
"Starting session with single gym: ${gyms.first().name}"
)
AppLogger.d("AscentlyApp") { "Starting session with single gym: ${gyms.first().name}" }
viewModel.startSession(context, gyms.first().id)
} else {
val targetGym =
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
?: lastUsedGym
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
?: lastUsedGym
if (targetGym != null) {
android.util.Log.d(
"AscentlyApp",
"Starting session with target gym: ${targetGym.name}"
)
AppLogger.d("AscentlyApp") { "Starting session with target gym: ${targetGym.name}" }
viewModel.startSession(context, targetGym.id)
} else {
android.util.Log.d(
"AscentlyApp",
"No target gym found, navigating to selection"
)
AppLogger.d("AscentlyApp") { "No target gym found, navigating to selection" }
navController.navigate(Screen.AddEditSession())
}
}
}
} else {
android.util.Log.d(
"AscentlyApp",
"Active session already exists: ${activeSession?.id}"
)
AppLogger.d("AscentlyApp") { "Active session already exists: ${activeSession?.id}" }
}
onShortcutActionProcessed()
@@ -168,79 +155,79 @@ fun AscentlyApp(
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
Scaffold(
bottomBar = { AscentlyBottomNavigation(navController = navController) },
floatingActionButton = {
fabConfig?.let { config ->
FloatingActionButton(
onClick = config.onClick,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = config.icon,
contentDescription = config.contentDescription
)
}
bottomBar = { AscentlyBottomNavigation(navController = navController) },
floatingActionButton = {
fabConfig?.let { config ->
FloatingActionButton(
onClick = config.onClick,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = config.icon,
contentDescription = config.contentDescription
)
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Screen.Sessions,
modifier = Modifier.padding(innerPadding)
navController = navController,
startDestination = Screen.Sessions,
modifier = Modifier.padding(innerPadding)
) {
composable<Screen.Sessions> {
LaunchedEffect(gyms, activeSession) {
fabConfig =
if (gyms.isNotEmpty() && activeSession == null) {
FabConfig(
icon = Icons.Default.PlayArrow,
contentDescription = "Start Session",
onClick = {
if (NotificationPermissionUtils
.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils
.isNotificationPermissionGranted(
context
)
) {
showNotificationPermissionDialog = true
} else {
navController.navigate(Screen.AddEditSession())
}
}
)
} else {
null
}
if (gyms.isNotEmpty() && activeSession == null) {
FabConfig(
icon = Icons.Default.PlayArrow,
contentDescription = "Start Session",
onClick = {
if (NotificationPermissionUtils
.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils
.isNotificationPermissionGranted(
context
)
) {
showNotificationPermissionDialog = true
} else {
navController.navigate(Screen.AddEditSession())
}
}
)
} else {
null
}
}
SessionsScreen(
viewModel = viewModel,
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
}
viewModel = viewModel,
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
}
)
}
composable<Screen.Problems> {
LaunchedEffect(gyms) {
fabConfig =
if (gyms.isNotEmpty()) {
FabConfig(
icon = Icons.Default.Add,
contentDescription = "Add Problem",
onClick = {
navController.navigate(Screen.AddEditProblem())
}
)
} else {
null
}
if (gyms.isNotEmpty()) {
FabConfig(
icon = Icons.Default.Add,
contentDescription = "Add Problem",
onClick = {
navController.navigate(Screen.AddEditProblem())
}
)
} else {
null
}
}
ProblemsScreen(
viewModel = viewModel,
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
viewModel = viewModel,
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
@@ -252,17 +239,17 @@ fun AscentlyApp(
composable<Screen.Gyms> {
LaunchedEffect(Unit) {
fabConfig =
FabConfig(
icon = Icons.Default.Add,
contentDescription = "Add Gym",
onClick = { navController.navigate(Screen.AddEditGym()) }
)
FabConfig(
icon = Icons.Default.Add,
contentDescription = "Add Gym",
onClick = { navController.navigate(Screen.AddEditGym()) }
)
}
GymsScreen(
viewModel = viewModel,
onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId))
}
viewModel = viewModel,
onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId))
}
)
}
@@ -275,12 +262,12 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.SessionDetail>()
LaunchedEffect(Unit) { fabConfig = null }
SessionDetailScreen(
sessionId = args.sessionId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
sessionId = args.sessionId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
@@ -288,12 +275,12 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
LaunchedEffect(Unit) { fabConfig = null }
ProblemDetailScreen(
problemId = args.problemId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { problemId ->
navController.navigate(Screen.AddEditProblem(problemId = problemId))
}
problemId = args.problemId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { problemId ->
navController.navigate(Screen.AddEditProblem(problemId = problemId))
}
)
}
@@ -301,18 +288,18 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.GymDetail>()
LaunchedEffect(Unit) { fabConfig = null }
GymDetailScreen(
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = gymId))
},
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
},
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = gymId))
},
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
},
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
@@ -320,9 +307,9 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen(
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
@@ -330,10 +317,10 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditProblemScreen(
problemId = args.problemId,
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
problemId = args.problemId,
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
@@ -341,22 +328,22 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditSession>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditSessionScreen(
sessionId = args.sessionId,
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
sessionId = args.sessionId,
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
}
if (showNotificationPermissionDialog) {
NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = {
permissionLauncher.launch(
NotificationPermissionUtils.getNotificationPermissionString()
)
}
onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = {
permissionLauncher.launch(
NotificationPermissionUtils.getNotificationPermissionString()
)
}
)
}
}
@@ -370,34 +357,34 @@ fun AscentlyBottomNavigation(navController: NavHostController) {
NavigationBar {
bottomNavigationItems.forEach { item ->
val isSelected =
when (item.screen) {
is Screen.Sessions -> currentRoute?.contains("Session") == true
is Screen.Problems -> currentRoute?.contains("Problem") == true
is Screen.Gyms -> currentRoute?.contains("Gym") == true
is Screen.Analytics -> currentRoute?.contains("Analytics") == true
is Screen.Settings -> currentRoute?.contains("Settings") == true
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
}
when (item.screen) {
is Screen.Sessions -> currentRoute?.contains("Session") == true
is Screen.Problems -> currentRoute?.contains("Problem") == true
is Screen.Gyms -> currentRoute?.contains("Gym") == true
is Screen.Analytics -> currentRoute?.contains("Analytics") == true
is Screen.Settings -> currentRoute?.contains("Settings") == true
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
}
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = isSelected,
onClick = {
navController.navigate(item.screen) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
// Don't restore state - always start fresh when switching tabs
restoreState = false
}
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = isSelected,
onClick = {
navController.navigate(item.screen) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
// Don't restore state - always start fresh when switching tabs
restoreState = false
}
}
)
}
}
}
data class FabConfig(
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String,
val onClick: () -> Unit
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String,
val onClick: () -> Unit
)

View File

@@ -1,18 +1,19 @@
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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -20,7 +21,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -29,25 +29,29 @@ 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()
// Handle back button press
BackHandler { onDismiss() }
// Auto-scroll thumbnail list to center current image
LaunchedEffect(pagerState.currentPage) {
thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200)
if (imagePaths.size > 1) {
thumbnailListState.animateScrollToItem(
index = pagerState.currentPage,
scrollOffset = -200
)
}
}
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = true)
) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black).systemBarsPadding()) {
// Main image pager
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
OrientationAwareImage(
@@ -58,76 +62,96 @@ fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDis
)
}
// 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)
)
// 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
) {
Text(
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
// 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 (if multiple images)
// Thumbnail strip at bottom (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)
)
Surface(
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
color = Color.Black.copy(alpha = 0.6f)
) {
LazyRow(
state = thumbnailListState,
modifier = Modifier.padding(8.dp),
modifier = Modifier.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
contentPadding = PaddingValues(horizontal = 16.dp)
) {
itemsIndexed(imagePaths) { index, imagePath ->
val isSelected = index == pagerState.currentPage
OrientationAwareImage(
imagePath = imagePath,
contentDescription = "Thumbnail ${index + 1}",
Box(
modifier =
Modifier.size(60.dp)
Modifier.size(48.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
)
) {
OrientationAwareImage(
imagePath = imagePath,
contentDescription = "Thumbnail ${index + 1}",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// Selection indicator
if (isSelected) {
Box(
modifier =
Modifier.fillMaxSize()
.background(
Color.White.copy(alpha = 0.3f),
RoundedCornerShape(8.dp)
)
)
Box(
modifier =
Modifier.fillMaxSize()
.background(
Color.Transparent,
RoundedCornerShape(8.dp)
)
.clip(RoundedCornerShape(8.dp))
.background(
Color.White.copy(alpha = 0.2f)
)
)
}
}
}
}
}

View File

@@ -20,7 +20,7 @@ fun ImageDisplay(
imageSize: Int = 120,
onImageClick: ((Int) -> Unit)? = null
) {
val context = LocalContext.current
LocalContext.current
if (imagePaths.isNotEmpty()) {
LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {

View File

@@ -255,7 +255,7 @@ private fun createImageFile(context: android.content.Context): File {
@Composable
private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) {
val context = LocalContext.current
val imageFile = ImageUtils.getImageFile(context, imagePath)
ImageUtils.getImageFile(context, imagePath)
Box(modifier = modifier.size(80.dp)) {
OrientationAwareImage(

View File

@@ -5,12 +5,15 @@ 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
@@ -20,8 +23,8 @@ import kotlinx.coroutines.withContext
@Composable
fun OrientationAwareImage(
imagePath: String,
contentDescription: String? = null,
modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit
) {
val context = LocalContext.current
@@ -42,7 +45,7 @@ fun OrientationAwareImage(
?: return@withContext null
val correctedBitmap = correctImageOrientation(imageFile, originalBitmap)
correctedBitmap.asImageBitmap()
} catch (e: Exception) {
} catch (_: Exception) {
null
}
}
@@ -52,7 +55,7 @@ fun OrientationAwareImage(
Box(modifier = modifier) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.fillMaxSize())
CircularProgressIndicator(modifier = Modifier.size(32.dp).align(Alignment.Center))
} else {
imageBitmap?.let { bitmap ->
Image(
@@ -113,15 +116,7 @@ private fun correctImageOrientation(
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
}
}
// Default case - no transformation needed
}
}
@@ -143,7 +138,7 @@ private fun correctImageOrientation(
}
rotatedBitmap
}
} catch (e: Exception) {
} catch (_: Exception) {
bitmap
}
}

View File

@@ -31,14 +31,14 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
// Collect flows
val isEnabled by healthConnectManager.isEnabled.collectAsState(initial = false)
val hasPermissions by healthConnectManager.hasPermissions.collectAsState(initial = false)
val autoSyncEnabled by healthConnectManager.autoSyncEnabled.collectAsState(initial = true)
val isCompatible by healthConnectManager.isCompatible.collectAsState(initial = true)
// Permission launcher
val permissionLauncher =
rememberLauncherForActivityResult(
contract = healthConnectManager.getPermissionRequestContract()
) { grantedPermissions ->
) { _ ->
coroutineScope.launch {
val allGranted = healthConnectManager.hasAllPermissions()
if (!allGranted) {
@@ -86,313 +86,207 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
) {
// Header with icon and title
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
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
}
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 = "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
)
}
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 ->
checked = isEnabled,
onCheckedChange = { enabled ->
coroutineScope.launch {
if (enabled && isHealthConnectAvailable) {
healthConnectManager.setEnabled(true)
coroutineScope.launch {
try {
val permissionSet =
healthConnectManager.getRequiredPermissions()
if (permissionSet.isNotEmpty()) {
permissionLauncher.launch(permissionSet)
}
} catch (e: Exception) {
errorMessage = "Error requesting permissions: ${e.message}"
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
}
},
enabled = isHealthConnectAvailable && !isLoading && isCompatible
)
}
if (isEnabled) {
Spacer(modifier = Modifier.height(16.dp))
Card(
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 =
if (hasPermissions) {
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.3f
)
} else {
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f
)
}
)
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector =
if (hasPermissions) Icons.Default.CheckCircle
else Icons.Default.Warning,
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 =
if (hasPermissions) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
if (hasPermissions) "Ready to sync"
else "Permissions needed",
Text(
text = "Permissions needed",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
if (!hasPermissions) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
"Grant Health Connect permissions to sync your climbing sessions",
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.8f
)
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}"
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()
}
},
modifier = Modifier.fillMaxWidth()
) { Text("Grant Permissions") }
}
}
}
if (hasPermissions) {
Spacer(modifier = Modifier.height(12.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Auto-sync sessions",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Automatically sync completed climbing sessions",
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.7f
)
)
}
Switch(
checked = autoSyncEnabled,
onCheckedChange = { enabled ->
healthConnectManager.setAutoSyncEnabled(enabled)
}
)
}
}
}
} else {
Spacer(modifier = Modifier.height(16.dp))
Text(
} 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.",
"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))
)
}
errorMessage?.let { error ->
Spacer(modifier = Modifier.height(12.dp))
Card(
Card(
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.5f
)
)
) {
Row(
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.5f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
if (isEnabled) {
Spacer(modifier = Modifier.height(12.dp))
var testResult by remember { mutableStateOf<String?>(null) }
var isTestRunning by remember { mutableStateOf(false) }
OutlinedButton(
onClick = {
isTestRunning = true
coroutineScope.launch {
try {
testResult = healthConnectManager.testHealthConnectSync()
} catch (e: Exception) {
testResult = "Test failed: ${e.message}"
} finally {
isTestRunning = false
}
}
},
enabled = !isTestRunning,
modifier = Modifier.fillMaxWidth()
) {
if (isTestRunning) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(if (isTestRunning) "Testing..." else "Test Connection")
}
testResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.5f
)
)
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = "Debug Results:",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = result,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
}
@@ -401,40 +295,3 @@ fun HealthConnectCard(modifier: Modifier = Modifier) {
}
}
}
@Composable
fun HealthConnectStatusBanner(isConnected: Boolean, modifier: Modifier = Modifier) {
if (isConnected) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.5f
)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.CloudDone,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Health Connect active - sessions will sync automatically",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}

View File

@@ -40,7 +40,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
emptyList()
} else {
selectedClimbTypes
.flatMap { climbType -> DifficultySystem.getSystemsForClimbType(climbType) }
.flatMap { climbType -> DifficultySystem.systemsForClimbType(climbType) }
.distinct()
}
@@ -89,7 +89,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
)
if (isEditing) {
viewModel.updateGym(gym.copy(id = gymId!!))
viewModel.updateGym(gym.copy(id = gymId))
} else {
viewModel.addGym(gym)
}
@@ -164,7 +164,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
onCheckedChange = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(climbType.getDisplayName())
Text(climbType.displayName)
}
}
}
@@ -219,7 +219,7 @@ fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack:
onCheckedChange = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(system.getDisplayName())
Text(system.displayName)
}
}
}
@@ -248,7 +248,6 @@ fun AddEditProblemScreen(
) {
val isEditing = problemId != null
val gyms by viewModel.gyms.collectAsState()
val context = LocalContext.current
// Problem form state
var selectedGym by remember {
@@ -295,7 +294,7 @@ fun AddEditProblemScreen(
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
val availableDifficultySystems =
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
selectedGym?.difficultySystems?.contains(system) != false
}
@@ -324,7 +323,7 @@ fun AddEditProblemScreen(
// Reset grade when difficulty system changes (unless it's a valid grade for the new system)
LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades()
val availableGrades = selectedDifficultySystem.availableGrades
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
difficultyGrade = ""
}
@@ -387,12 +386,11 @@ fun AddEditProblemScreen(
)
if (isEditing) {
viewModel.updateProblem(
problem.copy(id = problemId),
context
)
problemId.let { id ->
viewModel.updateProblem(problem.copy(id = id))
}
} else {
viewModel.addProblem(problem, context)
viewModel.addProblem(problem)
}
onNavigateBack()
}
@@ -505,7 +503,7 @@ fun AddEditProblemScreen(
availableClimbTypes.forEach { climbType ->
FilterChip(
onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) },
label = { Text(climbType.displayName) },
selected = selectedClimbType == climbType
)
}
@@ -538,7 +536,7 @@ fun AddEditProblemScreen(
items(availableDifficultySystems) { system ->
FilterChip(
onClick = { selectedDifficultySystem = system },
label = { Text(system.getDisplayName()) },
label = { Text(system.displayName) },
selected = selectedDifficultySystem == system
)
}
@@ -570,7 +568,7 @@ fun AddEditProblemScreen(
)
} else {
var expanded by remember { mutableStateOf(false) }
val availableGrades = selectedDifficultySystem.getAvailableGrades()
val availableGrades = selectedDifficultySystem.availableGrades
ExposedDropdownMenuBox(
expanded = expanded,
@@ -592,8 +590,7 @@ fun AddEditProblemScreen(
.outlinedTextFieldColors(),
modifier =
Modifier.menuAnchor(
androidx.compose.material3
.MenuAnchorType
ExposedDropdownMenuAnchorType
.PrimaryNotEditable,
enabled = true
)
@@ -765,9 +762,9 @@ fun AddEditSessionScreen(
null
}
)
viewModel.updateSession(
session.copy(id = sessionId!!)
)
sessionId.let { id ->
viewModel.updateSession(session.copy(id = id))
}
} else {
viewModel.startSession(
context,

View File

@@ -17,8 +17,8 @@ import com.atridad.ascently.ui.components.BarChart
import com.atridad.ascently.ui.components.BarChartDataPoint
import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.utils.DateFormatUtils
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Composable
fun AnalyticsScreen(viewModel: ClimbViewModel) {
@@ -253,11 +253,8 @@ fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionData
systemFiltered.filter { dataPoint ->
try {
val attemptDate =
LocalDateTime.parse(
dataPoint.date,
DateTimeFormatter.ISO_LOCAL_DATE_TIME
)
attemptDate.isAfter(sevenDaysAgo)
DateFormatUtils.parseToLocalDateTime(dataPoint.date)
attemptDate?.isAfter(sevenDaysAgo) == true
} catch (_: Exception) {
// If date parsing fails, include the data point
true

View File

@@ -16,10 +16,8 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -30,16 +28,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewModelScope
import com.atridad.ascently.data.model.*
import com.atridad.ascently.ui.components.FullscreenImageViewer
import com.atridad.ascently.ui.components.ImageDisplaySection
import com.atridad.ascently.ui.components.ImagePicker
import com.atridad.ascently.ui.theme.CustomIcons
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -221,7 +217,6 @@ fun SessionDetailScreen(
val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState()
var isGeneratingShare by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showAddAttemptDialog by remember { mutableStateOf(false) }
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) }
@@ -234,7 +229,7 @@ fun SessionDetailScreen(
val successfulAttempts =
attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
val uniqueProblems = attempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in uniqueProblems }
val completedProblems = successfulAttempts.map { it.problemId }.distinct()
val attemptsWithProblems =
@@ -261,64 +256,8 @@ fun SessionDetailScreen(
}
},
actions = {
if (session?.duration != null) {
val healthConnectManager = viewModel.getHealthConnectManager()
val isHealthConnectEnabled by
healthConnectManager.isEnabled.collectAsState(
initial = false
)
val hasPermissions by
healthConnectManager.hasPermissions.collectAsState(
initial = false
)
if (isHealthConnectEnabled && hasPermissions) {
IconButton(
onClick = {
viewModel.manualSyncToHealthConnect(sessionId)
}
) {
Icon(
imageVector = Icons.Default.HealthAndSafety,
contentDescription = "Sync to Health Connect",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
// Share button
if (session?.duration != null) { // Only show for completed sessions
IconButton(
onClick = {
isGeneratingShare = true
viewModel.viewModelScope.launch {
val shareFile =
viewModel.generateSessionShareCard(
context,
sessionId
)
isGeneratingShare = false
shareFile?.let { file ->
viewModel.shareSessionCard(context, file)
}
}
},
enabled = !isGeneratingShare
) {
if (isGeneratingShare) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share Session"
)
}
}
}
// No manual actions needed - Health Connect syncs automatically when
// sessions complete
// Show stop icon for active sessions, delete icon for completed
// sessions
@@ -564,7 +503,7 @@ fun SessionDetailScreen(
viewModel.addAttempt(attempt)
showAddAttemptDialog = false
},
onProblemCreated = { problem -> viewModel.addProblem(problem, context) }
onProblemCreated = { problem -> viewModel.addProblem(problem) }
)
}
@@ -590,7 +529,7 @@ fun ProblemDetailScreen(
onNavigateBack: () -> Unit,
onNavigateToEdit: (String) -> Unit
) {
val context = LocalContext.current
var showDeleteDialog by remember { mutableStateOf(false) }
var showImageViewer by remember { mutableStateOf(false) }
var selectedImageIndex by remember { mutableIntStateOf(0) }
@@ -665,7 +604,7 @@ fun ProblemDetailScreen(
problem?.let { p ->
Text(
text =
"${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}",
"${p.difficulty.system.displayName}: ${p.difficulty.grade}",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
@@ -674,7 +613,7 @@ fun ProblemDetailScreen(
problem?.let { p ->
Text(
text = p.climbType.getDisplayName(),
text = p.climbType.displayName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -854,7 +793,7 @@ fun ProblemDetailScreen(
TextButton(
onClick = {
problem?.let { p ->
viewModel.deleteProblem(p, context)
viewModel.deleteProblem(p)
onNavigateBack()
}
showDeleteDialog = false
@@ -1236,19 +1175,10 @@ fun GymDetailScreen(
}
},
supportingContent = {
val dateTime =
try {
LocalDateTime.parse(session.date)
} catch (_: Exception) {
null
}
val formattedDate =
dateTime?.format(
DateTimeFormatter.ofPattern(
"MMM dd, yyyy"
)
DateFormatUtils.formatDateForDisplay(
session.date
)
?: session.date
Text(
"$formattedDate${sessionAttempts.size} attempts"
@@ -1463,7 +1393,7 @@ fun SessionAttemptCard(
Text(
text =
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
"${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
@@ -1538,14 +1468,7 @@ fun SessionAttemptCard(
}
private fun formatDate(dateString: String): String {
return try {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val date = LocalDateTime.parse(dateString, formatter)
val displayFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy")
date.format(displayFormatter)
} catch (_: Exception) {
dateString.take(10) // Fallback to just the date part
}
return DateFormatUtils.formatDateForDisplay(dateString)
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -1567,6 +1490,7 @@ fun EnhancedAddAttemptDialog(
// New problem creation state
var newProblemName by remember { mutableStateOf("") }
var newProblemGrade by remember { mutableStateOf("") }
var newProblemImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) }
var selectedDifficultySystem by remember {
mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE)
@@ -1584,7 +1508,7 @@ fun EnhancedAddAttemptDialog(
// Auto-select difficulty system if there's only one available for the selected climb type
LaunchedEffect(selectedClimbType, gym.difficultySystems) {
val availableSystems =
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system ->
gym.difficultySystems.contains(system)
}
@@ -1604,7 +1528,7 @@ fun EnhancedAddAttemptDialog(
// Reset grade when difficulty system changes
LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades()
val availableGrades = selectedDifficultySystem.availableGrades
if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) {
newProblemGrade = ""
}
@@ -1721,7 +1645,7 @@ fun EnhancedAddAttemptDialog(
Spacer(modifier = Modifier.height(4.dp))
Text(
text =
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
"${problem.difficulty.system.displayName}: ${problem.difficulty.grade}",
style =
MaterialTheme.typography
.bodyMedium,
@@ -1730,7 +1654,7 @@ fun EnhancedAddAttemptDialog(
MaterialTheme
.colorScheme
.onSurface.copy(
alpha = 0.8f
alpha = 0.9f
)
else
MaterialTheme
@@ -1768,7 +1692,14 @@ fun EnhancedAddAttemptDialog(
color = MaterialTheme.colorScheme.onSurface
)
IconButton(onClick = { showCreateProblem = false }) {
IconButton(
onClick = {
showCreateProblem = false
newProblemName = ""
newProblemGrade = ""
newProblemImagePaths = emptyList()
}
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
@@ -1807,7 +1738,7 @@ fun EnhancedAddAttemptDialog(
onClick = { selectedClimbType = climbType },
label = {
Text(
climbType.getDisplayName(),
climbType.displayName,
fontWeight = FontWeight.Medium
)
},
@@ -1838,7 +1769,7 @@ fun EnhancedAddAttemptDialog(
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val availableSystems =
DifficultySystem.getSystemsForClimbType(
DifficultySystem.systemsForClimbType(
selectedClimbType
)
.filter { system ->
@@ -1849,7 +1780,7 @@ fun EnhancedAddAttemptDialog(
onClick = { selectedDifficultySystem = system },
label = {
Text(
system.getDisplayName(),
system.displayName,
fontWeight = FontWeight.Medium
)
},
@@ -1926,8 +1857,7 @@ fun EnhancedAddAttemptDialog(
)
} else {
var expanded by remember { mutableStateOf(false) }
val availableGrades =
selectedDifficultySystem.getAvailableGrades()
val availableGrades = selectedDifficultySystem.availableGrades
ExposedDropdownMenuBox(
expanded = expanded,
@@ -1949,8 +1879,7 @@ fun EnhancedAddAttemptDialog(
.outlinedTextFieldColors(),
modifier =
Modifier.menuAnchor(
androidx.compose.material3
.MenuAnchorType
ExposedDropdownMenuAnchorType
.PrimaryNotEditable,
enabled = true
)
@@ -1985,6 +1914,21 @@ fun EnhancedAddAttemptDialog(
}
}
}
// Photos Section
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Photos (Optional)",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
ImagePicker(
imageUris = newProblemImagePaths,
onImagesChanged = { newProblemImagePaths = it },
maxImages = 5
)
}
}
}
}
@@ -2149,7 +2093,9 @@ fun EnhancedAddAttemptDialog(
null
},
climbType = selectedClimbType,
difficulty = difficulty
difficulty = difficulty,
imagePaths =
newProblemImagePaths
)
onProblemCreated(newProblem)
@@ -2167,6 +2113,12 @@ fun EnhancedAddAttemptDialog(
notes = notes.ifBlank { null }
)
onAttemptAdded(attempt)
// Reset form
newProblemName = ""
newProblemGrade = ""
newProblemImagePaths = emptyList()
showCreateProblem = false
}
} else {
// Create attempt for selected problem

View File

@@ -87,7 +87,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
gym.supportedClimbTypes.forEach { climbType ->
AssistChip(
onClick = {},
label = { Text(climbType.getDisplayName()) },
label = { Text(climbType.displayName) },
modifier = Modifier.padding(end = 4.dp)
)
}
@@ -97,7 +97,7 @@ fun GymCard(gym: Gym, onClick: () -> Unit) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text =
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
"Systems: ${gym.difficultySystems.joinToString(", ") { it.displayName }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -11,7 +11,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -30,7 +29,6 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState()
val attempts by viewModel.attempts.collectAsState()
val context = LocalContext.current
// Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
@@ -104,7 +102,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
items(ClimbType.entries) { climbType ->
FilterChip(
onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) },
label = { Text(climbType.displayName) },
selected = selectedClimbType == climbType
)
}
@@ -183,7 +181,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
onClick = { onNavigateToProblemDetail(problem.id) },
onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem, context)
viewModel.updateProblem(updatedProblem)
}
)
Spacer(modifier = Modifier.height(8.dp))
@@ -268,7 +266,7 @@ fun ProblemCard(
}
Text(
text = problem.climbType.getDisplayName(),
text = problem.climbType.displayName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -1,16 +1,24 @@
package com.atridad.ascently.ui.screens
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
@@ -22,8 +30,18 @@ import com.atridad.ascently.data.model.SessionStatus
import com.atridad.ascently.ui.components.ActiveSessionBanner
import com.atridad.ascently.ui.components.SyncIndicator
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import com.atridad.ascently.utils.DateFormatUtils
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.Locale
import androidx.core.content.edit
enum class ViewMode {
LIST,
CALENDAR
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -34,7 +52,15 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
val activeSession by viewModel.activeSession.collectAsState()
val uiState by viewModel.uiState.collectAsState()
// Filter out active sessions from regular session list
val sharedPreferences =
context.getSharedPreferences("SessionsPreferences", Context.MODE_PRIVATE)
val savedViewMode = sharedPreferences.getString("view_mode", "LIST")
var viewMode by remember {
mutableStateOf(if (savedViewMode == "CALENDAR") ViewMode.CALENDAR else ViewMode.LIST)
}
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
var selectedDate by remember { mutableStateOf<LocalDate?>(LocalDate.now()) }
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
@@ -56,12 +82,30 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
viewMode =
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
selectedDate = null
sharedPreferences.edit { putString("view_mode", viewMode.name) }
}
) {
Icon(
imageVector =
if (viewMode == ViewMode.LIST) Icons.Default.CalendarMonth
else Icons.AutoMirrored.Filled.List,
contentDescription =
if (viewMode == ViewMode.LIST) "Calendar View" else "List View",
tint = MaterialTheme.colorScheme.primary
)
}
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
}
Spacer(modifier = Modifier.height(16.dp))
// Active session banner
ActiveSessionBanner(
activeSession = activeSession,
gym = activeSessionGym,
@@ -84,20 +128,35 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
actionText = ""
)
} else {
LazyColumn {
items(completedSessions) { session ->
SessionCard(
session = session,
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
onClick = { onNavigateToSessionDetail(session.id) }
when (viewMode) {
ViewMode.LIST -> {
LazyColumn {
items(completedSessions) { session ->
SessionCard(
session = session,
gymName = gyms.find { it.id == session.gymId }?.name
?: "Unknown Gym",
onClick = { onNavigateToSessionDetail(session.id) }
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
ViewMode.CALENDAR -> {
CalendarView(
sessions = completedSessions,
gyms = gyms,
selectedMonth = selectedMonth,
onMonthChange = { selectedMonth = it },
selectedDate = selectedDate,
onDateSelected = { selectedDate = it },
onNavigateToSessionDetail = onNavigateToSessionDetail
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
// Show UI state messages and errors
uiState.message?.let { message ->
LaunchedEffect(message) {
kotlinx.coroutines.delay(5000)
@@ -246,11 +305,234 @@ fun EmptyStateMessage(
}
}
private fun formatDate(dateString: String): String {
return try {
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
} catch (_: Exception) {
dateString
@Composable
fun CalendarView(
sessions: List<ClimbSession>,
gyms: List<com.atridad.ascently.data.model.Gym>,
selectedMonth: YearMonth,
onMonthChange: (YearMonth) -> Unit,
selectedDate: LocalDate?,
onDateSelected: (LocalDate?) -> Unit,
onNavigateToSessionDetail: (String) -> Unit
) {
val sessionsByDate =
remember(sessions) {
sessions.groupBy {
try {
java.time.Instant.parse(it.date)
.atZone(java.time.ZoneId.systemDefault())
.toLocalDate()
} catch (_: Exception) {
LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE)
}
}
}
val firstDayOfMonth = selectedMonth.atDay(1)
val daysInMonth = selectedMonth.lengthOfMonth()
val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7
val totalCells =
((firstDayOfWeek + daysInMonth) / 7.0).let {
if (it == it.toInt().toDouble()) it.toInt() * 7 else (it.toInt() + 1) * 7
}
val numRows = totalCells / 7
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier =
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) {
Text("", style = MaterialTheme.typography.headlineMedium)
}
Text(
text =
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) {
Text("", style = MaterialTheme.typography.headlineMedium)
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
val today = LocalDate.now()
onMonthChange(YearMonth.from(today))
onDateSelected(today)
},
shape = RoundedCornerShape(50),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp)
) {
Text(
text = "Today",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat").forEach { day ->
Text(
text = day,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
items(numRows) { rowIndex ->
Row(modifier = Modifier.fillMaxWidth()) {
for (colIndex in 0 until 7) {
val index = rowIndex * 7 + colIndex
val dayNumber = index - firstDayOfWeek + 1
Box(modifier = Modifier.weight(1f)) {
if (dayNumber in 1..daysInMonth) {
val date = selectedMonth.atDay(dayNumber)
val sessionsOnDate = sessionsByDate[date] ?: emptyList()
val isSelected = date == selectedDate
val isToday = date == LocalDate.now()
CalendarDay(
day = dayNumber,
hasSession = sessionsOnDate.isNotEmpty(),
isSelected = isSelected,
isToday = isToday,
onClick = {
if (sessionsOnDate.isNotEmpty()) {
onDateSelected(if (isSelected) null else date)
}
}
)
} else {
Spacer(modifier = Modifier.aspectRatio(1f))
}
}
}
}
}
if (selectedDate != null) {
val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList()
item {
Spacer(modifier = Modifier.height(16.dp))
Text(
text =
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp)
)
}
items(sessionsOnSelectedDate) { session ->
SessionCard(
session = session,
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
onClick = { onNavigateToSessionDetail(session.id) }
)
Spacer(modifier = Modifier.height(8.dp))
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
@Composable
fun CalendarDay(
day: Int,
hasSession: Boolean,
isSelected: Boolean,
isToday: Boolean,
onClick: () -> Unit
) {
Box(
modifier =
Modifier.aspectRatio(1f)
.padding(2.dp)
.clip(CircleShape)
.background(
when {
isSelected -> MaterialTheme.colorScheme.primaryContainer
isToday -> MaterialTheme.colorScheme.secondaryContainer
else -> Color.Transparent
}
)
.clickable(enabled = hasSession, onClick = onClick),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = day.toString(),
style = MaterialTheme.typography.bodyMedium,
color =
when {
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
isToday -> MaterialTheme.colorScheme.onSecondaryContainer
!hasSession -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurface
},
fontWeight = if (hasSession || isToday) FontWeight.Bold else FontWeight.Normal
)
if (hasSession) {
Box(
modifier =
Modifier.size(6.dp)
.clip(CircleShape)
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.primary.copy(
alpha = 0.7f
)
)
)
}
}
}
}
private fun formatDate(dateString: String): String {
return DateFormatUtils.formatDateForDisplay(dateString)
}

View File

@@ -50,15 +50,15 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
var isDeletingImages by remember { mutableStateOf(false) }
// Sync configuration state
var serverUrl by remember { mutableStateOf(syncService.serverURL) }
var serverUrl by remember { mutableStateOf(syncService.serverUrl) }
var authToken by remember { mutableStateOf(syncService.authToken) }
val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) }
val appVersion = packageInfo.versionName
// Update local state when sync service configuration changes
LaunchedEffect(syncService.serverURL, syncService.authToken) {
serverUrl = syncService.serverURL
LaunchedEffect(syncService.serverUrl, syncService.authToken) {
serverUrl = syncService.serverUrl
authToken = syncService.authToken
}
@@ -183,7 +183,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
},
supportingContent = {
Column {
Text("Server: ${syncService.serverURL}")
Text("Server: ${syncService.serverUrl}")
lastSyncTime?.let { time ->
Text(
"Last sync: ${
@@ -216,9 +216,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
// Manual Sync Button
TextButton(
onClick = {
coroutineScope.launch {
viewModel.performManualSync()
}
viewModel.performManualSync()
},
enabled = isConnected && !isSyncing
) {
@@ -583,41 +581,6 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
Spacer(modifier = Modifier.height(12.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
painter =
painterResource(
id = R.drawable.ic_mountains
),
contentDescription = "Ascently Logo",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text("Ascently")
}
},
supportingContent = { Text("Track your climbing progress") },
leadingContent = {}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
@@ -863,7 +826,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
onClick = {
coroutineScope.launch {
try {
syncService.serverURL = serverUrl.trim()
syncService.serverUrl = serverUrl.trim()
syncService.authToken = authToken.trim()
viewModel.testSyncConnection()
while (syncService.isTesting.value) {
@@ -905,7 +868,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
onClick = {
coroutineScope.launch {
try {
syncService.serverURL = serverUrl.trim()
syncService.serverUrl = serverUrl.trim()
syncService.authToken = authToken.trim()
viewModel.testSyncConnection()
while (syncService.isTesting.value) {
@@ -932,7 +895,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
dismissButton = {
TextButton(
onClick = {
serverUrl = syncService.serverURL
serverUrl = syncService.serverUrl
authToken = syncService.authToken
showSyncConfigDialog = false
}
@@ -981,7 +944,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
isDeletingImages = true
showDeleteImagesDialog = false
coroutineScope.launch {
viewModel.deleteAllImages(context)
viewModel.deleteAllImages()
isDeletingImages = false
viewModel.setMessage("All images deleted successfully!")
}

View File

@@ -8,20 +8,17 @@ import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.sync.SyncService
import com.atridad.ascently.service.SessionTrackingService
import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ImageUtils
import com.atridad.ascently.utils.SessionShareUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ClimbViewModel(
private val repository: ClimbRepository,
val syncService: SyncService,
private val context: Context
private val repository: ClimbRepository,
val syncService: SyncService,
private val context: Context
) : ViewModel() {
// Health Connect manager
@@ -33,109 +30,102 @@ class ClimbViewModel(
// Data flows
val gyms =
repository
.getAllGyms()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
repository
.getAllGyms()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val problems =
repository
.getAllProblems()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
repository
.getAllProblems()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val sessions =
repository
.getAllSessions()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
repository
.getAllSessions()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val activeSession =
repository
.getActiveSessionFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = null
)
repository
.getActiveSessionFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = null
)
val attempts =
repository
.getAllAttempts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
repository
.getAllAttempts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
// Gym operations
fun addGym(gym: Gym) {
viewModelScope.launch { repository.insertGym(gym) }
}
fun addGym(gym: Gym, context: Context) {
fun addGym(gym: Gym, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.insertGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun updateGym(gym: Gym) {
viewModelScope.launch { repository.updateGym(gym) }
}
fun updateGym(gym: Gym, context: Context) {
fun updateGym(gym: Gym, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.updateGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun deleteGym(gym: Gym) {
viewModelScope.launch { repository.deleteGym(gym) }
}
fun deleteGym(gym: Gym, context: Context) {
fun deleteGym(gym: Gym, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.deleteGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
// Problem operations
fun addProblem(problem: Problem, context: Context) {
fun addProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch {
val finalProblem = renameTemporaryImages(problem, context)
val finalProblem = renameTemporaryImages(problem)
repository.insertProblem(finalProblem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem {
private fun renameTemporaryImages(problem: Problem): Problem {
if (problem.imagePaths.isEmpty()) {
return problem
}
val appContext = context ?: return problem
val finalImagePaths = mutableListOf<String>()
problem.imagePaths.forEachIndexed { index, tempPath ->
if (tempPath.startsWith("temp_")) {
val deterministicName = ImageNamingUtils.generateImageFilename(problem.id, index)
val finalPath =
ImageUtils.renameTemporaryImage(appContext, tempPath, problem.id, index)
ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index)
finalImagePaths.add(finalPath ?: tempPath)
} else {
finalImagePaths.add(tempPath)
@@ -145,34 +135,34 @@ class ClimbViewModel(
return problem.copy(imagePaths = finalImagePaths)
}
fun updateProblem(problem: Problem, context: Context) {
fun updateProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch {
val finalProblem = renameTemporaryImages(problem, context)
val finalProblem = renameTemporaryImages(problem)
repository.updateProblem(finalProblem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun deleteProblem(problem: Problem, context: Context) {
fun deleteProblem(problem: Problem, updateWidgets: Boolean = true) {
viewModelScope.launch {
// Delete associated images
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
repository.deleteProblem(problem)
cleanupOrphanedImages(context)
ClimbStatsWidgetProvider.updateAllWidgets(context)
cleanupOrphanedImages()
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
private suspend fun cleanupOrphanedImages(context: Context) {
private suspend fun cleanupOrphanedImages() {
val allProblems = repository.getAllProblems().first()
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
}
fun deleteAllImages(context: Context) {
fun deleteAllImages() {
viewModelScope.launch {
val imagesDir = ImageUtils.getImagesDirectory(context)
var deletedCount = 0
@@ -187,23 +177,23 @@ class ClimbViewModel(
val allProblems = repository.getAllProblems().first()
val updatedProblems =
allProblems.map { problem ->
if (problem.imagePaths.isNotEmpty()) {
problem.copy(imagePaths = emptyList())
} else {
problem
}
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
allProblems.find { it.id == updatedProblem.id }?.imagePaths
) {
repository.insertProblemWithoutSync(updatedProblem)
}
}
println("Deleted $deletedCount image files and cleared image references")
AppLogger.i("ClimbViewModel") { "Deleted $deletedCount image files and cleared image references" }
}
}
@@ -212,36 +202,22 @@ class ClimbViewModel(
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
// Session operations
fun addSession(session: ClimbSession) {
viewModelScope.launch { repository.insertSession(session) }
}
fun addSession(session: ClimbSession, context: Context) {
viewModelScope.launch {
repository.insertSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
fun updateSession(session: ClimbSession) {
viewModelScope.launch { repository.updateSession(session) }
}
fun updateSession(session: ClimbSession, context: Context) {
fun updateSession(session: ClimbSession, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.updateSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun deleteSession(session: ClimbSession) {
viewModelScope.launch { repository.deleteSession(session) }
}
fun deleteSession(session: ClimbSession, context: Context) {
fun deleteSession(session: ClimbSession, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.deleteSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
@@ -250,7 +226,7 @@ class ClimbViewModel(
}
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
repository.getSessionsByGym(gymId)
repository.getSessionsByGym(gymId)
// Get last used gym for shortcut functionality
suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym()
@@ -258,41 +234,35 @@ class ClimbViewModel(
// Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch {
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
AppLogger.d("ClimbViewModel") { "startSession called with gymId: $gymId" }
if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context)
.isNotificationPermissionGranted(context)
) {
android.util.Log.d("ClimbViewModel", "Notification permission not granted")
AppLogger.d("ClimbViewModel") { "Notification permission not granted" }
_uiState.value =
_uiState.value.copy(
error =
"Notification permission is required to track your climbing session. Please enable notifications in settings."
)
_uiState.value.copy(
error =
"Notification permission is required to track your climbing session. Please enable notifications in settings."
)
return@launch
}
val existingActive = repository.getActiveSession()
if (existingActive != null) {
android.util.Log.d(
"ClimbViewModel",
"Active session already exists: ${existingActive.id}"
)
AppLogger.d("ClimbViewModel") { "Active session already exists: ${existingActive.id}" }
_uiState.value =
_uiState.value.copy(
error = "There's already an active session. Please end it first."
)
_uiState.value.copy(
error = "There's already an active session. Please end it first."
)
return@launch
}
android.util.Log.d("ClimbViewModel", "Creating new session")
AppLogger.d("ClimbViewModel") { "Creating new session" }
val newSession = ClimbSession.create(gymId = gymId, notes = notes)
repository.insertSession(newSession)
android.util.Log.d(
"ClimbViewModel",
"Starting tracking service for session: ${newSession.id}"
)
AppLogger.d("ClimbViewModel") { "Starting tracking service for session: ${newSession.id}" }
// Start the tracking service
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
context.startForegroundService(serviceIntent)
@@ -306,13 +276,13 @@ class ClimbViewModel(
fun endSession(context: Context, sessionId: String) {
viewModelScope.launch {
if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context)
.isNotificationPermissionGranted(context)
) {
_uiState.value =
_uiState.value.copy(
error =
"Notification permission is required to manage your climbing session. Please enable notifications in settings."
)
_uiState.value.copy(
error =
"Notification permission is required to manage your climbing session. Please enable notifications in settings."
)
return@launch
}
@@ -338,73 +308,67 @@ class ClimbViewModel(
val activeSession = repository.getActiveSession()
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
val serviceIntent =
SessionTrackingService.createStartIntent(context, activeSession.id)
SessionTrackingService.createStartIntent(context, activeSession.id)
context.startForegroundService(serviceIntent)
}
}
}
// Attempt operations
fun addAttempt(attempt: Attempt) {
viewModelScope.launch { repository.insertAttempt(attempt) }
}
fun addAttempt(attempt: Attempt, context: Context) {
fun addAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.insertAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun deleteAttempt(attempt: Attempt) {
viewModelScope.launch { repository.deleteAttempt(attempt) }
}
fun deleteAttempt(attempt: Attempt, context: Context) {
fun deleteAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.deleteAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun updateAttempt(attempt: Attempt) {
viewModelScope.launch { repository.updateAttempt(attempt) }
}
fun updateAttempt(attempt: Attempt, context: Context) {
fun updateAttempt(attempt: Attempt, updateWidgets: Boolean = true) {
viewModelScope.launch {
repository.updateAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
if (updateWidgets) {
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
}
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId)
repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
repository.getAttemptsByProblem(problemId)
repository.getAttemptsByProblem(problemId)
fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch {
try {
_uiState.value =
_uiState.value.copy(
isLoading = true,
message = "Creating ZIP file with images..."
)
_uiState.value.copy(
isLoading = true,
message = "Creating ZIP file with images..."
)
repository.exportAllDataToZipUri(context, uri)
_uiState.value =
_uiState.value.copy(
isLoading = false,
message =
"Export complete! Your climbing data and images have been saved."
)
_uiState.value.copy(
isLoading = false,
message =
"Export complete! Your climbing data and images have been saved."
)
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(
isLoading = false,
error = "Export failed: ${e.message}"
)
_uiState.value.copy(
isLoading = false,
error = "Export failed: ${e.message}"
)
}
}
}
@@ -416,23 +380,23 @@ class ClimbViewModel(
if (!file.name.lowercase().endsWith(".zip")) {
throw Exception(
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
)
}
repository.importDataFromZip(file)
_uiState.value =
_uiState.value.copy(
isLoading = false,
message = "Data imported successfully from ${file.name}"
)
_uiState.value.copy(
isLoading = false,
message = "Data imported successfully from ${file.name}"
)
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(
isLoading = false,
error = "Import failed: ${e.message}"
)
_uiState.value.copy(
isLoading = false,
error = "Import failed: ${e.message}"
)
}
}
}
@@ -447,11 +411,13 @@ class ClimbViewModel(
}
// Sync-related methods
suspend fun performManualSync() {
try {
syncService.syncWithServer()
} catch (e: Exception) {
setError("Sync failed: ${e.message}")
fun performManualSync() {
viewModelScope.launch {
try {
syncService.syncWithServer()
} catch (e: Exception) {
setError("Sync failed: ${e.message}")
}
}
}
@@ -479,13 +445,13 @@ class ClimbViewModel(
repository.resetAllData()
_uiState.value =
_uiState.value.copy(
isLoading = false,
message = "All data has been reset successfully"
)
_uiState.value.copy(
isLoading = false,
message = "All data has been reset successfully"
)
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
_uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
}
}
}
@@ -499,111 +465,29 @@ class ClimbViewModel(
val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size
val result = healthConnectManager.autoSyncSession(session, gymName, attemptCount)
val result =
healthConnectManager.autoSyncCompletedSession(
session,
gymName,
attemptCount
)
result
.onSuccess {
_uiState.value =
_uiState.value.copy(
message =
"Session synced to Health Connect successfully!"
)
}
.onFailure { error ->
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(
error =
"Failed to sync to Health Connect: ${error.message}"
)
}
}
result.onFailure { error ->
if (healthConnectManager.isReadySync()) {
AppLogger.w("ClimbViewModel") { "Health Connect sync failed: ${error.message}" }
}
}
} catch (e: Exception) {
if (healthConnectManager.isReadySync()) {
_uiState.value =
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
AppLogger.w("ClimbViewModel") { "Health Connect sync error: ${e.message}" }
}
}
}
}
fun manualSyncToHealthConnect(sessionId: String) {
viewModelScope.launch {
try {
val session = repository.getSessionById(sessionId)
if (session == null) {
_uiState.value = _uiState.value.copy(error = "Session not found")
return@launch
}
if (session.status != SessionStatus.COMPLETED) {
_uiState.value =
_uiState.value.copy(error = "Only completed sessions can be synced")
return@launch
}
val gym = repository.getGymById(session.gymId)
val gymName = gym?.name ?: "Unknown Gym"
val attempts = repository.getAttemptsBySession(session.id).first()
val attemptCount = attempts.size
val result =
healthConnectManager.syncClimbingSession(session, gymName, attemptCount)
result
.onSuccess {
_uiState.value =
_uiState.value.copy(
message =
"Session synced to Health Connect successfully!"
)
}
.onFailure { error ->
_uiState.value =
_uiState.value.copy(
error =
"Failed to sync to Health Connect: ${error.message}"
)
}
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(error = "Health Connect sync error: ${e.message}")
}
}
}
fun getHealthConnectManager(): HealthConnectManager = healthConnectManager
// Share operations
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
withContext(Dispatchers.IO) {
try {
val session = repository.getSessionById(sessionId) ?: return@withContext null
val attempts = repository.getAttemptsBySession(sessionId).first()
val problems =
repository.getAllProblems().first().filter { problem ->
attempts.any { it.problemId == problem.id }
}
val gym = repository.getGymById(session.gymId) ?: return@withContext null
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
SessionShareUtils.generateShareCard(context, session, gym, stats)
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(
error = "Failed to generate share card: ${e.message}"
)
null
}
}
fun shareSessionCard(context: Context, imageFile: File) {
SessionShareUtils.shareSessionCard(context, imageFile)
}
}
data class ClimbUiState(
val isLoading: Boolean = false,
val message: String? = null,
val error: String? = null
val isLoading: Boolean = false,
val message: String? = null,
val error: String? = null
)

View File

@@ -0,0 +1,48 @@
package com.atridad.ascently.utils
import android.util.Log
import com.atridad.ascently.BuildConfig
object AppLogger {
private const val DEFAULT_TAG = "Ascently"
enum class Level(val androidLevel: Int) {
DEBUG(Log.DEBUG),
INFO(Log.INFO),
WARN(Log.WARN),
ERROR(Log.ERROR)
}
fun d(tag: String = DEFAULT_TAG, messageProvider: () -> String) {
log(Level.DEBUG, tag, messageProvider)
}
fun i(tag: String = DEFAULT_TAG, messageProvider: () -> String) {
log(Level.INFO, tag, messageProvider)
}
fun w(tag: String = DEFAULT_TAG, throwable: Throwable? = null, messageProvider: () -> String) {
log(Level.WARN, tag, messageProvider, throwable)
}
fun e(tag: String = DEFAULT_TAG, throwable: Throwable? = null, messageProvider: () -> String) {
log(Level.ERROR, tag, messageProvider, throwable)
}
private fun log(
level: Level,
tag: String,
messageProvider: () -> String,
throwable: Throwable? = null
) {
if (!BuildConfig.DEBUG) return
val message = messageProvider()
if (throwable != null) {
Log.println(level.androidLevel, tag, "$message\n${Log.getStackTraceString(throwable)}")
} else {
Log.println(level.androidLevel, tag, message)
}
}
}

View File

@@ -1,6 +1,8 @@
package com.atridad.ascently.utils
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@@ -15,32 +17,47 @@ object DateFormatUtils {
return ISO_FORMATTER.format(Instant.now())
}
/** Format an Instant to iOS-compatible ISO 8601 format */
fun formatISO8601(instant: Instant): String {
return ISO_FORMATTER.format(instant)
}
/** Parse an iOS-compatible ISO 8601 date string back to Instant */
fun parseISO8601(dateString: String): Instant? {
return try {
Instant.from(ISO_FORMATTER.parse(dateString))
} catch (e: Exception) {
} catch (_: Exception) {
try {
Instant.parse(dateString)
} catch (e2: Exception) {
} catch (_: Exception) {
null
}
}
}
/** Validate that a date string matches the expected iOS format */
fun isValidISO8601(dateString: String): Boolean {
return parseISO8601(dateString) != null
/**
* 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): String {
val pattern = "MMM dd, yyyy"
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 (_: Exception) {
dateString.take(10)
}
}
/** Convert milliseconds timestamp to iOS-compatible ISO 8601 format */
fun millisToISO8601(millis: Long): String {
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
/** 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 (_: Exception) {
null
}
}
}

View File

@@ -1,7 +1,6 @@
package com.atridad.ascently.utils
import java.security.MessageDigest
import java.util.*
/**
* Utility for creating consistent image filenames across iOS and Android platforms. Uses
@@ -21,51 +20,11 @@ object ImageNamingUtils {
}
/** Legacy method for backward compatibility */
@Suppress("UNUSED_PARAMETER")
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
return generateImageFilename(problemId, imageIndex)
}
/** Extracts problem ID from an image filename */
fun extractProblemIdFromFilename(filename: String): String? {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return null
}
// Format: problem_{hash}_{index}.jpg
val nameWithoutExtension = filename.substring(0, filename.length - IMAGE_EXTENSION.length)
val parts = nameWithoutExtension.split("_")
if (parts.size != 3 || parts[0] != "problem") {
return null
}
return parts[1]
}
/** Validates if a filename follows our naming convention */
fun isValidImageFilename(filename: String): Boolean {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return false
}
val nameWithoutExtension = filename.substring(0, filename.length - IMAGE_EXTENSION.length)
val parts = nameWithoutExtension.split("_")
return parts.size == 3 &&
parts[0] == "problem" &&
parts[1].length == HASH_LENGTH &&
parts[2].toIntOrNull() != null
}
/** Migrates an existing filename to our naming convention */
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
if (isValidImageFilename(oldFilename)) {
return oldFilename
}
return generateImageFilename(problemId, imageIndex)
}
/** Creates a deterministic hash from input string */
private fun createHash(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
@@ -74,53 +33,5 @@ object ImageNamingUtils {
return hashHex.take(HASH_LENGTH)
}
/** Batch renames images for a problem to use our naming convention */
fun batchRenameForProblem(
problemId: String,
existingFilenames: List<String>
): Map<String, String> {
val renameMap = mutableMapOf<String, String>()
existingFilenames.forEachIndexed { index, oldFilename ->
val newFilename = generateImageFilename(problemId, index)
if (newFilename != oldFilename) {
renameMap[oldFilename] = newFilename
}
}
return renameMap
}
/** Generates the canonical filename for a problem image */
fun getCanonicalImageFilename(problemId: String, imageIndex: Int): String {
return generateImageFilename(problemId, imageIndex)
}
/** Creates a mapping of existing server filenames to canonical filenames */
fun createServerMigrationMap(
problemId: String,
serverImageFilenames: List<String>,
localImageCount: Int
): Map<String, String> {
val migrationMap = mutableMapOf<String, String>()
for (imageIndex in 0 until localImageCount) {
val canonicalName = getCanonicalImageFilename(problemId, imageIndex)
if (serverImageFilenames.contains(canonicalName)) {
continue
}
for (serverFilename in serverImageFilenames) {
if (isValidImageFilename(serverFilename) &&
!migrationMap.values.contains(serverFilename)
) {
migrationMap[serverFilename] = canonicalName
break
}
}
}
return migrationMap
}
}

View File

@@ -4,9 +4,8 @@ import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import java.io.File
@@ -30,17 +29,17 @@ object ImageUtils {
/** Saves an image from a URI while preserving EXIF orientation data */
private fun saveImageWithExif(
context: Context,
imageUri: Uri,
originalBitmap: Bitmap,
outputFile: File
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)
}
context.contentResolver.openInputStream(imageUri)?.use { input ->
ExifInterface(input)
}
// Compress and save the bitmap
val compressedBitmap = compressImage(originalBitmap)
@@ -73,106 +72,11 @@ object ImageUtils {
compressedBitmap.recycle()
true
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Error saving image with EXIF data" }
false
}
}
/** Saves an image from a URI with compression */
fun saveImageFromUri(
context: Context,
imageUri: Uri,
problemId: String? = null,
imageIndex: Int? = null
): String? {
return try {
val originalBitmap =
context.contentResolver.openInputStream(imageUri)?.use { input ->
BitmapFactory.decodeStream(input)
}
?: return null
// Always require deterministic naming
require(problemId != null && imageIndex != null) {
"Problem ID and image index are required for deterministic image naming"
}
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val imageFile = File(getImagesDirectory(context), filename)
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
originalBitmap.recycle()
if (!success) return null
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/** Corrects image orientation based on EXIF data */
private fun correctImageOrientation(context: Context, imageUri: Uri, bitmap: Bitmap): Bitmap {
return try {
val inputStream = context.contentResolver.openInputStream(imageUri)
inputStream?.use { input ->
val exif = androidx.exifinterface.media.ExifInterface(input)
val orientation =
exif.getAttributeInt(
androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL
)
val matrix = android.graphics.Matrix()
when (orientation) {
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
}
}
if (matrix.isIdentity) {
bitmap
} else {
android.graphics.Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true
)
}
}
?: bitmap
} catch (e: Exception) {
e.printStackTrace()
bitmap
}
}
/** Compresses and resizes an image bitmap */
@SuppressLint("UseKtx")
private fun compressImage(original: Bitmap): Bitmap {
@@ -181,11 +85,11 @@ object ImageUtils {
// Calculate the scaling factor
val scaleFactor =
if (width > height) {
if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f
} else {
if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f
}
if (width > height) {
if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f
} else {
if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f
}
return if (scaleFactor < 1f) {
val newWidth = (width * scaleFactor).toInt()
@@ -214,7 +118,7 @@ object ImageUtils {
val file = getImageFile(context, relativePath)
file.delete()
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to delete image: $relativePath" }
false
}
}
@@ -232,7 +136,7 @@ object ImageUtils {
sourceFile.copyTo(destFile, overwrite = true)
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to import image from source: ${sourceFile.name}" }
null
}
}
@@ -243,16 +147,16 @@ object ImageUtils {
val imagesDir = getImagesDirectory(context)
imagesDir.listFiles()?.mapNotNull { file ->
if (file.isFile &&
(file.extension == "jpg" ||
file.extension == "jpeg" ||
file.extension == "png")
(file.extension == "jpg" ||
file.extension == "jpeg" ||
file.extension == "png")
) {
"$IMAGES_DIR/${file.name}"
} else null
}
?: emptyList()
?: emptyList()
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to enumerate images directory" }
emptyList()
}
}
@@ -260,9 +164,8 @@ 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 source = ImageDecoder.createSource(context.contentResolver, imageUri)
val originalBitmap = ImageDecoder.decodeBitmap(source)
val tempFilename = "temp_${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), tempFilename)
@@ -274,78 +177,47 @@ object ImageUtils {
tempFilename
} catch (e: Exception) {
Log.e("ImageUtils", "Error saving temporary image from URI", e)
AppLogger.e("ImageUtils", e) { "Error saving temporary image from URI" }
null
}
}
/** Renames a temporary image */
fun renameTemporaryImage(
context: Context,
tempFilename: String,
problemId: String,
imageIndex: Int
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")
AppLogger.e("ImageUtils") { "Temporary file does not exist: $tempFilename" }
return null
}
val deterministicFilename =
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val finalFile = File(getImagesDirectory(context), deterministicFilename)
if (tempFile.renameTo(finalFile)) {
Log.d(
"ImageUtils",
"Renamed temporary image: $tempFilename -> $deterministicFilename"
)
AppLogger.d("ImageUtils") { "Renamed temporary image: $tempFilename -> $deterministicFilename" }
deterministicFilename
} else {
Log.e("ImageUtils", "Failed to rename temporary image: $tempFilename")
AppLogger.e("ImageUtils") { "Failed to rename temporary image: $tempFilename" }
null
}
} catch (e: Exception) {
Log.e("ImageUtils", "Error renaming temporary image", e)
null
}
}
/** Saves an image from byte array to app's private storage */
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
return try {
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap)
// Generate unique filename
val filename = "${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), filename)
// Save compressed image
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Clean up bitmaps
bitmap.recycle()
compressedBitmap.recycle()
// Return relative path
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Error renaming temporary image" }
null
}
}
/** Saves image data with a specific filename */
fun saveImageFromBytesWithFilename(
context: Context,
imageData: ByteArray,
filename: String
context: Context,
imageData: ByteArray,
filename: String
): String? {
return try {
val imageFile = File(getImagesDirectory(context), filename)
@@ -354,7 +226,7 @@ object ImageUtils {
if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold
// For large images, decode, compress, and try to preserve EXIF
val bitmap =
BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap)
// Save compressed image
@@ -373,7 +245,7 @@ object ImageUtils {
destExif.saveAttributes()
} catch (e: Exception) {
// If EXIF preservation fails, continue without it
Log.w("ImageUtils", "Failed to preserve EXIF data: ${e.message}")
AppLogger.w("ImageUtils") { "Failed to preserve EXIF data: ${e.message}" }
}
bitmap.recycle()
@@ -386,57 +258,11 @@ object ImageUtils {
// Return relative path
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to save image from bytes: $filename" }
null
}
}
/** Migrates existing images to use consistent naming convention */
fun migrateImageNaming(
context: Context,
problemId: String,
currentImagePaths: List<String>
): Map<String, String> {
val migrationMap = mutableMapOf<String, String>()
currentImagePaths.forEachIndexed { index, oldPath ->
val oldFilename = oldPath.substringAfterLast('/')
val newFilename = ImageNamingUtils.migrateFilename(oldFilename, problemId, index)
if (oldFilename != newFilename) {
try {
val oldFile = getImageFile(context, oldPath)
val newFile = File(getImagesDirectory(context), newFilename)
if (oldFile.exists() && oldFile.renameTo(newFile)) {
val newPath = "$IMAGES_DIR/$newFilename"
migrationMap[oldPath] = newPath
}
} catch (e: Exception) {
// Log error but continue with other images
e.printStackTrace()
}
}
}
return migrationMap
}
/** Batch migrates all images in the system to use consistent naming */
fun batchMigrateAllImages(
context: Context,
problemImageMap: Map<String, List<String>>
): Map<String, String> {
val allMigrations = mutableMapOf<String, String>()
problemImageMap.forEach { (problemId, imagePaths) ->
val migrations = migrateImageNaming(context, problemId, imagePaths)
allMigrations.putAll(migrations)
}
return allMigrations
}
/** Cleans up orphaned images that are not referenced by any problems */
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
try {
@@ -445,7 +271,7 @@ object ImageUtils {
orphanedImages.forEach { path -> deleteImage(context, path) }
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to clean up orphaned images" }
}
}
}

View File

@@ -2,13 +2,8 @@ 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 {
@@ -18,7 +13,7 @@ class MigrationManager(private val context: Context) {
}
private val migrationPrefs: SharedPreferences =
context.getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE)
context.getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE)
/**
* Perform migration from OpenClimb to Ascently if needed This should be called early in app
@@ -26,11 +21,11 @@ class MigrationManager(private val context: Context) {
*/
fun migrateIfNeeded() {
if (migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) {
Log.d(TAG, "Migration already completed, skipping")
AppLogger.d(TAG) { "Migration already completed, skipping" }
return
}
Log.i(TAG, "🔄 Starting migration from OpenClimb to Ascently...")
AppLogger.i(TAG) { "🔄 Starting migration from OpenClimb to Ascently..." }
var migrationCount = 0
// Migrate SharedPreferences
@@ -40,12 +35,9 @@ class MigrationManager(private val context: Context) {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, true) }
if (migrationCount > 0) {
Log.i(
TAG,
"🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently"
)
AppLogger.i(TAG) { "🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently" }
} else {
Log.i(TAG, " No OpenClimb data found to migrate")
AppLogger.i(TAG) { " No OpenClimb data found to migrate" }
}
}
@@ -55,12 +47,12 @@ class MigrationManager(private val context: Context) {
// 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
)
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) {
@@ -99,10 +91,7 @@ class MigrationManager(private val context: Context) {
// Clear old preferences
oldPrefs.edit { clear() }
Log.d(
TAG,
"✅ Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)"
)
AppLogger.d(TAG) { "Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)" }
return oldPrefs.all.size
}
@@ -115,12 +104,12 @@ class MigrationManager(private val context: Context) {
// Check for any openclimb-prefixed keys across all preference files
val preferencesToCheck =
listOf(
"ascently_data_state",
"health_connect_prefs",
"deleted_items",
"sync_preferences"
)
listOf(
"ascently_data_state",
"health_connect_prefs",
"deleted_items",
"sync_preferences"
)
for (prefFileName in preferencesToCheck) {
val prefs = context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
@@ -154,7 +143,7 @@ class MigrationManager(private val context: Context) {
}
}
Log.d(TAG, "Migrated ${keysToMigrate.size} keys in $prefFileName")
AppLogger.d(TAG) { "Migrated ${keysToMigrate.size} keys in $prefFileName" }
count += keysToMigrate.size
}
}
@@ -170,6 +159,6 @@ class MigrationManager(private val context: Context) {
/** Reset migration state (for testing purposes) */
fun resetMigrationState() {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, false) }
Log.d(TAG, "Migration state reset")
AppLogger.d(TAG) { "Migration state reset" }
}
}

View File

@@ -1,543 +0,0 @@
package com.atridad.ascently.utils
import android.content.Context
import android.content.Intent
import android.graphics.*
import android.graphics.drawable.GradientDrawable
import androidx.core.content.FileProvider
import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt
import com.atridad.ascently.data.model.*
import java.io.File
import java.io.FileOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
object SessionShareUtils {
data class SessionStats(
val totalAttempts: Int,
val successfulAttempts: Int,
val problems: List<Problem>,
val uniqueProblemsAttempted: Int,
val uniqueProblemsCompleted: Int,
val averageGrade: String?,
val sessionDuration: String,
val topResult: AttemptResult?,
val topGrade: String?
)
fun calculateSessionStats(
session: ClimbSession,
attempts: List<Attempt>,
problems: List<Problem>
): SessionStats {
val successfulResults = listOf(AttemptResult.SUCCESS, AttemptResult.FLASH)
val successfulAttempts = attempts.filter { it.result in successfulResults }
val uniqueProblems = attempts.map { it.problemId }.distinct()
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in uniqueProblems }
// Calculate separate averages for different climbing types and difficulty systems
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder")
val ropeAverage = calculateAverageGrade(ropeProblems, "Rope")
// Combine averages for display
val averageGrade =
when {
boulderAverage != null && ropeAverage != null ->
"$boulderAverage / $ropeAverage"
boulderAverage != null -> boulderAverage
ropeAverage != null -> ropeAverage
else -> null
}
// Determine highest achieved grade (only from completed problems: SUCCESS or FLASH)
val completedProblems = problems.filter { it.id in uniqueCompletedProblems }
val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER }
val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE }
val topBoulder = highestGradeForProblems(completedBoulder)
val topRope = highestGradeForProblems(completedRope)
val topGrade =
when {
topBoulder != null && topRope != null -> "$topBoulder / $topRope"
topBoulder != null -> topBoulder
topRope != null -> topRope
else -> null
}
val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
val topResult =
attempts
.maxByOrNull {
when (it.result) {
AttemptResult.FLASH -> 3
AttemptResult.SUCCESS -> 2
AttemptResult.FALL -> 1
else -> 0
}
}
?.result
return SessionStats(
totalAttempts = attempts.size,
successfulAttempts = successfulAttempts.size,
problems = attemptedProblems,
uniqueProblemsAttempted = uniqueProblems.size,
uniqueProblemsCompleted = uniqueCompletedProblems.size,
averageGrade = averageGrade,
sessionDuration = duration,
topResult = topResult,
topGrade = topGrade
)
}
/**
* Calculate average grade for a specific set of problems, respecting their difficulty systems
*/
private fun calculateAverageGrade(problems: List<Problem>, climbingType: String): String? {
if (problems.isEmpty()) return null
// Group problems by difficulty system
val problemsBySystem = problems.groupBy { it.difficulty.system }
val averages = mutableListOf<String>()
problemsBySystem.forEach { (system, systemProblems) ->
when (system) {
DifficultySystem.V_SCALE -> {
val gradeValues =
systemProblems.mapNotNull { problem ->
when {
problem.difficulty.grade == "VB" -> 0
else -> problem.difficulty.grade.removePrefix("V").toIntOrNull()
}
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt()
averages.add(if (avg == 0) "VB" else "V$avg")
}
}
DifficultySystem.FONT -> {
val gradeValues =
systemProblems.mapNotNull { problem ->
// Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" ->
// 7)
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt()
averages.add("$avg")
}
}
DifficultySystem.YDS -> {
val gradeValues =
systemProblems.mapNotNull { problem ->
// Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10)
val grade = problem.difficulty.grade
if (grade.startsWith("5.")) {
grade.substring(2).toDoubleOrNull()
} else null
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average()
averages.add("5.${String.format("%.1f", avg)}")
}
}
DifficultySystem.CUSTOM -> {
// For custom systems, try to extract numeric values
val gradeValues =
systemProblems.mapNotNull { problem ->
problem.difficulty
.grade
.filter { it.isDigit() || it == '.' || it == '-' }
.toDoubleOrNull()
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average()
averages.add(String.format("%.1f", avg))
}
}
}
}
return if (averages.isNotEmpty()) {
if (averages.size == 1) {
averages.first()
} else {
averages.joinToString(" / ")
}
} else null
}
fun generateShareCard(
context: Context,
session: ClimbSession,
gym: Gym,
stats: SessionStats
): File? {
return try {
val width = 1242 // 3:4 aspect at higher resolution for better fit
val height = 1656
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val gradientDrawable =
GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf("#667eea".toColorInt(), "#764ba2".toColorInt())
)
gradientDrawable.setBounds(0, 0, width, height)
gradientDrawable.draw(canvas)
// Setup paint objects
val titlePaint =
Paint().apply {
color = Color.WHITE
textSize = 72f
typeface = Typeface.DEFAULT_BOLD
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val subtitlePaint =
Paint().apply {
color = "#E8E8E8".toColorInt()
textSize = 48f
typeface = Typeface.DEFAULT
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val statLabelPaint =
Paint().apply {
color = "#B8B8B8".toColorInt()
textSize = 36f
typeface = Typeface.DEFAULT
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val statValuePaint =
Paint().apply {
color = Color.WHITE
textSize = 64f
typeface = Typeface.DEFAULT_BOLD
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val cardPaint =
Paint().apply {
color = "#40FFFFFF".toColorInt()
isAntiAlias = true
}
// Draw main card background
val cardRect = RectF(60f, 200f, width - 60f, height - 120f)
canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
// Draw content
var yPosition = 300f
// Title
canvas.drawText("Climbing Session", width / 2f, yPosition, titlePaint)
yPosition += 80f
// Gym and date
canvas.drawText(gym.name, width / 2f, yPosition, subtitlePaint)
yPosition += 60f
val dateText = formatSessionDate(session.date)
canvas.drawText(dateText, width / 2f, yPosition, subtitlePaint)
yPosition += 120f
// Stats grid
val statsStartY = yPosition
val columnWidth = width / 2f
val columnMaxTextWidth = columnWidth - 120f
// Left column stats
var leftY = statsStartY
drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Attempts",
stats.totalAttempts.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
leftY += 120f
drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Problems",
stats.uniqueProblemsAttempted.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
leftY += 120f
drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Duration",
stats.sessionDuration,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
// Right column stats
var rightY = statsStartY
drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rightY,
"Completed",
stats.uniqueProblemsCompleted.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
rightY += 120f
var rightYAfter = rightY
stats.topGrade?.let { grade ->
drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rightY,
"Top Grade",
grade,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
rightYAfter += 120f
}
// Grade range(s)
val boulderRange =
gradeRangeForProblems(
stats.problems.filter { it.climbType == ClimbType.BOULDER }
)
val ropeRange =
gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE })
val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f
if (boulderRange != null && ropeRange != null) {
// Two evenly spaced items
drawStatItemFitting(
canvas,
columnWidth / 2f,
rangesY,
"Boulder Range",
boulderRange,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rangesY,
"Rope Range",
ropeRange,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
} else if (boulderRange != null || ropeRange != null) {
// Single centered item
val singleRange = boulderRange ?: ropeRange ?: ""
drawStatItemFitting(
canvas,
width / 2f,
rangesY,
"Grade Range",
singleRange,
statLabelPaint,
statValuePaint,
width - 200f
)
}
// App branding
val brandingPaint =
Paint().apply {
color = "#80FFFFFF".toColorInt()
textSize = 32f
typeface = Typeface.DEFAULT
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
canvas.drawText("Ascently", width / 2f, height - 40f, brandingPaint)
// Save to file
val shareDir = File(context.cacheDir, "shares")
if (!shareDir.exists()) {
shareDir.mkdirs()
}
val filename = "session_${session.id}_${System.currentTimeMillis()}.png"
val file = File(shareDir, filename)
val outputStream = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
outputStream.flush()
outputStream.close()
bitmap.recycle()
file
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun drawStatItem(
canvas: Canvas,
x: Float,
y: Float,
label: String,
value: String,
labelPaint: Paint,
valuePaint: Paint
) {
canvas.drawText(value, x, y, valuePaint)
canvas.drawText(label, x, y + 50f, labelPaint)
}
/**
* Draws a stat item while fitting the value text to a max width by reducing text size if
* needed.
*/
private fun drawStatItemFitting(
canvas: Canvas,
x: Float,
y: Float,
label: String,
value: String,
labelPaint: Paint,
valuePaint: Paint,
maxTextWidth: Float
) {
val tempPaint = Paint(valuePaint)
var textSize = tempPaint.textSize
var textWidth = tempPaint.measureText(value)
while (textWidth > maxTextWidth && textSize > 36f) {
textSize -= 2f
tempPaint.textSize = textSize
textWidth = tempPaint.measureText(value)
}
canvas.drawText(value, x, y, tempPaint)
canvas.drawText(label, x, y + 50f, labelPaint)
}
/**
* Returns a range string like "X - Y" for the given problems, based on their difficulty grades.
*/
private fun gradeRangeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
val grades = problems.map { it.difficulty }
val sorted = grades.sortedWith { a, b -> a.compareTo(b) }
return "${sorted.first().grade} - ${sorted.last().grade}"
}
private fun formatSessionDate(dateString: String): String {
return try {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val date = LocalDateTime.parse(dateString, formatter)
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
date.format(displayFormatter)
} catch (_: Exception) {
dateString.take(10)
}
}
fun shareSessionCard(context: Context, imageFile: File) {
try {
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
imageFile
)
val shareIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! #Ascently")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(shareIntent, "Share Session")
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* Returns the highest grade string among the given problems, respecting their difficulty
* system.
*/
private fun highestGradeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
return problems
.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }
?.difficulty
?.grade
}
/** Produces a comparable numeric rank for grades across supported systems. */
private fun gradeRank(system: DifficultySystem, grade: String): Double {
return when (system) {
DifficultySystem.V_SCALE -> {
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
}
DifficultySystem.FONT -> {
val list = DifficultySystem.FONT.getAvailableGrades()
val idx = list.indexOf(grade.uppercase())
if (idx >= 0) idx.toDouble()
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
}
DifficultySystem.YDS -> {
// Parse 5.X with optional letter a-d
val s = grade.lowercase()
if (!s.startsWith("5.")) return -1.0
val tail = s.removePrefix("5.")
val numberPart = tail.takeWhile { it.isDigit() || it == '.' }
val letterPart = tail.drop(numberPart.length).firstOrNull()
val base = numberPart.toDoubleOrNull() ?: return -1.0
val letterWeight =
when (letterPart) {
'a' -> 0.0
'b' -> 0.1
'c' -> 0.2
'd' -> 0.3
else -> 0.0
}
base + letterWeight
}
DifficultySystem.CUSTOM -> {
grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() ?: -1.0
}
}
}
}

View File

@@ -22,19 +22,19 @@ object ZipExportImportUtils {
/** Creates a ZIP file containing the JSON data and all referenced images */
fun createExportZip(
context: Context,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>,
directory: File? = null
context: Context,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>,
directory: File? = null
): File {
val exportDir =
directory
?: File(
context.getExternalFilesDir(
android.os.Environment.DIRECTORY_DOCUMENTS
),
"Ascently"
)
directory
?: File(
context.getExternalFilesDir(
android.os.Environment.DIRECTORY_DOCUMENTS
),
"Ascently"
)
if (!exportDir.exists()) {
exportDir.mkdirs()
}
@@ -52,10 +52,11 @@ object ZipExportImportUtils {
zipOut.closeEntry()
// Add JSON data file
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val json =
Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
@@ -78,24 +79,21 @@ object ZipExportImportUtils {
zipOut.closeEntry()
successfulImages++
} else {
android.util.Log.w(
"ZipExportImportUtils",
"Image file not found or empty: $imagePath"
)
AppLogger.w("ZipExportImportUtils") {
"Image file not found or empty: $imagePath"
}
}
} catch (e: Exception) {
android.util.Log.e(
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}"
)
AppLogger.e("ZipExportImportUtils", e) {
"Failed to add image $imagePath: ${e.message}"
}
}
}
// Log export summary
android.util.Log.i(
"ZipExportImportUtils",
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
)
AppLogger.i("ZipExportImportUtils") {
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
}
}
// Validate the created ZIP file
@@ -115,10 +113,10 @@ object ZipExportImportUtils {
/** Creates a ZIP file and writes it to a provided URI */
fun createExportZipToUri(
context: Context,
uri: android.net.Uri,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
context: Context,
uri: android.net.Uri,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
@@ -131,10 +129,11 @@ object ZipExportImportUtils {
zipOut.closeEntry()
// Add JSON data file
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val json =
Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
@@ -158,28 +157,26 @@ object ZipExportImportUtils {
successfulImages++
}
} catch (e: Exception) {
android.util.Log.e(
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}"
)
AppLogger.e("ZipExportImportUtils", e) {
"Failed to add image $imagePath: ${e.message}"
}
}
}
android.util.Log.i(
"ZipExportImportUtils",
"Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included"
)
AppLogger.i("ZipExportImportUtils") {
"Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included"
}
}
}
?: throw IOException("Could not open output stream")
?: throw IOException("Could not open output stream")
} catch (e: Exception) {
throw IOException("Failed to create export ZIP to URI: ${e.message}")
}
}
private fun createMetadata(
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
): String {
return buildString {
appendLine("Ascently Export Metadata")
@@ -197,8 +194,8 @@ object ZipExportImportUtils {
/** Data class to hold extraction results */
data class ImportResult(
val jsonContent: String,
val importedImagePaths: Map<String, String> // original filename -> new relative path
val jsonContent: String,
val importedImagePaths: Map<String, String> // original filename -> new relative path
)
/** Extracts a ZIP file and returns the JSON content and imported image paths */
@@ -217,16 +214,17 @@ object ZipExportImportUtils {
// Read metadata for validation
val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("metadata")
android.util.Log.i(
"ZipExportImportUtils",
"Found metadata: ${metadataContent.lines().take(3).joinToString()}"
)
AppLogger.i("ZipExportImportUtils") {
"Found metadata: ${metadataContent.lines().take(3).joinToString()}"
}
}
entry.name == DATA_JSON_FILENAME -> {
// Read JSON data
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("data")
}
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
// Extract image file
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
@@ -234,11 +232,11 @@ object ZipExportImportUtils {
try {
// Create temporary file to hold the extracted image
val tempFile =
File.createTempFile(
"import_image_",
"_$originalFilename",
context.cacheDir
)
File.createTempFile(
"import_image_",
"_$originalFilename",
context.cacheDir
)
FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) }
@@ -248,37 +246,33 @@ object ZipExportImportUtils {
val newPath = ImageUtils.importImageFile(context, tempFile)
if (newPath != null) {
importedImagePaths[originalFilename] = newPath
android.util.Log.d(
"ZipExportImportUtils",
"Successfully imported image: $originalFilename -> $newPath"
)
AppLogger.d("ZipExportImportUtils") {
"Successfully imported image: $originalFilename -> $newPath"
}
} else {
android.util.Log.w(
"ZipExportImportUtils",
"Failed to import image: $originalFilename"
)
AppLogger.w("ZipExportImportUtils") {
"Failed to import image: $originalFilename"
}
}
} else {
android.util.Log.w(
"ZipExportImportUtils",
"Extracted image is empty: $originalFilename"
)
AppLogger.w("ZipExportImportUtils") {
"Extracted image is empty: $originalFilename"
}
}
// Clean up temp file
tempFile.delete()
} catch (e: Exception) {
android.util.Log.e(
"ZipExportImportUtils",
"Failed to process image $originalFilename: ${e.message}"
)
AppLogger.e("ZipExportImportUtils", e) {
"Failed to process image $originalFilename: ${e.message}"
}
}
}
else -> {
android.util.Log.d(
"ZipExportImportUtils",
"Skipping ZIP entry: ${entry.name}"
)
AppLogger.d("ZipExportImportUtils") {
"Skipping ZIP entry: ${entry.name}"
}
}
}
@@ -296,10 +290,9 @@ object ZipExportImportUtils {
throw IOException("Invalid ZIP file: data.json is empty")
}
android.util.Log.i(
"ZipExportImportUtils",
"Import extraction completed: ${importedImagePaths.size} images processed"
)
AppLogger.i("ZipExportImportUtils") {
"Import extraction completed: ${importedImagePaths.size} images processed"
}
return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) {
@@ -312,16 +305,16 @@ object ZipExportImportUtils {
* the new ones after import
*/
fun updateProblemImagePaths(
problems: List<BackupProblem>,
imagePathMapping: Map<String, String>
problems: List<BackupProblem>,
imagePathMapping: Map<String, String>
): List<BackupProblem> {
return problems.map { problem ->
val updatedImagePaths =
(problem.imagePaths ?: emptyList()).mapNotNull { oldPath ->
// Extract filename from the old path
val filename = oldPath.substringAfterLast("/")
imagePathMapping[filename]
}
(problem.imagePaths ?: emptyList()).mapNotNull { oldPath ->
// Extract filename from the old path
val filename = oldPath.substringAfterLast("/")
imagePathMapping[filename]
}
problem.withUpdatedImagePaths(updatedImagePaths)
}
}

View File

@@ -11,6 +11,7 @@ import com.atridad.ascently.MainActivity
import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository
import java.time.LocalDate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -48,54 +49,47 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
val database = AscentlyDatabase.getDatabase(context)
val repository = ClimbRepository(database, context)
// Fetch stats data
// Get last 7 days date range (rolling period)
val today = LocalDate.now()
val sevenDaysAgo = today.minusDays(6) // Today + 6 days ago = 7 days total
// Fetch all sessions and attempts
val sessions = repository.getAllSessions().first()
val problems = repository.getAllProblems().first()
val attempts = repository.getAllAttempts().first()
val gyms = repository.getAllGyms().first()
val activeSession = repository.getActiveSession()
// Calculate stats
val completedSessions = sessions.filter { it.endTime != null }
// Count problems that have been completed (have at least one successful attempt)
val completedProblems =
problems
.filter { problem ->
attempts.any { attempt ->
attempt.problemId == problem.id &&
(attempt.result ==
com.atridad.ascently.data.model
.AttemptResult.SUCCESS ||
attempt.result ==
com.atridad.ascently.data.model
.AttemptResult.FLASH)
}
}
.size
val favoriteGym =
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
(gymId, _) ->
gyms.find { it.id == gymId }?.name
// Filter for last 7 days across all gyms
val weekSessions =
sessions.filter { session ->
try {
val sessionDate = LocalDate.parse(session.date.substring(0, 10))
!sessionDate.isBefore(sevenDaysAgo) && !sessionDate.isAfter(today)
} catch (_: Exception) {
false
}
}
?: "No sessions yet"
val weekSessionIds = weekSessions.map { it.id }.toSet()
// Count total attempts this week
val totalAttempts =
attempts.count { attempt -> weekSessionIds.contains(attempt.sessionId) }
// Count sessions this week
val totalSessions = weekSessions.size
launch(Dispatchers.Main) {
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
views.setTextViewText(
R.id.widget_total_sessions,
completedSessions.size.toString()
)
views.setTextViewText(
R.id.widget_problems_completed,
completedProblems.toString()
)
views.setTextViewText(R.id.widget_total_problems, problems.size.toString())
views.setTextViewText(R.id.widget_favorite_gym, favoriteGym)
// Set weekly stats
views.setTextViewText(R.id.widget_attempts_value, totalAttempts.toString())
views.setTextViewText(R.id.widget_sessions_value, totalSessions.toString())
val intent = Intent(context, MainActivity::class.java)
val intent =
Intent(context, MainActivity::class.java).apply {
flags =
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent =
PendingIntent.getActivity(
context,
@@ -108,13 +102,11 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
appWidgetManager.updateAppWidget(appWidgetId, views)
}
} catch (e: Exception) {
} catch (_: Exception) {
launch(Dispatchers.Main) {
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
views.setTextViewText(R.id.widget_total_sessions, "0")
views.setTextViewText(R.id.widget_problems_completed, "0")
views.setTextViewText(R.id.widget_total_problems, "0")
views.setTextViewText(R.id.widget_favorite_gym, "No data")
views.setTextViewText(R.id.widget_attempts_value, "0")
views.setTextViewText(R.id.widget_sessions_value, "0")
val intent = Intent(context, MainActivity::class.java)
val pendingIntent =

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2ZM10,17L5,12L6.41,10.59L10,14.17L17.59,6.58L19,8L10,17Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M9,11.24V7.5C9,6.12 10.12,5 11.5,5S14,6.12 14,7.5v3.74c1.21,-0.81 2,-2.18 2,-3.74C16,5.01 13.99,3 11.5,3S7,5.01 7,7.5C7,9.06 7.79,10.43 9,11.24zM18.84,15.87l-4.54,-2.26c-0.17,-0.07 -0.35,-0.11 -0.54,-0.11H13v-6C13,6.67 12.33,6 11.5,6S10,6.67 10,7.5v10.74l-3.43,-0.72c-0.08,-0.01 -0.15,-0.03 -0.24,-0.03c-0.31,0 -0.59,0.13 -0.79,0.33l-0.79,0.8l4.94,4.94C9.96,23.83 10.34,24 10.75,24h6.79c0.75,0 1.33,-0.55 1.44,-1.28l0.75,-5.27c0.01,-0.07 0.02,-0.14 0.02,-0.2C19.75,16.63 19.37,16.09 18.84,15.87z"/>
</vector>

View File

@@ -4,27 +4,6 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="0.7"
android:scaleY="0.7"
android:translateX="16.2"
android:translateY="20">
<!-- Left mountain (yellow/amber) -->
<path
android:fillColor="#FFC107"
android:strokeColor="#1C1C1C"
android:strokeWidth="3"
android:strokeLineJoin="round"
android:pathData="M15,70 L35,25 L55,70 Z" />
<!-- Right mountain (red) -->
<path
android:fillColor="#F44336"
android:strokeColor="#1C1C1C"
android:strokeWidth="3"
android:strokeLineJoin="round"
android:pathData="M40,70 L65,15 L90,70 Z" />
</group>
<path android:fillColor="#FFC107" android:pathData="M24.000,78.545 L41.851,38.380 L59.702,78.545 Z" />
<path android:fillColor="#F44336" android:pathData="M39.372,78.545 L61.686,29.455 L84.000,78.545 Z" />
</vector>

View File

@@ -4,29 +4,6 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Left mountain (yellow/amber) -->
<path
android:fillColor="#FFC107"
android:pathData="M3,18 L8,9 L13,18 Z" />
<!-- Right mountain (red) -->
<path
android:fillColor="#F44336"
android:pathData="M11,18 L16,7 L21,18 Z" />
<!-- Black outlines -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#1C1C1C"
android:strokeWidth="1"
android:strokeLineJoin="round"
android:pathData="M3,18 L8,9 L13,18" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#1C1C1C"
android:strokeWidth="1"
android:strokeLineJoin="round"
android:pathData="M11,18 L16,7 L21,18" />
</vector>
<path android:fillColor="#FFC107" android:pathData="M2.000,20.182 L7.950,6.793 L13.901,20.182 Z" />
<path android:fillColor="#F44336" android:pathData="M7.124,20.182 L14.562,3.818 L22.000,20.182 Z" />
</vector>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
<path
android:fillColor="#FFC107"
android:pathData="M24,74 L42,34 L60,74 Z" />
<path
android:fillColor="#F44336"
android:pathData="M41,74 L59,24 L84,74 Z" />
</vector>

View File

@@ -5,190 +5,84 @@
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:padding="12dp">
android:padding="12dp"
android:gravity="center">
<!-- Header -->
<!-- Header with icon and "Weekly" text -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_width="28dp"
android:layout_height="28dp"
android:src="@drawable/ic_mountains"
android:tint="@color/widget_primary"
android:layout_marginEnd="8dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Ascently"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary" />
android:layout_marginEnd="8dp"
android:contentDescription="@string/ascently_icon" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Climbing Stats"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary" />
android:text="@string/weekly"
android:textSize="18sp"
android:textColor="@color/widget_text_primary" />
</LinearLayout>
<!-- Stats Grid -->
<!-- Attempts Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<!-- Top Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_circle_filled"
android:tint="@color/widget_primary"
android:layout_marginEnd="12dp"
android:contentDescription="Attempts icon" />
<!-- Sessions Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginEnd="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_attempts_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="40sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary" />
<TextView
android:id="@+id/widget_total_sessions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_primary" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sessions"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
<!-- Sessions Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
</LinearLayout>
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_play_arrow_24"
android:tint="@color/widget_primary"
android:layout_marginEnd="12dp"
android:contentDescription="@string/sessions_icon" />
<!-- Problems Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginStart="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_problems_completed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Completed"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
<!-- Bottom Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<!-- Success Rate Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginEnd="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_total_problems"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_secondary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Problems"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
<!-- Favorite Gym Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginStart="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_favorite_gym"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="No gyms"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="@color/widget_accent"
android:gravity="center"
android:maxLines="2"
android:ellipsize="end" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Favorite"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/widget_sessions_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/_0"
android:textSize="40sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary" />
</LinearLayout>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 854 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -12,5 +12,9 @@
<string name="shortcut_end_session_disabled">No active session to end</string>
<!-- Widget -->
<string name="widget_description">View your climbing stats at a glance</string>
<string name="widget_description">View your weekly climbing stats</string>
<string name="ascently_icon">Ascently icon</string>
<string name="weekly">Weekly</string>
<string name="sessions_icon">Sessions icon</string>
<string name="_0">0</string>
</resources>

View File

@@ -4,7 +4,7 @@
<style name="Theme.Ascently.Splash" parent="Theme.Ascently">
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
<item name="android:windowSplashScreenAnimationDuration">200</item>
</style>
</resources>

View File

@@ -5,10 +5,6 @@
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>

View File

@@ -3,15 +3,14 @@
android:description="@string/widget_description"
android:initialKeyguardLayout="@layout/widget_climb_stats"
android:initialLayout="@layout/widget_climb_stats"
android:minWidth="250dp"
android:minHeight="180dp"
android:minWidth="110dp"
android:minHeight="110dp"
android:maxResizeWidth="110dp"
android:maxResizeHeight="110dp"
android:previewImage="@drawable/ic_mountains"
android:previewLayout="@layout/widget_climb_stats"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:resizeMode="none"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable"
android:maxResizeWidth="320dp"
android:maxResizeHeight="240dp" />
android:widgetCategory="home_screen" />

View File

@@ -18,8 +18,8 @@ class DataModelTests {
@Test
fun testClimbTypeDisplayNames() {
assertEquals("Rope", ClimbType.ROPE.getDisplayName())
assertEquals("Bouldering", ClimbType.BOULDER.getDisplayName())
assertEquals("Rope", ClimbType.ROPE.displayName)
assertEquals("Bouldering", ClimbType.BOULDER.displayName)
}
@Test
@@ -34,58 +34,58 @@ class DataModelTests {
@Test
fun testDifficultySystemDisplayNames() {
assertEquals("V Scale", DifficultySystem.V_SCALE.getDisplayName())
assertEquals("YDS (Yosemite)", DifficultySystem.YDS.getDisplayName())
assertEquals("Font Scale", DifficultySystem.FONT.getDisplayName())
assertEquals("Custom", DifficultySystem.CUSTOM.getDisplayName())
assertEquals("V Scale", DifficultySystem.V_SCALE.displayName)
assertEquals("YDS (Yosemite)", DifficultySystem.YDS.displayName)
assertEquals("Font Scale", DifficultySystem.FONT.displayName)
assertEquals("Custom", DifficultySystem.CUSTOM.displayName)
}
@Test
fun testDifficultySystemClimbTypeCompatibility() {
// Test bouldering systems
assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem())
assertTrue(DifficultySystem.FONT.isBoulderingSystem())
assertFalse(DifficultySystem.YDS.isBoulderingSystem())
assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem())
assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem)
assertTrue(DifficultySystem.FONT.isBoulderingSystem)
assertFalse(DifficultySystem.YDS.isBoulderingSystem)
assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem)
// Test rope systems
assertTrue(DifficultySystem.YDS.isRopeSystem())
assertFalse(DifficultySystem.V_SCALE.isRopeSystem())
assertFalse(DifficultySystem.FONT.isRopeSystem())
assertTrue(DifficultySystem.CUSTOM.isRopeSystem())
assertTrue(DifficultySystem.YDS.isRopeSystem)
assertFalse(DifficultySystem.V_SCALE.isRopeSystem)
assertFalse(DifficultySystem.FONT.isRopeSystem)
assertTrue(DifficultySystem.CUSTOM.isRopeSystem)
}
@Test
fun testDifficultySystemAvailableGrades() {
val vScaleGrades = DifficultySystem.V_SCALE.getAvailableGrades()
val vScaleGrades = DifficultySystem.V_SCALE.availableGrades
assertTrue(vScaleGrades.contains("VB"))
assertTrue(vScaleGrades.contains("V0"))
assertTrue(vScaleGrades.contains("V17"))
assertEquals("VB", vScaleGrades.first())
val ydsGrades = DifficultySystem.YDS.getAvailableGrades()
val ydsGrades = DifficultySystem.YDS.availableGrades
assertTrue(ydsGrades.contains("5.0"))
assertTrue(ydsGrades.contains("5.15d"))
assertTrue(ydsGrades.contains("5.10a"))
val fontGrades = DifficultySystem.FONT.getAvailableGrades()
val fontGrades = DifficultySystem.FONT.availableGrades
assertTrue(fontGrades.contains("3"))
assertTrue(fontGrades.contains("8C+"))
assertTrue(fontGrades.contains("6A"))
val customGrades = DifficultySystem.CUSTOM.getAvailableGrades()
val customGrades = DifficultySystem.CUSTOM.availableGrades
assertTrue(customGrades.isEmpty())
}
@Test
fun testDifficultySystemsForClimbType() {
val boulderSystems = DifficultySystem.getSystemsForClimbType(ClimbType.BOULDER)
val boulderSystems = DifficultySystem.systemsForClimbType(ClimbType.BOULDER)
assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE))
assertTrue(boulderSystems.contains(DifficultySystem.FONT))
assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM))
assertFalse(boulderSystems.contains(DifficultySystem.YDS))
val ropeSystems = DifficultySystem.getSystemsForClimbType(ClimbType.ROPE)
val ropeSystems = DifficultySystem.systemsForClimbType(ClimbType.ROPE)
assertTrue(ropeSystems.contains(DifficultySystem.YDS))
assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM))
assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE))
@@ -387,7 +387,7 @@ class DataModelTests {
val currentTime = System.currentTimeMillis()
assertTrue(currentTime > 0)
val timeString = java.time.Instant.ofEpochMilli(currentTime).toString()
val timeString = Instant.ofEpochMilli(currentTime).toString()
assertTrue(timeString.isNotEmpty())
assertTrue(timeString.contains("T"))
assertTrue(timeString.endsWith("Z"))

View File

@@ -457,10 +457,6 @@ class SyncMergeLogicTest {
@Test
fun `test active sessions excluded from sync`() {
// Test scenario: Active sessions should not be included in sync data
// This tests the new behavior where active sessions are excluded from sync
// until they are completed
val allLocalSessions =
listOf(
BackupClimbSession(

View File

@@ -1,6 +1,6 @@
[versions]
agp = "8.12.3"
kotlin = "2.2.20"
kotlin = "2.2.21"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
@@ -9,17 +9,17 @@ androidxTestCore = "1.7.0"
androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2025.10.00"
room = "2.8.2"
navigation = "2.9.5"
viewmodel = "2.9.4"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.0"
composeBom = "2025.11.01"
room = "2.8.4"
navigation = "2.9.6"
viewmodel = "2.10.0"
kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2"
coil = "2.7.0"
ksp = "2.2.20-2.0.3"
exifinterface = "1.3.6"
exifinterface = "1.4.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }

3
branding/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.tmp
.DS_Store
*.log

394
branding/generate.py Executable file
View File

@@ -0,0 +1,394 @@
#!/usr/bin/env python3
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Callable, TypedDict
from PIL import Image, ImageDraw
class Polygon(TypedDict):
coords: list[tuple[float, float]]
fill: str
SCRIPT_DIR = Path(__file__).parent
PROJECT_ROOT = SCRIPT_DIR.parent
SOURCE_DIR = SCRIPT_DIR / "source"
LOGOS_DIR = SCRIPT_DIR / "logos"
def parse_svg_polygons(svg_path: Path) -> list[Polygon]:
tree = ET.parse(svg_path)
root = tree.getroot()
ns = {"svg": "http://www.w3.org/2000/svg"}
polygons = root.findall(".//svg:polygon", ns)
if not polygons:
polygons = root.findall(".//polygon")
result: list[Polygon] = []
for poly in polygons:
points_str = poly.get("points", "").strip()
fill = poly.get("fill", "#000000")
coords: list[tuple[float, float]] = []
for pair in points_str.split():
x, y = pair.split(",")
coords.append((float(x), float(y)))
result.append({"coords": coords, "fill": fill})
return result
def get_bbox(polygons: list[Polygon]) -> dict[str, float]:
all_coords: list[tuple[float, float]] = []
for poly in polygons:
all_coords.extend(poly["coords"])
xs = [c[0] for c in all_coords]
ys = [c[1] for c in all_coords]
return {
"min_x": min(xs),
"max_x": max(xs),
"min_y": min(ys),
"max_y": max(ys),
"width": max(xs) - min(xs),
"height": max(ys) - min(ys),
}
def scale_and_center(
polygons: list[Polygon], viewbox_size: float, target_width: float
) -> list[Polygon]:
bbox = get_bbox(polygons)
scale = target_width / bbox["width"]
center = viewbox_size / 2
scaled_polys: list[Polygon] = []
for poly in polygons:
scaled_coords = [(x * scale, y * scale) for x, y in poly["coords"]]
scaled_polys.append({"coords": scaled_coords, "fill": poly["fill"]})
scaled_bbox = get_bbox(scaled_polys)
current_center_x = (scaled_bbox["min_x"] + scaled_bbox["max_x"]) / 2
current_center_y = (scaled_bbox["min_y"] + scaled_bbox["max_y"]) / 2
offset_x = center - current_center_x
offset_y = center - current_center_y
final_polys: list[Polygon] = []
for poly in scaled_polys:
final_coords = [(x + offset_x, y + offset_y) for x, y in poly["coords"]]
final_polys.append({"coords": final_coords, "fill": poly["fill"]})
return final_polys
def format_svg_points(coords: list[tuple[float, float]]) -> str:
return " ".join(f"{x:.3f},{y:.3f}" for x, y in coords)
def format_android_path(coords: list[tuple[float, float]]) -> str:
points = " ".join(f"{x:.3f},{y:.3f}" for x, y in coords)
pairs = points.split()
return f"M{pairs[0]} L{pairs[1]} L{pairs[2]} Z"
def generate_svg(polygons: list[Polygon], width: int, height: int) -> str:
lines = [
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">'
]
for poly in polygons:
points = format_svg_points(poly["coords"])
lines.append(f' <polygon points="{points}" fill="{poly["fill"]}"/>')
lines.append("</svg>")
return "\n".join(lines)
def generate_android_vector(
polygons: list[Polygon], width: int, height: int, viewbox: int
) -> str:
lines = [
'<?xml version="1.0" encoding="utf-8"?>',
'<vector xmlns:android="http://schemas.android.com/apk/res/android"',
f' android:width="{width}dp"',
f' android:height="{height}dp"',
f' android:viewportWidth="{viewbox}"',
f' android:viewportHeight="{viewbox}">',
]
for poly in polygons:
path = format_android_path(poly["coords"])
lines.append(
f' <path android:fillColor="{poly["fill"]}" android:pathData="{path}" />'
)
lines.append("</vector>")
return "\n".join(lines)
def rasterize_svg(
svg_path: Path,
output_path: Path,
size: int,
bg_color: tuple[int, int, int, int] | None = None,
circular: bool = False,
) -> None:
from xml.dom import minidom
doc = minidom.parse(str(svg_path))
img = Image.new(
"RGBA", (size, size), (255, 255, 255, 0) if bg_color is None else bg_color
)
draw = ImageDraw.Draw(img)
svg_elem = doc.getElementsByTagName("svg")[0]
viewbox = svg_elem.getAttribute("viewBox").split()
if viewbox:
vb_width = float(viewbox[2])
vb_height = float(viewbox[3])
scale_x = size / vb_width
scale_y = size / vb_height
else:
scale_x = scale_y = 1
def parse_transform(
transform_str: str,
) -> Callable[[float, float], tuple[float, float]]:
import re
if not transform_str:
return lambda x, y: (x, y)
transforms: list[tuple[str, list[float]]] = []
for match in re.finditer(r"(\w+)\(([^)]+)\)", transform_str):
func, args_str = match.groups()
args = [float(x) for x in args_str.replace(",", " ").split()]
transforms.append((func, args))
def apply_transforms(x: float, y: float) -> tuple[float, float]:
for func, args in transforms:
if func == "translate":
x += args[0]
y += args[1] if len(args) > 1 else args[0]
elif func == "scale":
x *= args[0]
y *= args[1] if len(args) > 1 else args[0]
return x, y
return apply_transforms
for g in doc.getElementsByTagName("g"):
transform = parse_transform(g.getAttribute("transform"))
for poly in g.getElementsByTagName("polygon"):
points_str = poly.getAttribute("points").strip()
fill = poly.getAttribute("fill")
if not fill:
fill = "#000000"
coords: list[tuple[float, float]] = []
for pair in points_str.split():
x, y = pair.split(",")
x, y = float(x), float(y)
x, y = transform(x, y)
coords.append((x * scale_x, y * scale_y))
draw.polygon(coords, fill=fill)
for poly in doc.getElementsByTagName("polygon"):
if poly.parentNode and getattr(poly.parentNode, "tagName", None) == "g":
continue
points_str = poly.getAttribute("points").strip()
fill = poly.getAttribute("fill")
if not fill:
fill = "#000000"
coords = []
for pair in points_str.split():
x, y = pair.split(",")
coords.append((float(x) * scale_x, float(y) * scale_y))
draw.polygon(coords, fill=fill)
if circular:
mask = Image.new("L", (size, size), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse((0, 0, size, size), fill=255)
img.putalpha(mask)
img.save(output_path)
def main() -> None:
print("Generating branding assets...")
logo_svg = SOURCE_DIR / "logo.svg"
icon_light = SOURCE_DIR / "icon-light.svg"
icon_dark = SOURCE_DIR / "icon-dark.svg"
icon_tinted = SOURCE_DIR / "icon-tinted.svg"
polygons = parse_svg_polygons(logo_svg)
print(" iOS...")
ios_assets = PROJECT_ROOT / "ios/Ascently/Assets.xcassets/AppIcon.appiconset"
for src, dst in [
(icon_light, ios_assets / "app_icon_light_template.svg"),
(icon_dark, ios_assets / "app_icon_dark_template.svg"),
(icon_tinted, ios_assets / "app_icon_tinted_template.svg"),
]:
with open(src) as f:
content = f.read()
with open(dst, "w") as f:
f.write(content)
img_light = Image.new("RGB", (1024, 1024), (255, 255, 255))
draw_light = ImageDraw.Draw(img_light)
scaled = scale_and_center(polygons, 1024, int(1024 * 0.7))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw_light.polygon(coords, fill=poly["fill"])
img_light.save(ios_assets / "app_icon_1024.png")
img_dark = Image.new("RGB", (1024, 1024), (26, 26, 26))
draw_dark = ImageDraw.Draw(img_dark)
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw_dark.polygon(coords, fill=poly["fill"])
img_dark.save(ios_assets / "app_icon_1024_dark.png")
img_tinted = Image.new("RGB", (1024, 1024), (0, 0, 0))
draw_tinted = ImageDraw.Draw(img_tinted)
for i, poly in enumerate(scaled):
coords = [(x, y) for x, y in poly["coords"]]
draw_tinted.polygon(coords, fill=(0, 0, 0))
img_tinted.save(ios_assets / "app_icon_1024_tinted.png")
print(" Android...")
polys_108 = scale_and_center(polygons, 108, 60)
android_xml = generate_android_vector(polys_108, 108, 108, 108)
(
PROJECT_ROOT / "android/app/src/main/res/drawable/ic_launcher_foreground.xml"
).write_text(android_xml)
polys_24 = scale_and_center(polygons, 24, 20)
mountains_xml = generate_android_vector(polys_24, 24, 24, 24)
(PROJECT_ROOT / "android/app/src/main/res/drawable/ic_mountains.xml").write_text(
mountains_xml
)
for density, size in [
("mdpi", 48),
("hdpi", 72),
("xhdpi", 96),
("xxhdpi", 144),
("xxxhdpi", 192),
]:
mipmap_dir = PROJECT_ROOT / f"android/app/src/main/res/mipmap-{density}"
img = Image.new("RGBA", (size, size), (255, 255, 255, 255))
draw = ImageDraw.Draw(img)
scaled = scale_and_center(polygons, size, int(size * 0.6))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw.polygon(coords, fill=poly["fill"])
img.save(mipmap_dir / "ic_launcher.webp")
img_round = Image.new("RGBA", (size, size), (255, 255, 255, 255))
draw_round = ImageDraw.Draw(img_round)
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw_round.polygon(coords, fill=poly["fill"])
mask = Image.new("L", (size, size), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse((0, 0, size, size), fill=255)
img_round.putalpha(mask)
img_round.save(mipmap_dir / "ic_launcher_round.webp")
print(" Docs...")
polys_32 = scale_and_center(polygons, 32, 26)
logo_svg_32 = generate_svg(polys_32, 32, 32)
(PROJECT_ROOT / "docs/src/assets/logo.svg").write_text(logo_svg_32)
(PROJECT_ROOT / "docs/src/assets/logo-dark.svg").write_text(logo_svg_32)
polys_256 = scale_and_center(polygons, 256, 208)
logo_svg_256 = generate_svg(polys_256, 256, 256)
(PROJECT_ROOT / "docs/src/assets/logo-highres.svg").write_text(logo_svg_256)
logo_32_path = PROJECT_ROOT / "docs/src/assets/logo.svg"
rasterize_svg(logo_32_path, PROJECT_ROOT / "docs/public/favicon.png", 32)
sizes = [16, 32, 48]
imgs = []
for size in sizes:
img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)
scaled = scale_and_center(polygons, size, int(size * 0.8))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw.polygon(coords, fill=poly["fill"])
imgs.append(img)
imgs[0].save(
PROJECT_ROOT / "docs/public/favicon.ico",
format="ICO",
sizes=[(s, s) for s in sizes],
append_images=imgs[1:],
)
print(" Logos...")
LOGOS_DIR.mkdir(exist_ok=True)
sizes = [64, 128, 256, 512, 1024, 2048]
for size in sizes:
img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)
scaled = scale_and_center(polygons, size, int(size * 0.8))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw.polygon(coords, fill=poly["fill"])
img.save(LOGOS_DIR / f"logo-{size}.png")
for size in sizes:
img = Image.new("RGBA", (size, size), (255, 255, 255, 255))
draw = ImageDraw.Draw(img)
scaled = scale_and_center(polygons, size, int(size * 0.8))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw.polygon(coords, fill=poly["fill"])
img.save(LOGOS_DIR / f"logo-{size}-white.png")
for size in sizes:
img = Image.new("RGBA", (size, size), (26, 26, 26, 255))
draw = ImageDraw.Draw(img)
scaled = scale_and_center(polygons, size, int(size * 0.8))
for poly in scaled:
coords = [(x, y) for x, y in poly["coords"]]
draw.polygon(coords, fill=poly["fill"])
img.save(LOGOS_DIR / f"logo-{size}-dark.png")
print("Done.")
if __name__ == "__main__":
main()

12
branding/generate.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if ! command -v python3 &> /dev/null; then
echo "Error: Python 3 required"
exit 1
fi
python3 "$SCRIPT_DIR/generate.py"

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

BIN
branding/logos/logo-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
branding/logos/logo-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
branding/logos/logo-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

BIN
branding/logos/logo-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<polygon points="8,75 35,14.25 62,75" fill="#000000" opacity="0.8"/>
<polygon points="31.25,75 65,0.75 98.75,75" fill="#000000" opacity="0.9"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 443 B

5
branding/source/logo.svg Normal file
View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="108" height="108" viewBox="0 0 108 108" xmlns="http://www.w3.org/2000/svg">
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
</svg>

After

Width:  |  Height:  |  Size: 254 B

79
docs/.dockerignore Normal file
View File

@@ -0,0 +1,79 @@
# Node modules
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Build output
dist
.astro
# IDE files
.vscode
.idea
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Documentation
README.md
*.md
# Cache directories
.cache
.parcel-cache
# Logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Temporary folders
tmp/
temp/

21
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

80
docs/Dockerfile Normal file
View File

@@ -0,0 +1,80 @@
FROM node:24-alpine AS base
# Install system dependencies
RUN apk add --no-cache \
python3 \
make \
g++ \
libc6-compat \
vips-dev \
curl
# Install pnpm globally
RUN npm install -g pnpm
# Configure pnpm
RUN pnpm config set store-dir /.pnpm-store
RUN pnpm config set network-timeout 300000
RUN pnpm config set fetch-retries 10
RUN pnpm config set fetch-retry-factor 2
FROM base AS deps
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install dependencies with retry logic
RUN --mount=type=cache,id=pnpm,target=/.pnpm-store \
pnpm install --frozen-lockfile || \
(sleep 5 && pnpm install --frozen-lockfile) || \
(sleep 10 && pnpm install --frozen-lockfile --no-frozen-lockfile)
FROM base AS build-deps
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install all dependencies including dev dependencies
RUN --mount=type=cache,id=pnpm,target=/.pnpm-store \
pnpm install --frozen-lockfile || \
(sleep 5 && pnpm install --frozen-lockfile) || \
(sleep 10 && pnpm install --frozen-lockfile --no-frozen-lockfile)
FROM build-deps AS builder
# Copy source code
COPY . .
# Build the application
RUN pnpm run build
FROM node:20-alpine AS runtime
# Install pnpm for runtime
RUN npm install -g pnpm
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
# Copy production dependencies
COPY --from=deps /app/node_modules ./node_modules
# Copy package.json for any runtime needs
COPY package.json ./
# Set environment variables
ENV HOST=0.0.0.0 \
PORT=4321 \
NODE_ENV=production
# Expose port
EXPOSE 4321
# Start the application
CMD ["node", "./dist/server/entry.mjs"]

12
docs/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Ascently Documentation
Documentation site for Ascently.
This was built with [Astro Starlight](https://starlight.astro.build/).
### Setup
```bash
cd Ascently/docs
pnpm install
pnpm run dev
```

58
docs/astro.config.mjs Normal file
View File

@@ -0,0 +1,58 @@
// @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: "API Reference", slug: "sync/api-reference" },
],
},
{
label: "Privacy",
link: "/privacy/",
},
],
customCss: ["./src/styles/custom.css"],
}),
],
adapter: node({
mode: "standalone",
}),
});

8
docs/docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
app:
image: ${IMAGE}
ports:
- "${APP_PORT}:4321"
environment:
NODE_ENV: production
restart: unless-stopped

37
docs/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "ascently-docs",
"type": "module",
"version": "1.1.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.1",
"@astrojs/starlight": "^0.37.0",
"astro": "^5.16.3",
"qrcode": "^1.5.4",
"sharp": "^0.34.5"
},
"devDependencies": {
"@types/qrcode": "^1.5.6"
}
}

4478
docs/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
docs/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

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