Compare commits

...

93 Commits

Author SHA1 Message Date
b8f874a433 Cleanup 2025-10-05 12:42:02 -06:00
4bbd422c09 Added a proper set of Unit Tests for each sub-project
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m28s
2025-10-03 20:55:04 -06:00
4e42985135 1.2.2 - "Bug fixes and improvements" 2025-10-01 21:34:22 -06:00
ba1a7117d9 Create PRIVACY.md 2025-09-29 14:11:01 -06:00
1980ff802a Merge remote-tracking branch 'origin/main' 2025-09-29 14:03:51 -06:00
73c4e41cac Android 1.7.1 - Added a clear sync indicator 2025-09-29 14:03:30 -06:00
3ccd0ec7ea oops 2025-09-29 13:47:57 -06:00
8fbb40d453 iOS 1.2.1 - Better auto sync and sync indicator 2025-09-29 13:47:50 -06:00
016d427ff8 Update sync/.env.example
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m22s
2025-09-29 17:49:25 +00:00
7cbb333287 Update sync/docker-compose.yml
Some checks failed
OpenClimb Docker Deploy / build-and-push (push) Has been cancelled
2025-09-29 17:47:09 +00:00
2160ce30bd Update README.md 2025-09-29 05:46:47 +00:00
de21e3270e Update README.md 2025-09-29 05:46:23 +00:00
f6e1cdcb5b Update README.md 2025-09-28 23:45:38 -06:00
fab587c351 Cleanup from dev
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m22s
2025-09-28 23:43:19 -06:00
d570b8a70c Merge pull request 'Massive Sync Update!' (#5) from sync-server into main
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 1m26s
Reviewed-on: atridad/OpenClimb#5
2025-09-29 05:35:12 +00:00
c676e25a3d Sync server name change
Some checks failed
OpenClimb Docker Deploy / build-and-push (pull_request) Has been cancelled
2025-09-28 23:34:37 -06:00
56c501cef6 Version bumps 2025-09-28 23:33:05 -06:00
f7f1fba9aa Added proper CI
All checks were successful
OpenClimb Docker Deploy / build-and-push (pull_request) Successful in 2m3s
2025-09-28 23:29:44 -06:00
6e490d1598 Sync Server DONE! 2025-09-28 23:12:46 -06:00
036becb5be 1.6.0 for Android and 1.1.0 for iOS - Finalizing Export and Import
formats :)
2025-09-28 02:37:03 -06:00
dcc3f9cc9d Fixed versionCode for android 2025-09-27 20:00:30 -06:00
2a48908dd2 New builds for iOS 1.0.3 and Android 1.5.1 2025-09-27 18:47:23 -06:00
5d1748765f New builds for iOS 1.0.3 and Android 1.5.1 2025-09-27 18:47:09 -06:00
298ba6149b 1.03 for iOS and 1.5.0 for Android 2025-09-27 02:13:51 -06:00
416b68e96a [Android] 1.5.0 2025-09-24 17:15:53 -06:00
f68963afbc oops 2025-09-23 22:20:11 -06:00
f1bc61d202 1.0.2 - Widget and Photos fixes 2025-09-23 21:57:45 -06:00
57b16c89ad 1.0.1 (6) 2025-09-20 15:08:59 -06:00
44b9b7bb9e Release README 2025-09-20 14:33:16 -06:00
7839d52001 ??? 2025-09-20 14:32:12 -06:00
fff8123978 1.0.1 (5) 2025-09-20 14:32:04 -06:00
6172074509 ??? 2025-09-20 12:03:46 -06:00
0235b5d506 iOS Release - 1.0.1 2025-09-20 12:03:37 -06:00
7c18b56674 ??? 2025-09-16 09:57:25 -06:00
cccdc2dd66 Build 3 - Remove debug settings from dev 2025-09-16 09:57:04 -06:00
62703cf2eb ??? 2025-09-16 00:36:43 -06:00
2c0ae23417 Remove iPad compat 2025-09-16 00:36:36 -06:00
87dcd08189 ??? 2025-09-15 23:34:42 -06:00
f3dabbd3aa Fixed swipe actions and more widgets 2025-09-15 23:34:33 -06:00
e4c6440758 Fixed Widget stats 2025-09-15 23:27:21 -06:00
b478f05260 ??? 2025-09-15 21:01:18 -06:00
afd954785a Proper 1.0 release for iOS. Pending App Store submission. 2025-09-15 21:01:02 -06:00
d95c45abbb Update README.md 2025-09-15 05:11:20 +00:00
9df0b29ada Merge pull request 'monorepo' (#4) from monorepo into main
Reviewed-on: atridad/OpenClimb#4
2025-09-15 05:09:46 +00:00
ff9f0d6cc6 1.0.0 for iOS is ready to ship 2025-09-14 23:07:32 -06:00
61384623bd Import/export fixes, icon, and graphing 2025-09-13 00:42:15 -06:00
7da1893748 1.5.0 Initial run as iOS in a monorepo 2025-09-12 22:35:14 -06:00
f106244e57 oooops 2025-09-09 12:59:59 -06:00
76a9120184 oops 2025-09-09 12:58:26 -06:00
abeed46c90 1.4.2 - Dropped minSDK down to support Android 12 2025-09-09 12:57:02 -06:00
7770997fd4 1.4.1 - Shortcuts Bug Fix 2025-09-08 00:49:00 -06:00
f45ff8963d Merge remote-tracking branch 'origin/main' 2025-09-06 23:19:36 -06:00
5988cbf1fb 1.4.0 - Shortcuts & Widgets 2025-09-06 23:19:26 -06:00
13654cde70 Update README.md 2025-09-01 07:14:52 +00:00
9064dbe2ef Update README.md 2025-09-01 07:14:40 +00:00
0537da79e4 1.3.1 - Graphing Fixes Cont'd 2025-08-31 19:05:18 -06:00
4804049274 1.3.1 - Graphing Fixes Cont'd 2025-08-31 19:03:43 -06:00
8db6ed0e82 1.3.0 - Graphing Fixes 2025-08-28 00:18:54 -06:00
8b9901383a 1.1.2 - More fixes for notification reliability 2025-08-27 22:21:53 -06:00
cf2adeef7a 1.1.2 - More fixes for notification reliability 2025-08-22 23:22:23 -06:00
a7481135b4 1.1.1 - More fixes for notification reliabilityyyyy 2025-08-22 21:00:08 -06:00
748a23e1c0 1.1.1 - More fixes for notification reliability 2025-08-22 20:59:36 -06:00
f078cfc6e1 1.1.0 - Export/Import overhaul 2025-08-22 20:39:19 -06:00
8bb1f422c1 1.0.1 - Notification reliability update... again 2025-08-22 19:13:40 -06:00
327dfba425 1.0.1 - Notification reliability update 2025-08-22 19:11:21 -06:00
96759e402d 1.0.0 - 1.0 baybeeeee 2025-08-22 16:19:25 -06:00
ed76fb2fb2 Remove outdated comment 2025-08-22 10:56:43 -06:00
870278f240 0.5.0 - Optimizations and better session management 2025-08-19 00:35:04 -06:00
4eef77bd3b 0.4.5 2025-08-18 09:53:40 -06:00
2d957db948 0.4.5 2025-08-18 09:52:53 -06:00
22bed6a961 Merge pull request 'Updated to support API version 36 properly' (#2) from dependencies into main
Reviewed-on: atridad/OpenClimb#2
2025-08-18 15:49:36 +00:00
b443c18a19 Updated to support API version 36 properly 2025-08-18 00:46:28 -06:00
89f1e350b3 0.4.4 - Cleaned up range views 2025-08-17 01:29:15 -06:00
0f976f685f 0.4.3 - Removing Average Grade... its not useful 2025-08-17 01:13:10 -06:00
c07186a7df 0.4.2 - Fixed issue with photo upload streams 2025-08-17 00:57:48 -06:00
15a5e217a5 0.4.1 - Small fix for share image 2025-08-16 20:20:30 -06:00
b86ab591fe Readme changes w obtainium 2025-08-16 19:06:39 -06:00
70c85d159e 0.4.0 - Bug fixes and improvements 2025-08-16 19:04:11 -06:00
d6c5e937df MOAR 2025-08-16 02:35:12 -06:00
829bbbff7a More cleanup 2025-08-16 02:34:29 -06:00
e1ebf412bd Add .gitignore 2025-08-16 02:33:43 -06:00
5c133b655e Remove files I dont need committed... 2025-08-16 02:33:11 -06:00
cc1edbc65c 0.3.3 2025-08-16 02:31:52 -06:00
ca770b9db3 0.3.2 - Optimizations 2025-08-16 00:48:38 -06:00
7edb7c8191 0.3.1 - Bugfix for status bar 2025-08-15 19:38:01 -06:00
1ca6b33882 Build 2025-08-15 19:31:35 -06:00
bd6b5cc652 0.3.0 - Filtering and Better Scales 2025-08-15 19:30:50 -06:00
6e16a30429 0.2.0 quick fixes 2025-08-15 14:51:30 -06:00
66fdef78d9 Adding icons 2025-08-15 14:42:36 -06:00
87195aabf1 Merge pull request '0.1.0 - Initial release of the app' (#1) from 0.1.0 into main
Reviewed-on: atridad/OpenClimb#1
2025-08-15 20:25:23 +00:00
92f43daec1 Added photos and ZIP exports 2025-08-15 14:22:17 -06:00
4617f02e4f Added photos and ZIP exports 2025-08-15 14:22:09 -06:00
87b0334bad 0.1.0 2025-08-15 13:58:53 -06:00
177 changed files with 31235 additions and 0 deletions

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

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

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Gradle files
.gradle/
build/
release/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
.DS_Store
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.aab
*.apk
output-metadata.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof

21
PRIVACY.md Normal file
View File

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

@@ -1,2 +1,46 @@
# OpenClimb
This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS.
## Download
For Android do one of the following:
1. Download the latest APK from the Releases page
2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
For iOS:
Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
## Self-Hosted Sync Server
You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up using Docker.
### Quick Start with Docker Compose
1. Create a `.env` file with your configuration:
```
IMAGE=git.atri.dad/atridad/openclimb-sync:latest
APP_PORT=8080
AUTH_TOKEN=your-secure-auth-token-here
DATA_FILE=/data/openclimb.json
IMAGES_DIR=/data/images
ROOT_DIR=./openclimb-data
```
2. Use the provided `docker-compose.yml` in the `sync/` directory:
```bash
cd sync/
docker-compose up -d
```
The server will be available at `http://localhost:8080`. Configure your clients with your server URL and auth token to start syncing.
## Requirements
- Android 12+ or iOS 17+
## Contribution
As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out.

1
android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,97 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
android {
namespace = "com.atridad.openclimb"
compileSdk = 36
defaultConfig {
applicationId = "com.atridad.openclimb"
minSdk = 31
targetSdk = 36
versionCode = 29
versionName = "1.7.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
buildFeatures { compose = true }
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
dependencies {
// Core Android libraries
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
// Compose BOM and UI
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
// Room Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// Navigation
implementation(libs.androidx.navigation.compose)
// ViewModel
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Serialization
implementation(libs.kotlinx.serialization.json)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// Image Loading
implementation(libs.coil.compose)
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

View File

@@ -0,0 +1,98 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
android {
namespace = "com.atridad.openclimb"
compileSdk = 36
defaultConfig {
applicationId = "com.atridad.openclimb"
minSdk = 31
targetSdk = 36
versionCode = 27
versionName = "1.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
buildFeatures { compose = true }
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
dependencies {
// Core Android libraries
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
// Compose BOM and UI
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
// Room Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// Navigation
implementation(libs.androidx.navigation.compose)
// ViewModel
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Serialization
implementation(libs.kotlinx.serialization.json)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// Image Loading
implementation(libs.coil.compose)
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

21
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Permissions for file access and camera -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Permission for sync functionality -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Hardware features -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Permissions for notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OpenClimb"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.OpenClimb.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- FileProvider for sharing images -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<!-- Session tracking service -->
<service
android:name=".service.SessionTrackingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse"
android:description="@string/session_tracking_service_description">
<meta-data
android:name="android.app.foreground_service_type"
android:value="specialUse" />
</service>
<!-- Widget Provider -->
<receiver
android:name=".widget.ClimbStatsWidgetProvider"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_climb_stats_info" />
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,52 @@
package com.atridad.openclimb
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import com.atridad.openclimb.ui.OpenClimbApp
import com.atridad.openclimb.ui.theme.OpenClimbTheme
class MainActivity : ComponentActivity() {
private var shortcutAction by mutableStateOf<String?>(null)
private var lastUsedGymId by mutableStateOf<String?>(null)
fun clearShortcutAction() {
shortcutAction = null
lastUsedGymId = null
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(R.style.Theme_OpenClimb)
enableEdgeToEdge()
shortcutAction = intent?.action
lastUsedGymId = intent?.getStringExtra("LAST_USED_GYM_ID")
setContent {
OpenClimbTheme {
Surface(modifier = Modifier.fillMaxSize()) {
OpenClimbApp(
shortcutAction = shortcutAction,
lastUsedGymId = lastUsedGymId,
onShortcutActionProcessed = { clearShortcutAction() }
)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
shortcutAction = intent.action
lastUsedGymId = intent.getStringExtra("LAST_USED_GYM_ID")
}
}

View File

@@ -0,0 +1,80 @@
package com.atridad.openclimb.data.database
import androidx.room.TypeConverter
import com.atridad.openclimb.data.model.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class Converters {
@TypeConverter
fun fromClimbTypeList(value: List<ClimbType>): String {
return Json.encodeToString(value)
}
@TypeConverter
fun toClimbTypeList(value: String): List<ClimbType> {
return Json.decodeFromString(value)
}
@TypeConverter
fun fromDifficultySystemList(value: List<DifficultySystem>): String {
return Json.encodeToString(value)
}
@TypeConverter
fun toDifficultySystemList(value: String): List<DifficultySystem> {
return Json.decodeFromString(value)
}
@TypeConverter
fun fromStringList(value: List<String>): String {
return Json.encodeToString(value)
}
@TypeConverter
fun toStringList(value: String): List<String> {
return Json.decodeFromString(value)
}
@TypeConverter
fun fromDifficultyGrade(value: DifficultyGrade): String {
return Json.encodeToString(value)
}
@TypeConverter
fun toDifficultyGrade(value: String): DifficultyGrade {
return Json.decodeFromString(value)
}
@TypeConverter
fun fromClimbType(value: ClimbType): String {
return value.name
}
@TypeConverter
fun toClimbType(value: String): ClimbType {
return ClimbType.valueOf(value)
}
@TypeConverter
fun fromAttemptResult(value: AttemptResult): String {
return value.name
}
@TypeConverter
fun toAttemptResult(value: String): AttemptResult {
return AttemptResult.valueOf(value)
}
@TypeConverter
fun fromSessionStatus(value: SessionStatus): String {
return value.name
}
@TypeConverter
fun toSessionStatus(value: String): SessionStatus {
return SessionStatus.valueOf(value)
}
}

View File

@@ -0,0 +1,82 @@
package com.atridad.openclimb.data.database
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import android.content.Context
import com.atridad.openclimb.data.database.dao.*
import com.atridad.openclimb.data.model.*
@Database(
entities = [
Gym::class,
Problem::class,
ClimbSession::class,
Attempt::class
],
version = 5,
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class OpenClimbDatabase : RoomDatabase() {
abstract fun gymDao(): GymDao
abstract fun problemDao(): ProblemDao
abstract fun climbSessionDao(): ClimbSessionDao
abstract fun attemptDao(): AttemptDao
companion object {
@Volatile
private var INSTANCE: OpenClimbDatabase? = null
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
val cursor = database.query("PRAGMA table_info(climb_sessions)")
val existingColumns = mutableSetOf<String>()
while (cursor.moveToNext()) {
val columnName = cursor.getString(cursor.getColumnIndexOrThrow("name"))
existingColumns.add(columnName)
}
cursor.close()
if (!existingColumns.contains("startTime")) {
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT")
}
if (!existingColumns.contains("endTime")) {
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT")
}
if (!existingColumns.contains("status")) {
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'")
}
database.execSQL("UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL")
database.execSQL("UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''")
}
}
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
}
}
fun getDatabase(context: Context): OpenClimbDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
OpenClimbDatabase::class.java,
"openclimb_database"
)
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
.enableMultiInstanceInvalidation()
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,76 @@
package com.atridad.openclimb.data.database.dao
import androidx.room.*
import com.atridad.openclimb.data.model.Attempt
import com.atridad.openclimb.data.model.AttemptResult
import kotlinx.coroutines.flow.Flow
@Dao
interface AttemptDao {
@Query("SELECT * FROM attempts ORDER BY timestamp DESC")
fun getAllAttempts(): Flow<List<Attempt>>
@Query("SELECT * FROM attempts WHERE id = :id")
suspend fun getAttemptById(id: String): Attempt?
@Query("SELECT * FROM attempts WHERE sessionId = :sessionId ORDER BY timestamp ASC")
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>>
@Query("SELECT * FROM attempts WHERE problemId = :problemId ORDER BY timestamp DESC")
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>>
@Query("SELECT * FROM attempts WHERE sessionId = :sessionId AND problemId = :problemId ORDER BY timestamp ASC")
fun getAttemptsBySessionAndProblem(sessionId: String, problemId: String): Flow<List<Attempt>>
@Query("SELECT * FROM attempts WHERE result = :result ORDER BY timestamp DESC")
fun getAttemptsByResult(result: AttemptResult): Flow<List<Attempt>>
@Query("SELECT * FROM attempts WHERE timestamp BETWEEN :startDate AND :endDate ORDER BY timestamp DESC")
fun getAttemptsInDateRange(startDate: String, endDate: String): Flow<List<Attempt>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAttempt(attempt: Attempt)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAttempts(attempts: List<Attempt>)
@Update
suspend fun updateAttempt(attempt: Attempt)
@Delete
suspend fun deleteAttempt(attempt: Attempt)
@Query("DELETE FROM attempts WHERE id = :id")
suspend fun deleteAttemptById(id: String)
@Query("DELETE FROM attempts WHERE sessionId = :sessionId")
suspend fun deleteAttemptsBySession(sessionId: String)
@Query("DELETE FROM attempts WHERE problemId = :problemId")
suspend fun deleteAttemptsByProblem(problemId: String)
@Query("SELECT COUNT(*) FROM attempts")
suspend fun getAttemptsCount(): Int
@Query("DELETE FROM attempts")
suspend fun deleteAllAttempts()
@Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId")
suspend fun getAttemptsCountBySession(sessionId: String): Int
@Query("SELECT COUNT(*) FROM attempts WHERE problemId = :problemId")
suspend fun getAttemptsCountByProblem(problemId: String): Int
@Query("SELECT COUNT(*) FROM attempts WHERE result = :result")
suspend fun getAttemptsCountByResult(result: AttemptResult): Int
@Query("SELECT COUNT(*) FROM attempts WHERE problemId = :problemId AND result IN ('SUCCESS', 'FLASH', 'REDPOINT', 'ONSIGHT')")
suspend fun getSuccessfulAttemptsCountByProblem(problemId: String): Int
@Query("SELECT * FROM attempts WHERE problemId = :problemId AND result IN ('SUCCESS', 'FLASH', 'REDPOINT', 'ONSIGHT') ORDER BY timestamp ASC LIMIT 1")
suspend fun getFirstSuccessfulAttempt(problemId: String): Attempt?
@Query("SELECT * FROM attempts WHERE problemId = :problemId ORDER BY timestamp DESC LIMIT 1")
suspend fun getLatestAttemptForProblem(problemId: String): Attempt?
}

View File

@@ -0,0 +1,67 @@
package com.atridad.openclimb.data.database.dao
import androidx.room.*
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus
import kotlinx.coroutines.flow.Flow
@Dao
interface ClimbSessionDao {
@Query("SELECT * FROM climb_sessions ORDER BY date DESC")
fun getAllSessions(): Flow<List<ClimbSession>>
@Query("SELECT * FROM climb_sessions WHERE id = :id")
suspend fun getSessionById(id: String): ClimbSession?
@Query("SELECT * FROM climb_sessions WHERE gymId = :gymId ORDER BY date DESC")
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>>
@Query("SELECT * FROM climb_sessions WHERE date = :date ORDER BY createdAt DESC")
fun getSessionsByDate(date: String): Flow<List<ClimbSession>>
@Query("SELECT * FROM climb_sessions WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
fun getSessionsInDateRange(startDate: String, endDate: String): Flow<List<ClimbSession>>
@Query("SELECT * FROM climb_sessions ORDER BY date DESC LIMIT :limit")
fun getRecentSessions(limit: Int = 10): Flow<List<ClimbSession>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSession(session: ClimbSession)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSessions(sessions: List<ClimbSession>)
@Update
suspend fun updateSession(session: ClimbSession)
@Delete
suspend fun deleteSession(session: ClimbSession)
@Query("DELETE FROM climb_sessions WHERE id = :id")
suspend fun deleteSessionById(id: String)
@Query("SELECT COUNT(*) FROM climb_sessions")
suspend fun getSessionsCount(): Int
@Query("SELECT COUNT(*) FROM climb_sessions WHERE gymId = :gymId")
suspend fun getSessionsCountByGym(gymId: String): Int
@Query("SELECT COUNT(*) FROM climb_sessions WHERE date BETWEEN :startDate AND :endDate")
suspend fun getSessionsCountInDateRange(startDate: String, endDate: String): Int
@Query("SELECT DISTINCT date FROM climb_sessions ORDER BY date DESC")
suspend fun getUniqueDates(): List<String>
@Query("SELECT * FROM climb_sessions WHERE status = :status ORDER BY date DESC")
fun getSessionsByStatus(status: SessionStatus): Flow<List<ClimbSession>>
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
suspend fun getActiveSession(): ClimbSession?
@Query("DELETE FROM climb_sessions")
suspend fun deleteAllSessions()
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
fun getActiveSessionFlow(): Flow<ClimbSession?>
}

View File

@@ -0,0 +1,43 @@
package com.atridad.openclimb.data.database.dao
import androidx.room.*
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Gym
import kotlinx.coroutines.flow.Flow
@Dao
interface GymDao {
@Query("SELECT * FROM gyms ORDER BY name ASC")
fun getAllGyms(): Flow<List<Gym>>
@Query("SELECT * FROM gyms WHERE id = :id")
suspend fun getGymById(id: String): Gym?
@Query("SELECT * FROM gyms WHERE :climbType IN (supportedClimbTypes)")
fun getGymsByClimbType(climbType: ClimbType): Flow<List<Gym>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGym(gym: Gym)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGyms(gyms: List<Gym>)
@Update
suspend fun updateGym(gym: Gym)
@Delete
suspend fun deleteGym(gym: Gym)
@Query("DELETE FROM gyms WHERE id = :id")
suspend fun deleteGymById(id: String)
@Query("SELECT COUNT(*) FROM gyms")
suspend fun getGymsCount(): Int
@Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'")
fun searchGyms(searchQuery: String): Flow<List<Gym>>
@Query("DELETE FROM gyms")
suspend fun deleteAllGyms()
}

View File

@@ -0,0 +1,64 @@
package com.atridad.openclimb.data.database.dao
import androidx.room.*
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Problem
import kotlinx.coroutines.flow.Flow
@Dao
interface ProblemDao {
@Query("SELECT * FROM problems ORDER BY updatedAt DESC")
fun getAllProblems(): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE id = :id") suspend fun getProblemById(id: String): Problem?
@Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC")
fun getProblemsByGym(gymId: String): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC")
fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>>
@Query(
"SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC"
)
fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC")
fun getActiveProblems(): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC")
fun getActiveProblemsByGym(gymId: String): Flow<List<Problem>>
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertProblem(problem: Problem)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProblems(problems: List<Problem>)
@Update suspend fun updateProblem(problem: Problem)
@Delete suspend fun deleteProblem(problem: Problem)
@Query("DELETE FROM problems WHERE id = :id") suspend fun deleteProblemById(id: String)
@Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId")
suspend fun getProblemsCountByGym(gymId: String): Int
@Query("SELECT COUNT(*) FROM problems WHERE isActive = 1")
suspend fun getActiveProblemsCount(): Int
@Query(
"""
SELECT * FROM problems
WHERE (name LIKE '%' || :searchQuery || '%'
OR description LIKE '%' || :searchQuery || '%'
OR location LIKE '%' || :searchQuery || '%')
ORDER BY updatedAt DESC
"""
)
fun searchProblems(searchQuery: String): Flow<List<Problem>>
@Query("SELECT COUNT(*) FROM problems") suspend fun getProblemsCount(): Int
@Query("DELETE FROM problems") suspend fun deleteAllProblems()
}

View File

@@ -0,0 +1,230 @@
package com.atridad.openclimb.data.format
import com.atridad.openclimb.data.model.*
import kotlinx.serialization.Serializable
// Root structure for OpenClimb backup data
@Serializable
data class ClimbDataBackup(
val exportedAt: String,
val version: String = "2.0",
val formatVersion: String = "2.0",
val gyms: List<BackupGym>,
val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>
)
// Platform-neutral gym representation for backup/restore
@Serializable
data class BackupGym(
val id: String,
val name: String,
val location: String? = null,
val supportedClimbTypes: List<ClimbType>,
val difficultySystems: List<DifficultySystem>,
@kotlinx.serialization.SerialName("customDifficultyGrades")
val customDifficultyGrades: List<String> = emptyList(),
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,
name = gym.name,
location = gym.location,
supportedClimbTypes = gym.supportedClimbTypes,
difficultySystems = gym.difficultySystems,
customDifficultyGrades = gym.customDifficultyGrades,
notes = gym.notes,
createdAt = gym.createdAt,
updatedAt = gym.updatedAt
)
}
}
/** Convert to native Android Gym model */
fun toGym(): Gym {
return Gym(
id = id,
name = name,
location = location,
supportedClimbTypes = supportedClimbTypes,
difficultySystems = difficultySystems,
customDifficultyGrades = customDifficultyGrades,
notes = notes,
createdAt = createdAt,
updatedAt = updatedAt
)
}
}
// Platform-neutral problem representation for backup/restore
@Serializable
data class BackupProblem(
val id: String,
val gymId: String,
val name: String? = null,
val description: String? = null,
val climbType: ClimbType,
val difficulty: DifficultyGrade,
val tags: List<String> = emptyList(),
val location: String? = null,
val imagePaths: List<String>? = null,
val isActive: Boolean = true,
val dateSet: String? = null,
val notes: String? = null,
val createdAt: String,
val updatedAt: String
) {
companion object {
/** Create BackupProblem from native Android Problem model */
fun fromProblem(problem: Problem): BackupProblem {
return BackupProblem(
id = problem.id,
gymId = problem.gymId,
name = problem.name,
description = problem.description,
climbType = problem.climbType,
difficulty = problem.difficulty,
tags = problem.tags,
location = problem.location,
imagePaths =
if (problem.imagePaths.isEmpty()) null
else problem.imagePaths.map { path -> path.substringAfterLast('/') },
isActive = problem.isActive,
dateSet = problem.dateSet,
notes = problem.notes,
createdAt = problem.createdAt,
updatedAt = problem.updatedAt
)
}
}
/** Convert to native Android Problem model */
fun toProblem(): Problem {
return Problem(
id = id,
gymId = gymId,
name = name,
description = description,
climbType = climbType,
difficulty = difficulty,
tags = tags,
location = location,
imagePaths = imagePaths ?: emptyList(),
isActive = isActive,
dateSet = dateSet,
notes = notes,
createdAt = createdAt,
updatedAt = updatedAt
)
}
/** Create a copy with updated image paths for import processing */
fun withUpdatedImagePaths(newImagePaths: List<String>): BackupProblem {
return copy(imagePaths = newImagePaths.ifEmpty { null })
}
}
// Platform-neutral climb session representation for backup/restore
@Serializable
data class BackupClimbSession(
val id: String,
val gymId: String,
val date: String,
val startTime: String? = null,
val endTime: String? = null,
val duration: Long? = null,
val status: SessionStatus,
val notes: String? = null,
val createdAt: String,
val updatedAt: String
) {
companion object {
/** Create BackupClimbSession from native Android ClimbSession model */
fun fromClimbSession(session: ClimbSession): BackupClimbSession {
return BackupClimbSession(
id = session.id,
gymId = session.gymId,
date = session.date,
startTime = session.startTime,
endTime = session.endTime,
duration = session.duration,
status = session.status,
notes = session.notes,
createdAt = session.createdAt,
updatedAt = session.updatedAt
)
}
}
/** Convert to native Android ClimbSession model */
fun toClimbSession(): ClimbSession {
return ClimbSession(
id = id,
gymId = gymId,
date = date,
startTime = startTime,
endTime = endTime,
duration = duration,
status = status,
notes = notes,
createdAt = createdAt,
updatedAt = updatedAt
)
}
}
// Platform-neutral attempt representation for backup/restore
@Serializable
data class BackupAttempt(
val id: String,
val sessionId: String,
val problemId: String,
val result: AttemptResult,
val highestHold: String? = null,
val notes: String? = null,
val duration: Long? = null,
val restTime: Long? = null,
val timestamp: String,
val createdAt: String,
val updatedAt: String
) {
companion object {
/** Create BackupAttempt from native Android Attempt model */
fun fromAttempt(attempt: Attempt): BackupAttempt {
return BackupAttempt(
id = attempt.id,
sessionId = attempt.sessionId,
problemId = attempt.problemId,
result = attempt.result,
highestHold = attempt.highestHold,
notes = attempt.notes,
duration = attempt.duration,
restTime = attempt.restTime,
timestamp = attempt.timestamp,
createdAt = attempt.createdAt
)
}
}
/** Convert to native Android Attempt model */
fun toAttempt(): Attempt {
return Attempt(
id = id,
sessionId = sessionId,
problemId = problemId,
result = result,
highestHold = highestHold,
notes = notes,
duration = duration,
restTime = restTime,
timestamp = timestamp,
createdAt = createdAt
)
}
}

View File

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

View File

@@ -0,0 +1,75 @@
package com.atridad.openclimb.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils
import kotlinx.serialization.Serializable
@Serializable
enum class AttemptResult {
SUCCESS,
FALL,
NO_PROGRESS,
FLASH,
}
@Entity(
tableName = "attempts",
foreignKeys =
[
ForeignKey(
entity = ClimbSession::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = Problem::class,
parentColumns = ["id"],
childColumns = ["problemId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])]
)
@Serializable
data class Attempt(
@PrimaryKey val id: String,
val sessionId: String,
val problemId: String,
val result: AttemptResult,
val highestHold: String? = null,
val notes: String? = null,
val duration: Long? = null,
val restTime: Long? = null,
val timestamp: String,
val createdAt: String
) {
companion object {
fun create(
sessionId: String,
problemId: String,
result: AttemptResult,
highestHold: String? = null,
notes: String? = null,
duration: Long? = null,
restTime: Long? = null,
timestamp: String = DateFormatUtils.nowISO8601()
): Attempt {
val now = DateFormatUtils.nowISO8601()
return Attempt(
id = java.util.UUID.randomUUID().toString(),
sessionId = sessionId,
problemId = problemId,
result = result,
highestHold = highestHold,
notes = notes,
duration = duration,
restTime = restTime,
timestamp = timestamp,
createdAt = now
)
}
}
}

View File

@@ -0,0 +1,80 @@
package com.atridad.openclimb.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils
import kotlinx.serialization.Serializable
@Serializable
enum class SessionStatus {
ACTIVE,
COMPLETED,
PAUSED
}
@Entity(
tableName = "climb_sessions",
foreignKeys =
[
ForeignKey(
entity = Gym::class,
parentColumns = ["id"],
childColumns = ["gymId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["gymId"])]
)
@Serializable
data class ClimbSession(
@PrimaryKey val id: String,
val gymId: String,
val date: String,
val startTime: String? = null,
val endTime: String? = null,
val duration: Long? = null,
val status: SessionStatus = SessionStatus.ACTIVE,
val notes: String? = null,
val createdAt: String,
val updatedAt: String
) {
companion object {
fun create(gymId: String, notes: String? = null): ClimbSession {
val now = DateFormatUtils.nowISO8601()
return ClimbSession(
id = java.util.UUID.randomUUID().toString(),
gymId = gymId,
date = now,
startTime = now,
status = SessionStatus.ACTIVE,
notes = notes,
createdAt = now,
updatedAt = now
)
}
fun ClimbSession.complete(): ClimbSession {
val endTime = DateFormatUtils.nowISO8601()
val durationMinutes =
if (startTime != null) {
try {
val start = DateFormatUtils.parseISO8601(startTime)
val end = DateFormatUtils.parseISO8601(endTime)
if (start != null && end != null) {
java.time.Duration.between(start, end).toMinutes()
} else null
} catch (_: Exception) {
null
}
} else null
return this.copy(
endTime = endTime,
duration = durationMinutes,
status = SessionStatus.COMPLETED,
updatedAt = DateFormatUtils.nowISO8601()
)
}
}
}

View File

@@ -0,0 +1,17 @@
package com.atridad.openclimb.data.model
import kotlinx.serialization.Serializable
@Serializable
enum class ClimbType {
ROPE,
BOULDER;
/**
* Get the display name
*/
fun getDisplayName(): String = when (this) {
ROPE -> "Rope"
BOULDER -> "Bouldering"
}
}

View File

@@ -0,0 +1,224 @@
package com.atridad.openclimb.data.model
import kotlinx.serialization.Serializable
@Serializable
enum class DifficultySystem {
// Bouldering
V_SCALE,
FONT,
// Rope
YDS,
CUSTOM;
/** Get the display name for the UI */
fun getDisplayName(): String =
when (this) {
V_SCALE -> "V Scale"
FONT -> "Font Scale"
YDS -> "YDS (Yosemite)"
CUSTOM -> "Custom"
}
/** Check if this system is for bouldering */
fun isBoulderingSystem(): Boolean =
when (this) {
V_SCALE, FONT -> true
YDS -> false
CUSTOM -> true
}
/** Check if this system is for rope climbing */
fun isRopeSystem(): Boolean =
when (this) {
YDS -> true
V_SCALE, FONT -> false
CUSTOM -> true
}
/** Get available grades for this system */
fun getAvailableGrades(): List<String> =
when (this) {
V_SCALE ->
listOf(
"VB",
"V0",
"V1",
"V2",
"V3",
"V4",
"V5",
"V6",
"V7",
"V8",
"V9",
"V10",
"V11",
"V12",
"V13",
"V14",
"V15",
"V16",
"V17"
)
FONT ->
listOf(
"3",
"4A",
"4B",
"4C",
"5A",
"5B",
"5C",
"6A",
"6A+",
"6B",
"6B+",
"6C",
"6C+",
"7A",
"7A+",
"7B",
"7B+",
"7C",
"7C+",
"8A",
"8A+",
"8B",
"8B+",
"8C",
"8C+"
)
YDS ->
listOf(
"5.0",
"5.1",
"5.2",
"5.3",
"5.4",
"5.5",
"5.6",
"5.7",
"5.8",
"5.9",
"5.10a",
"5.10b",
"5.10c",
"5.10d",
"5.11a",
"5.11b",
"5.11c",
"5.11d",
"5.12a",
"5.12b",
"5.12c",
"5.12d",
"5.13a",
"5.13b",
"5.13c",
"5.13d",
"5.14a",
"5.14b",
"5.14c",
"5.14d",
"5.15a",
"5.15b",
"5.15c",
"5.15d"
)
CUSTOM -> emptyList()
}
companion object {
/** Get all difficulty systems based on type */
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> =
when (climbType) {
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }
ClimbType.ROPE -> entries.filter { it.isRopeSystem() }
}
}
}
@Serializable
data class DifficultyGrade(val system: DifficultySystem, val grade: String, val numericValue: Int) {
constructor(
system: DifficultySystem,
grade: String
) : this(system = system, grade = grade, numericValue = calculateNumericValue(system, grade))
companion object {
private fun calculateNumericValue(system: DifficultySystem, grade: String): Int {
return when (system) {
DifficultySystem.V_SCALE -> {
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
}
DifficultySystem.YDS -> {
when {
grade.startsWith("5.10") ->
10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
grade.startsWith("5.11") ->
14 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
grade.startsWith("5.12") ->
18 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
grade.startsWith("5.13") ->
22 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
grade.startsWith("5.14") ->
26 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
grade.startsWith("5.15") ->
30 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
else -> grade.removePrefix("5.").toIntOrNull() ?: 0
}
}
DifficultySystem.FONT -> {
when {
grade.startsWith("6A") -> 6
grade.startsWith("6B") -> 7
grade.startsWith("6C") -> 8
grade.startsWith("7A") -> 9
grade.startsWith("7B") -> 10
grade.startsWith("7C") -> 11
grade.startsWith("8A") -> 12
grade.startsWith("8B") -> 13
grade.startsWith("8C") -> 14
else -> grade.toIntOrNull() ?: 0
}
}
DifficultySystem.CUSTOM -> grade.hashCode().rem(100)
}
}
}
/**
* Compare this grade with another grade of the same system Returns negative if this grade is
* easier, positive if harder, 0 if equal
*/
fun compareTo(other: DifficultyGrade): Int {
if (system != other.system) return 0
return when (system) {
DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade)
DifficultySystem.FONT -> compareFontGrades(grade, other.grade)
DifficultySystem.YDS -> compareYDSGrades(grade, other.grade)
DifficultySystem.CUSTOM -> grade.compareTo(other.grade)
}
}
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB" && grade2 == "VB") return 0
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
return num1.compareTo(num2)
}
private fun compareFontGrades(grade1: String, grade2: String): Int {
return grade1.compareTo(grade2)
}
private fun compareYDSGrades(grade1: String, grade2: String): Int {
return grade1.compareTo(grade2)
}
}

View File

@@ -0,0 +1,44 @@
package com.atridad.openclimb.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils
import kotlinx.serialization.Serializable
@Entity(tableName = "gyms")
@Serializable
data class Gym(
@PrimaryKey val id: String,
val name: String,
val location: String? = null,
val supportedClimbTypes: List<ClimbType>,
val difficultySystems: List<DifficultySystem>,
val customDifficultyGrades: List<String> = emptyList(),
val notes: String? = null,
val createdAt: String,
val updatedAt: String
) {
companion object {
fun create(
name: String,
location: String? = null,
supportedClimbTypes: List<ClimbType>,
difficultySystems: List<DifficultySystem>,
customDifficultyGrades: List<String> = emptyList(),
notes: String? = null
): Gym {
val now = DateFormatUtils.nowISO8601()
return Gym(
id = java.util.UUID.randomUUID().toString(),
name = name,
location = location,
supportedClimbTypes = supportedClimbTypes,
difficultySystems = difficultySystems,
customDifficultyGrades = customDifficultyGrades,
notes = notes,
createdAt = now,
updatedAt = now
)
}
}
}

View File

@@ -0,0 +1,71 @@
package com.atridad.openclimb.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.atridad.openclimb.utils.DateFormatUtils
import kotlinx.serialization.Serializable
@Entity(
tableName = "problems",
foreignKeys =
[
ForeignKey(
entity = Gym::class,
parentColumns = ["id"],
childColumns = ["gymId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["gymId"])]
)
@Serializable
data class Problem(
@PrimaryKey val id: String,
val gymId: String,
val name: String? = null,
val description: String? = null,
val climbType: ClimbType,
val difficulty: DifficultyGrade,
val tags: List<String> = emptyList(),
val location: String? = null,
val imagePaths: List<String> = emptyList(),
val isActive: Boolean = true,
val dateSet: String? = null,
val notes: String? = null,
val createdAt: String,
val updatedAt: String
) {
companion object {
fun create(
gymId: String,
name: String? = null,
description: String? = null,
climbType: ClimbType,
difficulty: DifficultyGrade,
tags: List<String> = emptyList(),
location: String? = null,
imagePaths: List<String> = emptyList(),
dateSet: String? = null,
notes: String? = null
): Problem {
val now = DateFormatUtils.nowISO8601()
return Problem(
id = java.util.UUID.randomUUID().toString(),
gymId = gymId,
name = name,
description = description,
climbType = climbType,
difficulty = difficulty,
tags = tags,
location = location,
imagePaths = imagePaths,
isActive = true,
dateSet = dateSet,
notes = notes,
createdAt = now,
updatedAt = now
)
}
}
}

View File

@@ -0,0 +1,362 @@
package com.atridad.openclimb.data.repository
import android.content.Context
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym
import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.data.state.DataStateManager
import com.atridad.openclimb.utils.DateFormatUtils
import com.atridad.openclimb.utils.ZipExportImportUtils
import java.io.File
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
private val gymDao = database.gymDao()
private val problemDao = database.problemDao()
private val sessionDao = database.climbSessionDao()
private val attemptDao = database.attemptDao()
private val dataStateManager = DataStateManager(context)
private var autoSyncCallback: (() -> Unit)? = null
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
// Gym operations
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
suspend fun insertGym(gym: Gym) {
gymDao.insertGym(gym)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun updateGym(gym: Gym) {
gymDao.updateGym(gym)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun deleteGym(gym: Gym) {
gymDao.deleteGym(gym)
dataStateManager.updateDataState()
triggerAutoSync()
}
// Problem operations
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
suspend fun insertProblem(problem: Problem) {
problemDao.insertProblem(problem)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun updateProblem(problem: Problem) {
problemDao.updateProblem(problem)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun deleteProblem(problem: Problem) {
problemDao.deleteProblem(problem)
dataStateManager.updateDataState()
triggerAutoSync()
}
// Session operations
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)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
suspend fun insertSession(session: ClimbSession) {
sessionDao.insertSession(session)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun updateSession(session: ClimbSession) {
sessionDao.updateSession(session)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun deleteSession(session: ClimbSession) {
sessionDao.deleteSession(session)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun getLastUsedGym(): Gym? {
val recentSessions = sessionDao.getRecentSessions(1).first()
return if (recentSessions.isNotEmpty()) {
getGymById(recentSessions.first().gymId)
} else {
null
}
}
// Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) {
attemptDao.insertAttempt(attempt)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun updateAttempt(attempt: Attempt) {
attemptDao.updateAttempt(attempt)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun deleteAttempt(attempt: Attempt) {
attemptDao.deleteAttempt(attempt)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
try {
val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first()
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) }
)
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths =
referencedImagePaths
.filter { imagePath ->
try {
val imageFile =
com.atridad.openclimb.utils.ImageUtils.getImageFile(
context,
imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (_: Exception) {
false
}
}
.toSet()
ZipExportImportUtils.createExportZipToUri(
context = context,
uri = uri,
exportData = backupData,
referencedImagePaths = validImagePaths
)
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
}
}
suspend fun importDataFromZip(file: File) {
try {
if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist")
}
val importResult = ZipExportImportUtils.extractImportZip(context, file)
if (importResult.jsonContent.isBlank()) {
throw Exception("Invalid ZIP file: no data.json found or empty content")
}
val importData =
try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
} catch (e: Exception) {
throw Exception("Invalid data format: ${e.message}")
}
validateImportData(importData)
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
importData.gyms.forEach { backupGym ->
try {
gymDao.insertGym(backupGym.toGym())
} catch (e: Exception) {
throw Exception("Failed to import gym '${backupGym.name}': ${e.message}")
}
}
val updatedBackupProblems =
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}"
)
}
}
importData.sessions.forEach { backupSession ->
try {
sessionDao.insertSession(backupSession.toClimbSession())
} catch (e: Exception) {
throw Exception("Failed to import session '${backupSession.id}': ${e.message}")
}
}
importData.attempts.forEach { backupAttempt ->
try {
attemptDao.insertAttempt(backupAttempt.toAttempt())
} catch (e: Exception) {
throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}")
}
}
dataStateManager.updateDataState()
} catch (e: Exception) {
throw Exception("Import failed: ${e.message}")
}
}
/**
* 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 validateDataIntegrity(
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"
)
}
val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
)
}
val problemIds = problems.map { it.id }.toSet()
val sessionIds = sessions.map { it.id }.toSet()
val invalidAttempts =
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"
)
}
}
private fun validateImportData(importData: ClimbDataBackup) {
if (importData.gyms.isEmpty()) {
throw Exception("Import data is invalid: no gyms found")
}
if (importData.version.isBlank()) {
throw Exception("Import data is invalid: no version information")
}
if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 ||
importData.sessions.size > 10000 ||
importData.attempts.size > 100000
) {
throw Exception("Import data is too large: possible corruption or malicious file")
}
}
suspend fun resetAllData() {
try {
val originalCallback = autoSyncCallback
autoSyncCallback = null
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
clearAllImages()
autoSyncCallback = originalCallback
} catch (e: Exception) {
throw Exception("Reset failed: ${e.message}")
}
}
suspend fun insertGymWithoutSync(gym: Gym) {
gymDao.insertGym(gym)
dataStateManager.updateDataState()
}
suspend fun insertProblemWithoutSync(problem: Problem) {
problemDao.insertProblem(problem)
dataStateManager.updateDataState()
}
suspend fun insertSessionWithoutSync(session: ClimbSession) {
sessionDao.insertSession(session)
dataStateManager.updateDataState()
}
suspend fun insertAttemptWithoutSync(attempt: Attempt) {
attemptDao.insertAttempt(attempt)
dataStateManager.updateDataState()
}
private fun clearAllImages() {
try {
val imagesDir = File(context.filesDir, "images")
if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0
imagesDir.deleteRecursively()
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files")
}
} catch (e: Exception) {
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}")
}
}
}

View File

@@ -0,0 +1,80 @@
package com.atridad.openclimb.data.state
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.atridad.openclimb.utils.DateFormatUtils
/**
* Manages the overall data state timestamp for sync purposes. This tracks when any data in the
* local database was last modified, independent of individual entity timestamps.
*/
class DataStateManager(context: Context) {
companion object {
private const val TAG = "DataStateManager"
private const val PREFS_NAME = "openclimb_data_state"
private const val KEY_LAST_MODIFIED = "last_modified_timestamp"
private const val KEY_INITIALIZED = "state_initialized"
}
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
init {
if (!isInitialized()) {
updateDataState()
markAsInitialized()
Log.d(TAG, "DataStateManager initialized with timestamp: ${getLastModified()}")
}
}
/**
* Updates the data state timestamp to the current time. Call this whenever any data is modified
* (create, update, delete).
*/
fun updateDataState() {
val now = DateFormatUtils.nowISO8601()
prefs.edit().putString(KEY_LAST_MODIFIED, now).apply()
Log.d(TAG, "Data state updated to: $now")
}
/**
* Gets the current data state timestamp. This represents when any data was last modified
* locally.
*/
fun getLastModified(): String {
return prefs.getString(KEY_LAST_MODIFIED, DateFormatUtils.nowISO8601())
?: DateFormatUtils.nowISO8601()
}
/**
* Sets the data state timestamp to a specific value. Used when importing data from server to
* sync the state.
*/
fun setLastModified(timestamp: String) {
prefs.edit().putString(KEY_LAST_MODIFIED, timestamp).apply()
Log.d(TAG, "Data state set to: $timestamp")
}
/** Resets the data state (for testing or complete data wipe). */
fun reset() {
prefs.edit().clear().apply()
Log.d(TAG, "Data state reset")
}
/** Checks if the data state has been initialized. */
private fun isInitialized(): Boolean {
return prefs.getBoolean(KEY_INITIALIZED, false)
}
/** Marks the data state as initialized. */
private fun markAsInitialized() {
prefs.edit().putBoolean(KEY_INITIALIZED, true).apply()
}
/** Gets debug information about the current state. */
fun getDebugInfo(): String {
return "DataState(lastModified=${getLastModified()}, initialized=${isInitialized()})"
}
}

View File

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

View File

@@ -0,0 +1,36 @@
package com.atridad.openclimb.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector
data class BottomNavigationItem(val screen: Screen, val icon: ImageVector, val label: String)
val bottomNavigationItems =
listOf(
BottomNavigationItem(
screen = Screen.Sessions,
icon = Icons.Default.PlayArrow,
label = "Sessions"
),
BottomNavigationItem(
screen = Screen.Problems,
icon = Icons.Default.Star,
label = "Problems"
),
BottomNavigationItem(
screen = Screen.Analytics,
icon = Icons.Default.Info,
label = "Analytics"
),
BottomNavigationItem(
screen = Screen.Gyms,
icon = Icons.Default.LocationOn,
label = "Gyms"
),
BottomNavigationItem(
screen = Screen.Settings,
icon = Icons.Default.Settings,
label = "Settings"
)
)

View File

@@ -0,0 +1,30 @@
package com.atridad.openclimb.navigation
import kotlinx.serialization.Serializable
@Serializable
sealed class Screen {
@Serializable data object Sessions : Screen()
@Serializable data object Problems : Screen()
@Serializable data object Analytics : Screen()
@Serializable data object Gyms : Screen()
@Serializable data object Settings : Screen()
@Serializable data class SessionDetail(val sessionId: String) : Screen()
@Serializable data class ProblemDetail(val problemId: String) : Screen()
@Serializable data class GymDetail(val gymId: String) : Screen()
@Serializable data class AddEditGym(val gymId: String? = null) : Screen()
@Serializable
data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen()
@Serializable
data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen()
}

View File

@@ -0,0 +1,292 @@
package com.atridad.openclimb.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository
import 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 {
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
putExtra(EXTRA_SESSION_ID, sessionId)
}
}
}
override fun onCreate() {
super.onCreate()
val database = OpenClimbDatabase.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 -> {
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
if (sessionId != null) {
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.openclimb.data.model.SessionStatus.ACTIVE) {
val completed = with(com.atridad.openclimb.data.model.ClimbSession) { targetSession.complete() }
repository.updateSession(completed)
}
} finally {
stopSessionTracking()
}
}
}
}
return START_REDELIVER_INTENT
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
}
override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) {
notificationJob?.cancel()
monitoringJob?.cancel()
try {
createAndShowNotification(sessionId)
} catch (e: Exception) {
e.printStackTrace()
}
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)
if (!isNotificationActive()) {
updateNotification(sessionId)
}
val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
break
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun stopSessionTracking() {
notificationJob?.cancel()
monitoringJob?.cancel()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun isNotificationActive(): Boolean {
return try {
val activeNotifications = notificationManager.activeNotifications
activeNotifications.any { it.id == NOTIFICATION_ID }
} catch (e: Exception) {
false
}
}
private suspend fun updateNotification(sessionId: String) {
try {
createAndShowNotification(sessionId)
} catch (e: Exception) {
e.printStackTrace()
try {
delay(10000L)
createAndShowNotification(sessionId)
} catch (retryException: Exception) {
retryException.printStackTrace()
stopSessionTracking()
}
}
}
private fun createAndShowNotification(sessionId: String) {
try {
val session = runBlocking {
repository.getSessionById(sessionId)
}
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
return
}
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"
}
} 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()
startForeground(NOTIFICATION_ID, notification)
notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) {
e.printStackTrace()
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"
}
return PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun createStopPendingIntent(sessionId: String): PendingIntent {
val intent = createStopIntent(this, sessionId)
return PendingIntent.getService(
this,
1,
intent,
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)
}
notificationManager.createNotificationChannel(channel)
}
override fun onDestroy() {
super.onDestroy()
notificationJob?.cancel()
monitoringJob?.cancel()
serviceScope.cancel()
}
}

View File

@@ -0,0 +1,407 @@
package com.atridad.openclimb.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.sync.SyncService
import com.atridad.openclimb.navigation.Screen
import com.atridad.openclimb.navigation.bottomNavigationItems
import com.atridad.openclimb.ui.components.NotificationPermissionDialog
import com.atridad.openclimb.ui.screens.*
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
import com.atridad.openclimb.utils.AppShortcutManager
import com.atridad.openclimb.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OpenClimbApp(
shortcutAction: String? = null,
lastUsedGymId: String? = null,
onShortcutActionProcessed: () -> Unit = {}
) {
val navController = rememberNavController()
val context = LocalContext.current
var lastUsedGym by remember { mutableStateOf<com.atridad.openclimb.data.model.Gym?>(null) }
val database = remember { OpenClimbDatabase.getDatabase(context) }
val repository = remember { ClimbRepository(database, context) }
val syncService = remember { SyncService(context, repository) }
val viewModel: ClimbViewModel =
viewModel(factory = ClimbViewModelFactory(repository, syncService))
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
val permissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (!isGranted) {
showNotificationPermissionDialog = false
}
}
LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)
) {
showNotificationPermissionDialog = true
}
}
}
LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) }
LaunchedEffect(Unit) { syncService.triggerAutoSync() }
val activeSession by viewModel.activeSession.collectAsState()
val gyms by viewModel.gyms.collectAsState()
LaunchedEffect(gyms) {
if (gyms.isNotEmpty() && lastUsedGym == null) {
lastUsedGym = viewModel.getLastUsedGym()
}
}
LaunchedEffect(activeSession, gyms, lastUsedGym) {
AppShortcutManager.updateShortcuts(
context = context,
hasActiveSession = activeSession != null,
hasGyms = gyms.isNotEmpty(),
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
)
}
LaunchedEffect(shortcutAction) {
when (shortcutAction) {
AppShortcutManager.ACTION_START_SESSION -> {
navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
}
}
AppShortcutManager.ACTION_END_SESSION -> {
navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
}
activeSession?.let { session -> viewModel.endSession(context, session.id) }
}
}
}
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d(
"OpenClimbApp",
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
)
if (activeSession == null) {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(
context
)
) {
android.util.Log.d("OpenClimbApp", "Showing notification permission dialog")
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) {
android.util.Log.d(
"OpenClimbApp",
"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
if (targetGym != null) {
android.util.Log.d(
"OpenClimbApp",
"Starting session with target gym: ${targetGym.name}"
)
viewModel.startSession(context, targetGym.id)
} else {
android.util.Log.d(
"OpenClimbApp",
"No target gym found, navigating to selection"
)
navController.navigate(Screen.AddEditSession())
}
}
}
} else {
android.util.Log.d(
"OpenClimbApp",
"Active session already exists: ${activeSession?.id}"
)
}
onShortcutActionProcessed()
}
}
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
Scaffold(
bottomBar = { OpenClimbBottomNavigation(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)
) {
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 {
if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id)
} else {
navController.navigate(Screen.AddEditSession())
}
}
}
)
} else {
null
}
}
SessionsScreen(
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
}
}
ProblemsScreen(
viewModel = viewModel,
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
composable<Screen.Analytics> {
LaunchedEffect(Unit) { fabConfig = null }
AnalyticsScreen(viewModel = viewModel)
}
composable<Screen.Gyms> {
LaunchedEffect(Unit) {
fabConfig =
FabConfig(
icon = Icons.Default.Add,
contentDescription = "Add Gym",
onClick = { navController.navigate(Screen.AddEditGym()) }
)
}
GymsScreen(
viewModel = viewModel,
onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId))
}
)
}
composable<Screen.Settings> {
LaunchedEffect(Unit) { fabConfig = null }
SettingsScreen(viewModel = viewModel)
}
composable<Screen.SessionDetail> { backStackEntry ->
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))
}
)
}
composable<Screen.ProblemDetail> { backStackEntry ->
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))
}
)
}
composable<Screen.GymDetail> { backStackEntry ->
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))
}
)
}
composable<Screen.AddEditGym> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen(
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
composable<Screen.AddEditProblem> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditProblemScreen(
problemId = args.problemId,
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
composable<Screen.AddEditSession> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditSession>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditSessionScreen(
sessionId = args.sessionId,
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
}
if (showNotificationPermissionDialog) {
NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = {
permissionLauncher.launch(
NotificationPermissionUtils.getNotificationPermissionString()
)
}
)
}
}
}
@Composable
fun OpenClimbBottomNavigation(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
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
}
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
}
}
)
}
}
}
data class FabConfig(
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String,
val onClick: () -> Unit
)

View File

@@ -0,0 +1,123 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.ui.theme.CustomIcons
import kotlinx.coroutines.delay
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
@Composable
fun ActiveSessionBanner(
activeSession: ClimbSession?,
gym: Gym?,
onSessionClick: () -> Unit,
onEndSession: () -> Unit
) {
if (activeSession != null) {
// Add a timer that updates every second for real-time duration counting
var currentTime by remember { mutableStateOf(LocalDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(1000) // Update every second
currentTime = LocalDateTime.now()
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onSessionClick() },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Active session",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Active Session",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = gym?.name ?: "Unknown Gym",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
activeSession.startTime?.let { startTime ->
val duration = calculateDuration(startTime, currentTime)
Text(
text = duration,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
IconButton(
onClick = onEndSession,
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Icon(
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
contentDescription = "End session"
)
}
}
}
}
}
private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String {
return try {
val startTime = LocalDateTime.parse(startTimeString)
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
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"
}
}

View File

@@ -0,0 +1,208 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Data point for the bar chart */
data class BarChartDataPoint(val label: String, val value: Int, val gradeNumeric: Int)
/** Configuration for bar chart styling */
data class BarChartStyle(
val barColor: Color,
val gridColor: Color,
val textColor: Color,
val backgroundColor: Color
)
/** Custom Bar Chart for displaying grade distribution */
@Composable
fun BarChart(
data: List<BarChartDataPoint>,
modifier: Modifier = Modifier,
style: BarChartStyle =
BarChartStyle(
barColor = MaterialTheme.colorScheme.primary,
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
backgroundColor = MaterialTheme.colorScheme.surface
),
showGrid: Boolean = true
) {
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
Box(modifier = modifier) {
Canvas(modifier = Modifier.fillMaxSize().padding(16.dp)) {
if (data.isEmpty()) return@Canvas
val padding = with(density) { 32.dp.toPx() }
val chartWidth = size.width - padding * 2
val chartHeight = size.height - padding * 2
// Sort data by grade numeric value
val sortedData = data.sortedBy { it.gradeNumeric }
// Calculate max value
val maxValue = sortedData.maxOfOrNull { it.value } ?: 1
// Bar dimensions
val barCount = sortedData.size
val totalSpacing = chartWidth * 0.2f
val barSpacing = if (barCount > 1) totalSpacing / (barCount + 1) else totalSpacing / 2
val barWidth = (chartWidth - totalSpacing) / barCount
// Draw background
drawRect(
color = style.backgroundColor,
topLeft = Offset(padding, padding),
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight)
)
// Draw grid
if (showGrid) {
drawGrid(
padding = padding,
chartWidth = chartWidth,
chartHeight = chartHeight,
gridColor = style.gridColor,
maxValue = maxValue,
textMeasurer = textMeasurer,
textColor = style.textColor
)
}
// Draw bars and labels
sortedData.forEachIndexed { index, dataPoint ->
val barHeight =
if (maxValue > 0) {
(dataPoint.value.toFloat() / maxValue.toFloat()) * chartHeight * 0.8f
} else 0f
val barX =
padding +
barSpacing +
index * (barWidth + barSpacing / (barCount - 1).coerceAtLeast(1))
val barY = padding + chartHeight - barHeight
// Draw bar
drawRect(
color = style.barColor,
topLeft = Offset(barX, barY),
size = androidx.compose.ui.geometry.Size(barWidth, barHeight)
)
// Draw value on bar
if (dataPoint.value > 0) {
val valueText = dataPoint.value.toString()
val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp)
val textSize = textMeasurer.measure(valueText, textStyle)
// Position text
val textY =
if (barHeight > textSize.size.height + 8.dp.toPx()) {
barY + 8.dp.toPx()
} else {
barY - 4.dp.toPx()
}
val textColor =
if (barHeight > textSize.size.height + 8.dp.toPx()) {
Color.White
} else {
style.textColor
}
drawText(
textMeasurer = textMeasurer,
text = valueText,
style = textStyle.copy(color = textColor),
topLeft = Offset(barX + barWidth / 2f - textSize.size.width / 2f, textY)
)
}
// Draw grade label below bar
val gradeText = dataPoint.label
val labelTextStyle = TextStyle(color = style.textColor, fontSize = 10.sp)
val labelTextSize = textMeasurer.measure(gradeText, labelTextStyle)
drawText(
textMeasurer = textMeasurer,
text = gradeText,
style = labelTextStyle,
topLeft =
Offset(
barX + barWidth / 2f - labelTextSize.size.width / 2f,
padding + chartHeight + 8.dp.toPx()
)
)
}
}
}
}
private fun DrawScope.drawGrid(
padding: Float,
chartWidth: Float,
chartHeight: Float,
gridColor: Color,
maxValue: Int,
textMeasurer: TextMeasurer,
textColor: Color
) {
val textStyle = TextStyle(color = textColor, fontSize = 10.sp)
// Horizontal grid lines
val gridLines =
when {
maxValue <= 5 -> (0..maxValue).toList()
maxValue <= 10 -> (0..maxValue step 2).toList()
maxValue <= 20 -> (0..maxValue step 5).toList()
else -> {
val step = (maxValue / 5).coerceAtLeast(1)
(0..maxValue step step).toList()
}
}
gridLines.forEach { value ->
val y = padding + chartHeight - (value.toFloat() / maxValue.toFloat()) * chartHeight * 0.8f
// Draw grid line
drawLine(
color = gridColor,
start = Offset(padding, y),
end = Offset(padding + chartWidth, y),
strokeWidth = 1.dp.toPx()
)
// Draw Y-axis label
if (value >= 0) {
val text = value.toString()
val textSize = textMeasurer.measure(text, textStyle)
drawText(
textMeasurer = textMeasurer,
text = text,
style = textStyle,
topLeft =
Offset(
padding - textSize.size.width - 8.dp.toPx(),
y - textSize.size.height / 2f
)
)
}
}
}

View File

@@ -0,0 +1,209 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FullscreenImageViewer(
imagePaths: List<String>,
initialIndex: Int = 0,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val pagerState = rememberPagerState(
initialPage = initialIndex,
pageCount = { imagePaths.size }
)
val thumbnailListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
// Auto-scroll thumbnail list to center current image
LaunchedEffect(pagerState.currentPage) {
thumbnailListState.animateScrollToItem(
index = pagerState.currentPage,
scrollOffset = -200
)
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
// Main image pager
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
ZoomableImage(
imagePath = imagePaths[page],
modifier = Modifier.fillMaxSize()
)
}
// Close button
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.background(
Color.Black.copy(alpha = 0.5f),
CircleShape
)
) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = Color.White
)
}
// Image counter
if (imagePaths.size > 1) {
Card(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.7f)
)
) {
Text(
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
// Thumbnail strip (if multiple images)
if (imagePaths.size > 1) {
Card(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.7f)
)
) {
LazyRow(
state = thumbnailListState,
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
itemsIndexed(imagePaths) { index, imagePath ->
val imageFile = ImageUtils.getImageFile(context, imagePath)
val isSelected = index == pagerState.currentPage
AsyncImage(
model = imageFile,
contentDescription = "Thumbnail ${index + 1}",
modifier = Modifier
.size(60.dp)
.clip(RoundedCornerShape(8.dp))
.clickable {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
}
.then(
if (isSelected) {
Modifier.background(
Color.White.copy(alpha = 0.3f),
RoundedCornerShape(8.dp)
)
} else Modifier
),
contentScale = ContentScale.Crop
)
}
}
}
}
}
}
}
@Composable
private fun ZoomableImage(
imagePath: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val imageFile = ImageUtils.getImageFile(context, imagePath)
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Box(
modifier = modifier
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(0.5f, 5f)
val maxOffsetX = (size.width * (scale - 1)) / 2
val maxOffsetY = (size.height * (scale - 1)) / 2
offsetX = (offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
offsetY = (offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
}
)
},
contentAlignment = Alignment.Center
) {
AsyncImage(
model = imageFile,
contentDescription = "Full screen image",
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
),
contentScale = ContentScale.Fit
)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,302 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Data point for the line chart
*/
data class ChartDataPoint(
val x: Float,
val y: Float,
val label: String? = null
)
/**
* Configuration for chart styling
*/
data class ChartStyle(
val lineColor: Color,
val fillColor: Color,
val lineWidth: Float = 3f,
val gridColor: Color,
val textColor: Color,
val backgroundColor: Color
)
/**
* Custom Line Chart with area fill below the line
*/
@Composable
fun LineChart(
data: List<ChartDataPoint>,
modifier: Modifier = Modifier,
style: ChartStyle = ChartStyle(
lineColor = MaterialTheme.colorScheme.primary,
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
backgroundColor = MaterialTheme.colorScheme.surface
),
showGrid: Boolean = true,
xAxisFormatter: (Float) -> String = { it.toString() },
yAxisFormatter: (Float) -> String = { it.toString() }
) {
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
Box(modifier = modifier) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
if (data.isEmpty()) return@Canvas
val padding = with(density) { 32.dp.toPx() }
val chartWidth = size.width - padding * 2
val chartHeight = size.height - padding * 2
// Calculate data bounds
val dataMinY = data.minOf { it.y }
val dataMaxY = data.maxOf { it.y }
// Add some padding to Y-axis (10% above and below the data range)
val yPadding = if (dataMaxY == dataMinY) 1f else (dataMaxY - dataMinY) * 0.1f
val minY = dataMinY - yPadding
val maxY = dataMaxY + yPadding
val minX = data.minOf { it.x }
val maxX = data.maxOf { it.x }
val xRange = if (maxX - minX == 0f) 1f else maxX - minX // Minimum range of 1 for single points
val yRange = maxY - minY
// Ensure we have valid ranges
if (yRange == 0f) return@Canvas
// Convert data points to screen coordinates
val screenPoints = data.map { point ->
val x = padding + (point.x - minX) / xRange * chartWidth
val y = padding + chartHeight - (point.y - minY) / yRange * chartHeight
Offset(x, y)
}
// Draw background
drawRect(
color = style.backgroundColor,
topLeft = Offset(padding, padding),
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight)
)
// Draw grid
if (showGrid) {
drawGrid(
padding = padding,
chartWidth = chartWidth,
chartHeight = chartHeight,
gridColor = style.gridColor,
minX = minX,
maxX = maxX,
minY = minY,
maxY = maxY,
textMeasurer = textMeasurer,
textColor = style.textColor,
xAxisFormatter = xAxisFormatter,
yAxisFormatter = yAxisFormatter,
actualDataPoints = data
)
}
// Draw area fill
if (screenPoints.size > 1) {
drawAreaFill(
points = screenPoints,
padding = padding,
chartHeight = chartHeight,
fillColor = style.fillColor
)
}
// Draw line
if (screenPoints.size > 1) {
drawLine(
points = screenPoints,
lineColor = style.lineColor,
lineWidth = style.lineWidth
)
}
// Draw data points - more pronounced
screenPoints.forEach { point ->
// Draw outer circle (larger)
drawCircle(
color = style.lineColor,
radius = 8f,
center = point
)
// Draw inner circle (white center)
drawCircle(
color = style.backgroundColor,
radius = 5f,
center = point
)
// Draw border for better visibility
drawCircle(
color = style.lineColor,
radius = 8f,
center = point,
style = Stroke(width = 2f)
)
}
}
}
}
private fun DrawScope.drawGrid(
padding: Float,
chartWidth: Float,
chartHeight: Float,
gridColor: Color,
minX: Float,
maxX: Float,
minY: Float,
maxY: Float,
textMeasurer: TextMeasurer,
textColor: Color,
xAxisFormatter: (Float) -> String,
yAxisFormatter: (Float) -> String,
actualDataPoints: List<ChartDataPoint>
) {
val textStyle = TextStyle(
color = textColor,
fontSize = 10.sp
)
// Draw vertical grid lines (X-axis) - only at integer values for sessions
val xRange = maxX - minX
if (xRange > 0) {
val startX = kotlin.math.ceil(minX).toInt()
val endX = kotlin.math.floor(maxX).toInt()
for (sessionNum in startX..endX) {
val x = padding + (sessionNum.toFloat() - minX) / xRange * chartWidth
// Draw grid line
drawLine(
color = gridColor,
start = Offset(x, padding),
end = Offset(x, padding + chartHeight),
strokeWidth = 1.dp.toPx()
)
// X-axis labels removed per user request
}
}
// Draw horizontal grid lines (Y-axis) - only at actual data point values
val yRange = maxY - minY
if (yRange > 0) {
// Get unique Y values from actual data points
val actualYValues = actualDataPoints.map { kotlin.math.round(it.y).toInt() }.toSet()
actualYValues.forEach { gradeValue ->
val y = padding + chartHeight - (gradeValue.toFloat() - minY) / yRange * chartHeight
// Only draw if within chart bounds
if (y >= padding && y <= padding + chartHeight) {
// Draw grid line
drawLine(
color = gridColor,
start = Offset(padding, y),
end = Offset(padding + chartWidth, y),
strokeWidth = 1.dp.toPx()
)
// Draw label
val text = yAxisFormatter(gradeValue.toFloat())
val textSize = textMeasurer.measure(text, textStyle)
drawText(
textMeasurer = textMeasurer,
text = text,
style = textStyle,
topLeft = Offset(
padding - textSize.size.width - 8.dp.toPx(),
y - textSize.size.height / 2f
)
)
}
}
}
}
private fun DrawScope.drawAreaFill(
points: List<Offset>,
padding: Float,
chartHeight: Float,
fillColor: Color
) {
val bottomY = padding + chartHeight // This represents the bottom of the chart area
val path = Path().apply {
// Start from bottom-left (at chart bottom level)
moveTo(points.first().x, bottomY)
// Draw to first point
lineTo(points.first().x, points.first().y)
// Draw line through all points
for (i in 1 until points.size) {
lineTo(points[i].x, points[i].y)
}
// Close the path by going to bottom-right (at chart bottom level) and back to start
lineTo(points.last().x, bottomY)
lineTo(points.first().x, bottomY)
close()
}
drawPath(
path = path,
color = fillColor
)
}
private fun DrawScope.drawLine(
points: List<Offset>,
lineColor: Color,
lineWidth: Float
) {
val path = Path().apply {
moveTo(points.first().x, points.first().y)
for (i in 1 until points.size) {
lineTo(points[i].x, points[i].y)
}
}
drawPath(
path = path,
color = lineColor,
style = Stroke(
width = lineWidth,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
}

View File

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

View File

@@ -0,0 +1,49 @@
package com.atridad.openclimb.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.StateFlow
@Composable
fun SyncIndicator(isSyncing: StateFlow<Boolean>, modifier: Modifier = Modifier) {
val syncing by isSyncing.collectAsState()
AnimatedVisibility(
visible = syncing,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
modifier = modifier
) {
Box(
modifier =
Modifier.size(28.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
.padding(6.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
}

View File

@@ -0,0 +1,865 @@
package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.ui.components.ImagePicker
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import kotlinx.coroutines.flow.first
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack: () -> Unit) {
var name by remember { mutableStateOf("") }
var location by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") }
var selectedClimbTypes by remember { mutableStateOf(setOf<ClimbType>()) }
var selectedDifficultySystems by remember { mutableStateOf(setOf<DifficultySystem>()) }
val isEditing = gymId != null
// Calculate available difficulty systems based on selected climb types
val availableDifficultySystems =
if (selectedClimbTypes.isEmpty()) {
emptyList()
} else {
selectedClimbTypes
.flatMap { climbType -> DifficultySystem.getSystemsForClimbType(climbType) }
.distinct()
}
// Reset selected difficulty systems when available systems change
LaunchedEffect(availableDifficultySystems) {
selectedDifficultySystems =
selectedDifficultySystems.filter { it in availableDifficultySystems }.toSet()
}
// Load existing gym data for editing
LaunchedEffect(gymId) {
if (gymId != null) {
val existingGym = viewModel.getGymById(gymId).first()
existingGym?.let { gym ->
name = gym.name
location = gym.location ?: ""
notes = gym.notes ?: ""
selectedClimbTypes = gym.supportedClimbTypes.toSet()
selectedDifficultySystems = gym.difficultySystems.toSet()
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isEditing) "Edit Gym" else "Add Gym") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
actions = {
TextButton(
onClick = {
val gym =
Gym.create(
name,
location,
selectedClimbTypes.toList(),
selectedDifficultySystems.toList(),
notes = notes
)
if (isEditing) {
viewModel.updateGym(gym.copy(id = gymId!!))
} else {
viewModel.addGym(gym)
}
onNavigateBack()
},
enabled =
name.isNotBlank() &&
selectedClimbTypes.isNotEmpty() &&
selectedDifficultySystems.isNotEmpty()
) { Text("Save") }
}
)
}
) { paddingValues ->
Column(
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Name field
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Gym Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
// Location field
OutlinedTextField(
value = location,
onValueChange = { location = it },
label = { Text("Location (Optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
// Climb Types
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Supported Climb Types",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
ClimbType.entries.forEach { climbType ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.selectable(
selected = climbType in selectedClimbTypes,
onClick = {
selectedClimbTypes =
if (climbType in
selectedClimbTypes
) {
selectedClimbTypes -
climbType
} else {
selectedClimbTypes +
climbType
}
},
role = Role.Checkbox
)
) {
Checkbox(
checked = climbType in selectedClimbTypes,
onCheckedChange = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(climbType.getDisplayName())
}
}
}
}
// Difficulty Systems
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Difficulty Systems",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
if (selectedClimbTypes.isEmpty()) {
Text(
text =
"Select climb types first to see available difficulty systems",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
availableDifficultySystems.forEach { system ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.selectable(
selected =
system in
selectedDifficultySystems,
onClick = {
selectedDifficultySystems =
if (system in
selectedDifficultySystems
) {
selectedDifficultySystems -
system
} else {
selectedDifficultySystems +
system
}
},
role = Role.Checkbox
)
) {
Checkbox(
checked = system in selectedDifficultySystems,
onCheckedChange = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(system.getDisplayName())
}
}
}
}
}
// Notes field
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes (Optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 3
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddEditProblemScreen(
problemId: String?,
gymId: String?,
viewModel: ClimbViewModel,
onNavigateBack: () -> Unit
) {
val isEditing = problemId != null
val gyms by viewModel.gyms.collectAsState()
// Problem form state
var selectedGym by remember {
mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } })
}
var problemName by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) }
var selectedDifficultySystem by remember { mutableStateOf(DifficultySystem.V_SCALE) }
var difficultyGrade by remember { mutableStateOf("") }
var location by remember { mutableStateOf("") }
var tags by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") }
var isActive by remember { mutableStateOf(true) }
var imagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
// Load existing problem data for editing
LaunchedEffect(problemId) {
if (problemId != null) {
val existingProblem = viewModel.getProblemById(problemId).first()
existingProblem?.let { p ->
problemName = p.name ?: ""
description = p.description ?: ""
selectedClimbType = p.climbType
selectedDifficultySystem = p.difficulty.system
difficultyGrade = p.difficulty.grade
location = p.location ?: ""
tags = p.tags.joinToString(", ")
notes = p.notes ?: ""
isActive = p.isActive
imagePaths = p.imagePaths
selectedGym = gyms.find { it.id == p.gymId }
}
}
}
LaunchedEffect(gymId, gyms) {
if (gymId != null && selectedGym == null) {
selectedGym = gyms.find { it.id == gymId }
}
}
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
val availableDifficultySystems =
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
selectedGym?.difficultySystems?.contains(system) != false
}
// Auto-select climb type if there's only one available
LaunchedEffect(availableClimbTypes) {
if (availableClimbTypes.size == 1 && selectedClimbType != availableClimbTypes.first()) {
selectedClimbType = availableClimbTypes.first()
}
}
// Auto-select or reset difficulty system based on climb type
LaunchedEffect(selectedClimbType, availableDifficultySystems) {
when {
// If current system is not compatible, select the first available one
selectedDifficultySystem !in availableDifficultySystems -> {
selectedDifficultySystem =
availableDifficultySystems.firstOrNull() ?: DifficultySystem.CUSTOM
}
// If there's only one available system and nothing is selected, auto-select it
availableDifficultySystems.size == 1 &&
selectedDifficultySystem != availableDifficultySystems.first() -> {
selectedDifficultySystem = availableDifficultySystems.first()
}
}
}
// Reset grade when difficulty system changes (unless it's a valid grade for the new system)
LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades()
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
difficultyGrade = ""
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isEditing) "Edit Problem" else "Add Problem") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
actions = {
TextButton(
onClick = {
selectedGym?.let { gym ->
val difficulty =
DifficultyGrade(
system = selectedDifficultySystem,
grade = difficultyGrade,
numericValue =
when (selectedDifficultySystem
) {
DifficultySystem.V_SCALE ->
difficultyGrade
.removePrefix(
"V"
)
.toIntOrNull()
?: 0
else ->
difficultyGrade
.hashCode() %
100 // Simple mapping for other systems
}
)
val problem =
Problem.create(
gymId = gym.id,
name = problemName.ifBlank { null },
description =
description.ifBlank { null },
climbType = selectedClimbType,
difficulty = difficulty,
tags =
tags.split(",")
.map { it.trim() }
.filter {
it.isNotBlank()
},
location = location.ifBlank { null },
imagePaths = imagePaths,
notes = notes.ifBlank { null }
)
if (isEditing) {
viewModel.updateProblem(
problem.copy(id = problemId!!)
)
} else {
viewModel.addProblem(problem)
}
onNavigateBack()
}
},
enabled = selectedGym != null && difficultyGrade.isNotBlank()
) { Text("Save") }
}
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Gym Selection
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Select Gym",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
if (gyms.isEmpty()) {
Text(
text = "No gyms available. Add a gym first.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
} else {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(gyms) { gym ->
FilterChip(
onClick = { selectedGym = gym },
label = { Text(gym.name) },
selected = selectedGym?.id == gym.id
)
}
}
}
}
}
}
// Basic Problem Info
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Problem Details",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = problemName,
onValueChange = { problemName = it },
label = { Text("Problem Name (Optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("e.g., 'The Overhang Monster', 'Yellow V4'") }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description (Optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
placeholder = { Text("Describe the problem, holds, style, etc.") }
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = location,
onValueChange = { location = it },
label = { Text("Location (Optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("e.g., 'Cave area', 'Wall 3', 'Right side'") }
)
}
}
}
// Climb Type
if (selectedGym != null) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Climb Type",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
availableClimbTypes.forEach { climbType ->
FilterChip(
onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) },
selected = selectedClimbType == climbType
)
}
}
}
}
}
}
// Difficulty
if (selectedGym != null) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Difficulty",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Difficulty System",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(availableDifficultySystems) { system ->
FilterChip(
onClick = { selectedDifficultySystem = system },
label = { Text(system.getDisplayName()) },
selected = selectedDifficultySystem == system
)
}
}
Spacer(modifier = Modifier.height(8.dp))
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
OutlinedTextField(
value = difficultyGrade,
onValueChange = { newValue ->
// Only allow integers for custom scales
if (newValue.isEmpty() || newValue.all { it.isDigit() }
) {
difficultyGrade = newValue
}
},
label = { Text("Grade *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = {
Text("Enter numeric grade (e.g. 5, 10, 15)")
},
supportingText = {
Text("Custom grades must be whole numbers")
},
keyboardOptions =
KeyboardOptions(keyboardType = KeyboardType.Number)
)
} else {
var expanded by remember { mutableStateOf(false) }
val availableGrades = selectedDifficultySystem.getAvailableGrades()
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = difficultyGrade,
onValueChange = {},
readOnly = true,
label = { Text("Grade *") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded
)
},
colors =
ExposedDropdownMenuDefaults
.outlinedTextFieldColors(),
modifier =
Modifier.menuAnchor(
androidx.compose.material3
.MenuAnchorType
.PrimaryNotEditable,
enabled = true
)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
availableGrades.forEach { grade ->
DropdownMenuItem(
text = { Text(grade) },
onClick = {
difficultyGrade = grade
expanded = false
}
)
}
}
}
}
}
}
}
}
// Images Section
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Photos",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
ImagePicker(
imageUris = imagePaths,
onImagesChanged = { imagePaths = it },
maxImages = 5
)
}
}
}
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Additional Info",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = tags,
onValueChange = { tags = it },
label = { Text("Tags (Optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("e.g., crimpy, dynamic (comma-separated)") }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes (Optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
placeholder = { Text("Any additional notes about this problem") }
)
Spacer(modifier = Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.selectable(
selected = isActive,
onClick = { isActive = !isActive },
role = Role.Checkbox
)
) {
Checkbox(checked = isActive, onCheckedChange = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Problem is currently active",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddEditSessionScreen(
sessionId: String?,
gymId: String?,
viewModel: ClimbViewModel,
onNavigateBack: () -> Unit
) {
val isEditing = sessionId != null
val gyms by viewModel.gyms.collectAsState()
val context = LocalContext.current
// Session form state
var selectedGym by remember {
mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } })
}
var sessionDate by remember { mutableStateOf(LocalDateTime.now().toLocalDate().toString()) }
var duration by remember { mutableStateOf("") }
var sessionNotes by remember { mutableStateOf("") }
// Load existing session data for editing
LaunchedEffect(sessionId) {
if (sessionId != null) {
val existingSession = viewModel.getSessionById(sessionId).first()
existingSession?.let { session ->
selectedGym = gyms.find { it.id == session.gymId }
sessionDate = session.date.split("T")[0] // Extract date part
duration = session.duration?.toString() ?: ""
sessionNotes = session.notes ?: ""
}
}
}
LaunchedEffect(gymId, gyms) {
if (gymId != null && selectedGym == null) {
selectedGym = gyms.find { it.id == gymId }
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isEditing) "Edit Session" else "Add Session") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
actions = {
TextButton(
onClick = {
selectedGym?.let { gym ->
if (isEditing) {
val session =
ClimbSession.create(
gymId = gym.id,
notes =
sessionNotes.ifBlank {
null
}
)
viewModel.updateSession(
session.copy(id = sessionId!!)
)
} else {
viewModel.startSession(
context,
gym.id,
sessionNotes.ifBlank { null }
)
}
onNavigateBack()
}
},
enabled = selectedGym != null
) { Text("Save") }
}
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Gym Selection
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Select Gym",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
if (gyms.isEmpty()) {
Text(
text = "No gyms available. Add a gym first.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
} else {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(gyms) { gym ->
FilterChip(
onClick = { selectedGym = gym },
label = { Text(gym.name) },
selected = selectedGym?.id == gym.id
)
}
}
}
}
}
}
// Session Details
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Session Details",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = sessionDate,
onValueChange = { sessionDate = it },
label = { Text("Date (YYYY-MM-DD)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = duration,
onValueChange = { duration = it },
label = { Text("Duration (minutes)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions =
KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = sessionNotes,
onValueChange = { sessionNotes = it },
label = { Text("Session Notes (Optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 3
)
}
}
}
}
}
}

View File

@@ -0,0 +1,531 @@
package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.AttemptResult
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.DifficultySystem
import com.atridad.openclimb.ui.components.BarChart
import com.atridad.openclimb.ui.components.BarChartDataPoint
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Composable
fun AnalyticsScreen(viewModel: ClimbViewModel) {
val sessions by viewModel.sessions.collectAsState()
val problems by viewModel.problems.collectAsState()
val attempts by viewModel.attempts.collectAsState()
val gyms by viewModel.gyms.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Analytics",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
}
}
// Overall Stats
item {
OverallStatsCard(
totalSessions = sessions.size,
totalProblems = problems.size,
totalAttempts = attempts.size,
totalGyms = gyms.size
)
}
// Grade Distribution Chart
item {
val gradeDistributionData = calculateGradeDistribution(sessions, problems, attempts)
GradeDistributionChartCard(gradeDistributionData = gradeDistributionData)
}
// Favorite Gym
item {
val favoriteGym =
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
(gymId, sessions) ->
gyms.find { it.id == gymId }?.name to sessions.size
}
FavoriteGymCard(
gymName = favoriteGym?.first ?: "No sessions yet",
sessionCount = favoriteGym?.second ?: 0
)
}
// Recent Activity
item {
val recentSessions = sessions.take(5)
RecentActivityCard(recentSessions = recentSessions.size)
}
}
}
@Composable
fun OverallStatsCard(totalSessions: Int, totalProblems: Int, totalAttempts: Int, totalGyms: Int) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Overall Stats",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(label = "Sessions", value = totalSessions.toString())
StatItem(label = "Problems", value = totalProblems.toString())
StatItem(label = "Attempts", value = totalAttempts.toString())
StatItem(label = "Gyms", value = totalGyms.toString())
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionDataPoint>) {
// Find all grading systems that have been used in the data
val usedSystems =
remember(gradeDistributionData) {
gradeDistributionData.map { it.difficultySystem }.distinct()
}
var selectedSystem by
remember(usedSystems) {
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
}
var expanded by remember { mutableStateOf(false) }
var showAllTime by remember { mutableStateOf(true) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Grade Distribution",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
// Toggles section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Time period toggle
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// All Time button
FilterChip(
onClick = { showAllTime = true },
label = {
Text("All Time", style = MaterialTheme.typography.bodySmall)
},
selected = showAllTime,
colors =
FilterChipDefaults.filterChipColors(
selectedContainerColor =
MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
// 7 Days button
FilterChip(
onClick = { showAllTime = false },
label = { Text("7 Days", style = MaterialTheme.typography.bodySmall) },
selected = !showAllTime,
colors =
FilterChipDefaults.filterChipColors(
selectedContainerColor =
MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
}
// Scale selector dropdown
if (usedSystems.size > 1) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value =
when (selectedSystem) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
},
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
modifier =
Modifier.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
enabled = true
)
.width(120.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
usedSystems.forEach { system ->
DropdownMenuItem(
text = {
Text(
when (system) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
}
)
},
onClick = {
selectedSystem = system
expanded = false
}
)
}
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Filter grade distribution data by selected scale and time period
val filteredGradeData =
remember(gradeDistributionData, selectedSystem, showAllTime) {
val systemFiltered =
gradeDistributionData.filter {
it.difficultySystem == selectedSystem
}
if (showAllTime) {
systemFiltered
} else {
// Filter for last 7 days
val sevenDaysAgo = LocalDateTime.now().minusDays(7)
systemFiltered.filter { dataPoint ->
try {
val attemptDate =
LocalDateTime.parse(
dataPoint.date,
DateTimeFormatter.ISO_LOCAL_DATE_TIME
)
attemptDate.isAfter(sevenDaysAgo)
} catch (_: Exception) {
// If date parsing fails, include the data point
true
}
}
}
}
if (filteredGradeData.isNotEmpty()) {
// Group by grade and sum counts
val gradeGroups =
filteredGradeData
.groupBy { it.grade }
.mapValues { (_, dataPoints) -> dataPoints.sumOf { it.count } }
.map { (grade, count) ->
val firstDataPoint =
filteredGradeData.first { it.grade == grade }
BarChartDataPoint(
label = grade,
value = count,
gradeNumeric = firstDataPoint.gradeNumeric
)
}
BarChart(data = gradeGroups, modifier = Modifier.fillMaxWidth().height(220.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
"Successful climbs by ${when(selectedSystem) {
DifficultySystem.V_SCALE -> "V-grade"
DifficultySystem.FONT -> "Font grade"
DifficultySystem.YDS -> "YDS grade"
DifficultySystem.CUSTOM -> "custom grade"
}}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Column(
modifier = Modifier.fillMaxWidth().height(220.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "No data",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No data available.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text =
if (showAllTime)
"Complete some climbs to see your grade distribution!"
else "No climbs in the last 7 days",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
}
}
@Composable
fun FavoriteGymCard(gymName: String, sessionCount: Int) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Favorite Gym",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = gymName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Medium
)
if (sessionCount > 0) {
Text(
text = "$sessionCount sessions",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
fun RecentActivityCard(recentSessions: Int) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Recent Activity",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
if (recentSessions > 0) {
"You've had $recentSessions recent sessions"
} else {
"No recent activity"
},
style = MaterialTheme.typography.bodyMedium
)
}
}
}
data class GradeDistributionDataPoint(
val date: String,
val grade: String,
val gradeNumeric: Int,
val count: Int,
val climbType: ClimbType,
val difficultySystem: DifficultySystem
)
fun calculateGradeDistribution(
sessions: List<com.atridad.openclimb.data.model.ClimbSession>,
problems: List<com.atridad.openclimb.data.model.Problem>,
attempts: List<com.atridad.openclimb.data.model.Attempt>
): List<GradeDistributionDataPoint> {
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
return emptyList()
}
// Get all successful attempts
val successfulAttempts =
attempts.filter {
it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH
}
if (successfulAttempts.isEmpty()) {
return emptyList()
}
// Map attempts to problems and create grade distribution data
val gradeDistribution = mutableMapOf<String, GradeDistributionDataPoint>()
successfulAttempts.forEach { attempt ->
val problem = problems.find { it.id == attempt.problemId }
val session = sessions.find { it.id == attempt.sessionId }
if (problem != null && session != null) {
val key = "${problem.difficulty.system.name}-${problem.difficulty.grade}"
val existing = gradeDistribution[key]
if (existing != null) {
gradeDistribution[key] = existing.copy(count = existing.count + 1)
} else {
gradeDistribution[key] =
GradeDistributionDataPoint(
date = attempt.timestamp,
grade = problem.difficulty.grade,
gradeNumeric =
gradeToNumeric(
problem.difficulty.system,
problem.difficulty.grade
),
count = 1,
climbType = problem.climbType,
difficultySystem = problem.difficulty.system
)
}
}
}
return gradeDistribution.values.toList()
}
fun gradeToNumeric(system: DifficultySystem, grade: String): Int {
return when (system) {
DifficultySystem.V_SCALE -> {
when (grade) {
"VB" -> 0
else -> grade.removePrefix("V").toIntOrNull() ?: 0
}
}
DifficultySystem.FONT -> {
when (grade) {
"3" -> 3
"4A" -> 4
"4B" -> 5
"4C" -> 6
"5A" -> 7
"5B" -> 8
"5C" -> 9
"6A" -> 10
"6A+" -> 11
"6B" -> 12
"6B+" -> 13
"6C" -> 14
"6C+" -> 15
"7A" -> 16
"7A+" -> 17
"7B" -> 18
"7B+" -> 19
"7C" -> 20
"7C+" -> 21
"8A" -> 22
"8A+" -> 23
"8B" -> 24
"8B+" -> 25
"8C" -> 26
"8C+" -> 27
else -> 0
}
}
DifficultySystem.YDS -> {
when (grade) {
"5.0" -> 50
"5.1" -> 51
"5.2" -> 52
"5.3" -> 53
"5.4" -> 54
"5.5" -> 55
"5.6" -> 56
"5.7" -> 57
"5.8" -> 58
"5.9" -> 59
"5.10a" -> 60
"5.10b" -> 61
"5.10c" -> 62
"5.10d" -> 63
"5.11a" -> 64
"5.11b" -> 65
"5.11c" -> 66
"5.11d" -> 67
"5.12a" -> 68
"5.12b" -> 69
"5.12c" -> 70
"5.12d" -> 71
"5.13a" -> 72
"5.13b" -> 73
"5.13c" -> 74
"5.13d" -> 75
"5.14a" -> 76
"5.14b" -> 77
"5.14c" -> 78
"5.14d" -> 79
"5.15a" -> 80
"5.15b" -> 81
"5.15c" -> 82
"5.15d" -> 83
else -> 0
}
}
DifficultySystem.CUSTOM -> {
// Custom grades are numeric strings, so parse them directly
grade.toIntOrNull() ?: 0
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Unit) {
val gyms by viewModel.gyms.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Climbing Gyms",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
}
Spacer(modifier = Modifier.height(16.dp))
if (gyms.isEmpty()) {
EmptyStateMessage(
title = "No Gyms Added",
message = "Add your favorite climbing gyms to start tracking your progress!",
onActionClick = {},
actionText = ""
)
} else {
LazyColumn {
items(gyms) { gym ->
GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) })
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GymCard(gym: Gym, onClick: () -> Unit) {
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = gym.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
gym.location?.let { location ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = location,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
Row {
gym.supportedClimbTypes.forEach { climbType ->
AssistChip(
onClick = {},
label = { Text(climbType.getDisplayName()) },
modifier = Modifier.padding(end = 4.dp)
)
}
}
if (gym.difficultySystems.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text =
"Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
gym.notes?.let { notes ->
if (notes.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = notes,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
}
}
}
}

View File

@@ -0,0 +1,322 @@
package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.data.model.Problem
import com.atridad.openclimb.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplay
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState()
var showImageViewer by remember { mutableStateOf(false) }
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedImageIndex by remember { mutableIntStateOf(0) }
// Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
var selectedGym by remember { mutableStateOf<Gym?>(null) }
// Apply filters
val filteredProblems =
problems.filter { problem ->
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
climbTypeMatch && gymMatch
}
// Separate active and inactive problems
val activeProblems = filteredProblems.filter { it.isActive }
val inactiveProblems = filteredProblems.filter { !it.isActive }
val sortedProblems = activeProblems + inactiveProblems
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Climbing Problems",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
}
Spacer(modifier = Modifier.height(16.dp))
// Filters Section
if (problems.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Filters",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
// Climb Type Filter
Text(
text = "Climb Type",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item {
FilterChip(
onClick = { selectedClimbType = null },
label = { Text("All Types") },
selected = selectedClimbType == null
)
}
items(ClimbType.entries) { climbType ->
FilterChip(
onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) },
selected = selectedClimbType == climbType
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Gym Filter
Text(
text = "Gym",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item {
FilterChip(
onClick = { selectedGym = null },
label = { Text("All Gyms") },
selected = selectedGym == null
)
}
items(gyms) { gym ->
FilterChip(
onClick = { selectedGym = gym },
label = { Text(gym.name) },
selected = selectedGym?.id == gym.id
)
}
}
// Filter result count
if (selectedClimbType != null || selectedGym != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text =
"Showing ${filteredProblems.size} of ${problems.size} problems (${activeProblems.size} active, ${inactiveProblems.size} reset)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
if (filteredProblems.isEmpty()) {
EmptyStateMessage(
title =
if (problems.isEmpty()) {
if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet"
} else {
"No Problems Match Filters"
},
message =
if (problems.isEmpty()) {
if (gyms.isEmpty())
"Add a gym first to start tracking problems and routes!"
else "Start tracking your favorite problems and routes!"
} else {
"Try adjusting your filters to see more problems."
},
onActionClick = {},
actionText = ""
)
} else {
LazyColumn {
items(sortedProblems) { problem ->
ProblemCard(
problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
onClick = { onNavigateToProblemDetail(problem.id) },
onImageClick = { imagePaths, index ->
selectedImagePaths = imagePaths
selectedImageIndex = index
showImageViewer = true
},
onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem)
}
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
// Fullscreen Image Viewer
if (showImageViewer && selectedImagePaths.isNotEmpty()) {
FullscreenImageViewer(
imagePaths = selectedImagePaths,
initialIndex = selectedImageIndex,
onDismiss = { showImageViewer = false }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProblemCard(
problem: Problem,
gymName: String,
onClick: () -> Unit,
onImageClick: ((List<String>, Int) -> Unit)? = null,
onToggleActive: (() -> Unit)? = null
) {
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = problem.name ?: "Unnamed Problem",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color =
if (problem.isActive) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = gymName,
style = MaterialTheme.typography.bodyMedium,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = if (problem.isActive) 1f else 0.6f
)
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = problem.difficulty.grade,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = problem.climbType.getDisplayName(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
problem.location?.let { location ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Location: $location",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (problem.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Row {
problem.tags.take(3).forEach { tag ->
AssistChip(
onClick = {},
label = { Text(tag) },
modifier = Modifier.padding(end = 4.dp)
)
}
}
}
// Display images if any
if (problem.imagePaths.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
ImageDisplay(
imagePaths = problem.imagePaths.take(3), // Show max 3 images in list
imageSize = 60,
onImageClick = { index -> onImageClick?.invoke(problem.imagePaths, index) }
)
}
if (!problem.isActive) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Reset / No Longer Set",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Medium
)
}
// Toggle active button
if (onToggleActive != null) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onToggleActive,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor =
if (problem.isActive)
MaterialTheme.colorScheme.tertiary
else MaterialTheme.colorScheme.primary
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (problem.isActive) "Mark as Reset" else "Mark as Active",
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}

View File

@@ -0,0 +1,256 @@
package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus
import com.atridad.openclimb.ui.components.ActiveSessionBanner
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String) -> Unit) {
val context = LocalContext.current
val sessions by viewModel.sessions.collectAsState()
val gyms by viewModel.gyms.collectAsState()
val activeSession by viewModel.activeSession.collectAsState()
val uiState by viewModel.uiState.collectAsState()
// Filter out active sessions from regular session list
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Climbing Sessions",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
}
Spacer(modifier = Modifier.height(16.dp))
// Active session banner
ActiveSessionBanner(
activeSession = activeSession,
gym = activeSessionGym,
onSessionClick = { activeSession?.let { onNavigateToSessionDetail(it.id) } },
onEndSession = { activeSession?.let { viewModel.endSession(context, it.id) } }
)
if (activeSession != null) {
Spacer(modifier = Modifier.height(16.dp))
}
if (completedSessions.isEmpty() && activeSession == null) {
EmptyStateMessage(
title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet",
message =
if (gyms.isEmpty())
"Add a gym first to start tracking your climbing sessions!"
else "Start your first climbing session!",
onActionClick = {},
actionText = ""
)
} else {
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))
}
}
}
}
// Show UI state messages and errors
uiState.message?.let { message ->
LaunchedEffect(message) {
kotlinx.coroutines.delay(5000)
viewModel.clearMessage()
}
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
uiState.error?.let { error ->
LaunchedEffect(error) {
kotlinx.coroutines.delay(5000)
viewModel.clearError()
}
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = gymName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = formatDate(session.date),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
session.duration?.let { duration ->
Text(
text = "Duration: $duration minutes",
style = MaterialTheme.typography.bodyMedium
)
}
session.notes?.let { notes ->
if (notes.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = notes,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
}
}
}
}
@Composable
fun EmptyStateMessage(
title: String,
message: String,
onActionClick: () -> Unit,
actionText: String
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
if (actionText.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onActionClick) { Text(actionText) }
}
}
}
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
}
}

View File

@@ -0,0 +1,831 @@
package com.atridad.openclimb.ui.screens
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.io.File
import java.time.Instant
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: ClimbViewModel) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
// Sync service state
val syncService = viewModel.syncService
val isSyncing by syncService.isSyncing.collectAsState()
val isConnected by syncService.isConnected.collectAsState()
val isTesting by syncService.isTesting.collectAsState()
val lastSyncTime by syncService.lastSyncTime.collectAsState()
val syncError by syncService.syncError.collectAsState()
// State for dialogs
var showResetDialog by remember { mutableStateOf(false) }
var showSyncConfigDialog by remember { mutableStateOf(false) }
var showDisconnectDialog by remember { mutableStateOf(false) }
// Sync configuration state
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
// File picker launcher for import - only accepts ZIP files
val importLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri
->
uri?.let {
try {
val inputStream = context.contentResolver.openInputStream(uri)
// Determine file extension from content resolver
val fileName =
context.contentResolver.query(uri, null, null, null, null)?.use {
cursor ->
val nameIndex =
cursor.getColumnIndex(
android.provider.OpenableColumns.DISPLAY_NAME
)
if (nameIndex >= 0 && cursor.moveToFirst()) {
cursor.getString(nameIndex)
} else null
}
?: "import_file"
// Only allow ZIP files
if (!fileName.lowercase().endsWith(".zip")) {
viewModel.setError(
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
)
return@let
}
val tempFile = File(context.cacheDir, "temp_import.zip")
inputStream?.use { input ->
tempFile.outputStream().use { output -> input.copyTo(output) }
}
viewModel.importData(tempFile)
} catch (e: Exception) {
viewModel.setError("Failed to read file: ${e.message}")
}
}
}
// File picker launcher for export - ZIP format with images
val exportZipLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/zip")
) { uri ->
uri?.let {
try {
viewModel.exportDataToZipUri(context, uri)
} catch (e: Exception) {
viewModel.setError("Failed to save file: ${e.message}")
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Settings",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
}
}
// Sync Section
item {
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Sync",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
if (syncService.isConfigured) {
// Connected state
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (isConnected)
MaterialTheme.colorScheme
.primaryContainer.copy(
alpha = 0.3f
)
else
MaterialTheme.colorScheme
.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = {
Text(
if (isConnected) "Connected to Server"
else "Server Configured"
)
},
supportingContent = {
Column {
Text("Server: ${syncService.serverURL}")
lastSyncTime?.let { time ->
Text(
"Last sync: ${
try {
Instant.parse(time).toString()
} catch (_: Exception) {
time
}
}",
style = MaterialTheme.typography.bodySmall
)
}
}
},
leadingContent = {
Icon(
if (isConnected) Icons.Default.CloudDone
else Icons.Default.Cloud,
contentDescription = null,
tint =
if (isConnected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme
.onSurfaceVariant
)
},
trailingContent = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Manual Sync Button
TextButton(
onClick = {
coroutineScope.launch {
viewModel.performManualSync()
}
},
enabled = isConnected && !isSyncing
) {
if (isSyncing) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Sync")
}
}
// Configure Button
TextButton(onClick = { showSyncConfigDialog = true }) {
Text("Configure")
}
}
}
)
}
Spacer(modifier = Modifier.height(8.dp))
// Auto-sync settings
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant
.copy(alpha = 0.3f)
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Sync Mode",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("Auto-sync")
Text(
text =
"Sync automatically on app launch and data changes",
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.7f
),
maxLines = 2
)
}
Spacer(modifier = Modifier.width(16.dp))
Switch(
checked = syncService.isAutoSyncEnabled,
onCheckedChange = { syncService.isAutoSyncEnabled = it }
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// Disconnect option
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer
.copy(alpha = 0.3f)
)
) {
ListItem(
headlineContent = { Text("Disconnect") },
supportingContent = { Text("Clear sync configuration") },
leadingContent = {
Icon(
Icons.Default.CloudOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
trailingContent = {
TextButton(onClick = { showDisconnectDialog = true }) {
Text(
"Disconnect",
color = MaterialTheme.colorScheme.error
)
}
}
)
}
} else {
// Not configured state
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant
.copy(alpha = 0.3f)
)
) {
ListItem(
headlineContent = { Text("Setup Sync") },
supportingContent = {
Text("Connect to your OpenClimb sync server")
},
leadingContent = {
Icon(Icons.Default.CloudSync, contentDescription = null)
},
trailingContent = {
TextButton(onClick = { showSyncConfigDialog = true }) {
Text("Setup")
}
}
)
}
}
// Show sync error if any
syncError?.let { error ->
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
}
}
// Data Management Section
item {
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Data Management",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
// Export Data
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Export Data with Images") },
supportingContent = {
Text(
"Export all your climbing data and images to ZIP file (recommended)"
)
},
leadingContent = {
Icon(Icons.Default.Share, contentDescription = null)
},
trailingContent = {
TextButton(
onClick = {
val defaultFileName =
"openclimb_export_${
java.time.LocalDateTime.now()
.toString()
.replace(":", "-")
.replace(".", "-")
}.zip"
exportZipLauncher.launch(defaultFileName)
},
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Export ZIP")
}
}
}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Import Data") },
supportingContent = {
Text("Import climbing data from ZIP file (recommended format)")
},
leadingContent = {
Icon(Icons.Default.Add, contentDescription = null)
},
trailingContent = {
TextButton(
onClick = { importLauncher.launch("application/zip") },
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Import")
}
}
}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Reset All Data") },
supportingContent = {
Text(
"Permanently delete all gyms, problems, sessions, attempts, and images"
)
},
leadingContent = {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
trailingContent = {
TextButton(
onClick = { showResetDialog = true },
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Reset", color = MaterialTheme.colorScheme.error)
}
}
}
)
}
}
}
}
// App Information Section
item {
Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "App Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
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 = "OpenClimb Logo",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text("OpenClimb")
}
},
supportingContent = { Text("Track your climbing progress") },
leadingContent = {}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = { Text("Version") },
supportingContent = { Text(appVersion ?: "Unknown") },
leadingContent = {
Icon(Icons.Default.Info, contentDescription = null)
}
)
}
}
}
}
}
// Show loading/message states
if (uiState.isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
uiState.message?.let { message ->
LaunchedEffect(message) {
kotlinx.coroutines.delay(5000)
viewModel.clearMessage()
}
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
uiState.error?.let { error ->
LaunchedEffect(error) {
kotlinx.coroutines.delay(5000)
viewModel.clearError()
}
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
// Reset confirmation dialog
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Reset All Data") },
text = {
Column {
Text("Are you sure you want to reset all data?")
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This will permanently delete:",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text =
"• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
"This action cannot be undone. Consider exporting your data first.",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.error
)
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.resetAllData()
showResetDialog = false
}
) { Text("Reset All Data", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) { Text("Cancel") }
}
)
}
// Sync Configuration Dialog
if (showSyncConfigDialog) {
AlertDialog(
onDismissRequest = { showSyncConfigDialog = false },
title = { Text("Sync Configuration") },
text = {
Column {
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server URL") },
placeholder = { Text("https://your-server.com") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = authToken,
onValueChange = { authToken = it },
label = { Text("Auth Token") },
placeholder = { Text("your-secret-token") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
if (syncService.isConfigured) {
Text(
text = "Test connection before enabling sync features",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "Enter your server URL and auth token to enable sync",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
confirmButton = {
Row {
if (syncService.isConfigured) {
TextButton(
onClick = {
coroutineScope.launch {
try {
// Save configuration first
syncService.serverURL = serverUrl.trim()
syncService.authToken = authToken.trim()
viewModel.testSyncConnection()
showSyncConfigDialog = false
} catch (_: Exception) {
// Error will be shown via syncError state
}
}
},
enabled =
!isTesting &&
serverUrl.isNotBlank() &&
authToken.isNotBlank()
) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Test Connection")
}
}
Spacer(modifier = Modifier.width(8.dp))
}
TextButton(
onClick = {
syncService.serverURL = serverUrl.trim()
syncService.authToken = authToken.trim()
showSyncConfigDialog = false
},
enabled = serverUrl.isNotBlank() && authToken.isNotBlank()
) { Text("Save") }
}
},
dismissButton = {
TextButton(
onClick = {
// Reset to current values
serverUrl = syncService.serverURL
authToken = syncService.authToken
showSyncConfigDialog = false
}
) { Text("Cancel") }
}
)
}
// Disconnect Dialog
if (showDisconnectDialog) {
AlertDialog(
onDismissRequest = { showDisconnectDialog = false },
title = { Text("Disconnect from Sync") },
text = {
Text(
"Are you sure you want to disconnect from the sync server? This will clear your server configuration and disable auto-sync."
)
},
confirmButton = {
TextButton(
onClick = {
syncService.clearConfiguration()
serverUrl = ""
authToken = ""
showDisconnectDialog = false
}
) { Text("Disconnect", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
}
)
}
}

View File

@@ -0,0 +1,66 @@
package com.atridad.openclimb.ui.theme
import androidx.compose.ui.graphics.Color
// Climbing-themed Material You color palette
// Orange - Primary (represents rock/sandstone climbing)
val ClimbOrange10 = Color(0xFF1F0E00)
val ClimbOrange20 = Color(0xFF3E1C00)
val ClimbOrange30 = Color(0xFF5D2B00)
val ClimbOrange40 = Color(0xFF7C3900)
val ClimbOrange80 = Color(0xFFFFB786)
val ClimbOrange90 = Color(0xFFFFDCC2)
val ClimbOrange100 = Color(0xFFFFFFFF)
// Grey - Secondary (represents granite/slate)
val ClimbGrey10 = Color(0xFF1F1F1F)
val ClimbGrey20 = Color(0xFF2F2F2F)
val ClimbGrey30 = Color(0xFF484848)
val ClimbGrey40 = Color(0xFF606060)
val ClimbGrey80 = Color(0xFFC7C7C7)
val ClimbGrey90 = Color(0xFFE3E3E3)
val ClimbGrey100 = Color(0xFFFFFFFF)
// Blue - Tertiary (represents ice/water)
val ClimbBlue10 = Color(0xFF001F2A)
val ClimbBlue20 = Color(0xFF003544)
val ClimbBlue30 = Color(0xFF004D61)
val ClimbBlue40 = Color(0xFF00677F)
val ClimbBlue80 = Color(0xFF5DDBFF)
val ClimbBlue90 = Color(0xFFB8EAFF)
val ClimbBlue100 = Color(0xFFFFFFFF)
// Red - Error colors
val ClimbRed10 = Color(0xFF410001)
val ClimbRed20 = Color(0xFF680003)
val ClimbRed30 = Color(0xFF930006)
val ClimbRed40 = Color(0xFFBA1B1B)
val ClimbRed80 = Color(0xFFFFB4A9)
val ClimbRed90 = Color(0xFFFFDAD4)
val ClimbRed100 = Color(0xFFFFFFFF)
// Neutral colors for surfaces
val ClimbNeutral0 = Color(0xFF000000)
val ClimbNeutral4 = Color(0xFF0F0F0F)
val ClimbNeutral6 = Color(0xFF141414)
val ClimbNeutral10 = Color(0xFF1F1F1F)
val ClimbNeutral12 = Color(0xFF232323)
val ClimbNeutral17 = Color(0xFF2C2C2C)
val ClimbNeutral20 = Color(0xFF313131)
val ClimbNeutral22 = Color(0xFF363636)
val ClimbNeutral24 = Color(0xFF393939)
val ClimbNeutral87 = Color(0xFFDDDDDD)
val ClimbNeutral90 = Color(0xFFE6E6E6)
val ClimbNeutral92 = Color(0xFFEBEBEB)
val ClimbNeutral94 = Color(0xFFF0F0F0)
val ClimbNeutral95 = Color(0xFFF3F3F3)
val ClimbNeutral96 = Color(0xFFF5F5F5)
val ClimbNeutral98 = Color(0xFFFAFAFA)
val ClimbNeutral100 = Color(0xFFFFFFFF)
// Neutral variant colors for outlines and variants
val ClimbNeutralVariant30 = Color(0xFF484848)
val ClimbNeutralVariant50 = Color(0xFF797979)
val ClimbNeutralVariant60 = Color(0xFF939393)
val ClimbNeutralVariant80 = Color(0xFFC7C7C7)
val ClimbNeutralVariant90 = Color(0xFFE3E3E3)

View File

@@ -0,0 +1,25 @@
package com.atridad.openclimb.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
object CustomIcons {
fun Stop(color: Color = Color.Black): ImageVector = ImageVector.Builder(
name = "Stop",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).path(
fill = SolidColor(color)
) {
moveTo(6f, 6f)
horizontalLineTo(18f)
verticalLineTo(18f)
horizontalLineTo(6f)
close()
}.build()
}

View File

@@ -0,0 +1,121 @@
package com.atridad.openclimb.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// Climbing-themed dark color scheme with full Material You compatibility
private val DarkColorScheme = darkColorScheme(
primary = ClimbOrange80,
onPrimary = ClimbOrange20,
primaryContainer = ClimbOrange30,
onPrimaryContainer = ClimbOrange90,
secondary = ClimbGrey80,
onSecondary = ClimbGrey20,
secondaryContainer = ClimbGrey30,
onSecondaryContainer = ClimbGrey90,
tertiary = ClimbBlue80,
onTertiary = ClimbBlue20,
tertiaryContainer = ClimbBlue30,
onTertiaryContainer = ClimbBlue90,
error = ClimbRed80,
onError = ClimbRed20,
errorContainer = ClimbRed30,
onErrorContainer = ClimbRed90,
surface = ClimbNeutral10,
onSurface = ClimbNeutral90,
surfaceVariant = ClimbNeutralVariant30,
onSurfaceVariant = ClimbNeutralVariant80,
outline = ClimbNeutralVariant60,
outlineVariant = ClimbNeutralVariant30,
scrim = ClimbNeutral0,
inverseSurface = ClimbNeutral90,
inverseOnSurface = ClimbNeutral20,
inversePrimary = ClimbOrange40,
surfaceDim = ClimbNeutral6,
surfaceBright = ClimbNeutral24,
surfaceContainerLowest = ClimbNeutral4,
surfaceContainerLow = ClimbNeutral10,
surfaceContainer = ClimbNeutral12,
surfaceContainerHigh = ClimbNeutral17,
surfaceContainerHighest = ClimbNeutral22
)
// Climbing-themed light color scheme with full Material You compatibility
private val LightColorScheme = lightColorScheme(
primary = ClimbOrange40,
onPrimary = ClimbOrange100,
primaryContainer = ClimbOrange90,
onPrimaryContainer = ClimbOrange10,
secondary = ClimbGrey40,
onSecondary = ClimbGrey100,
secondaryContainer = ClimbGrey90,
onSecondaryContainer = ClimbGrey10,
tertiary = ClimbBlue40,
onTertiary = ClimbBlue100,
tertiaryContainer = ClimbBlue90,
onTertiaryContainer = ClimbBlue10,
error = ClimbRed40,
onError = ClimbRed100,
errorContainer = ClimbRed90,
onErrorContainer = ClimbRed10,
surface = ClimbNeutral98,
onSurface = ClimbNeutral10,
surfaceVariant = ClimbNeutralVariant90,
onSurfaceVariant = ClimbNeutralVariant30,
outline = ClimbNeutralVariant50,
outlineVariant = ClimbNeutralVariant80,
scrim = ClimbNeutral0,
inverseSurface = ClimbNeutral20,
inverseOnSurface = ClimbNeutral95,
inversePrimary = ClimbOrange80,
surfaceDim = ClimbNeutral87,
surfaceBright = ClimbNeutral98,
surfaceContainerLowest = ClimbNeutral100,
surfaceContainerLow = ClimbNeutral96,
surfaceContainer = ClimbNeutral94,
surfaceContainerHigh = ClimbNeutral92,
surfaceContainerHighest = ClimbNeutral90
)
@Composable
fun OpenClimbTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ and provides full Material You theming
// When enabled, it adapts to the user's system wallpaper colors
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && true -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,18 @@
package com.atridad.openclimb.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
)

View File

@@ -0,0 +1,464 @@
package com.atridad.openclimb.ui.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.sync.SyncService
import com.atridad.openclimb.service.SessionTrackingService
import com.atridad.openclimb.utils.ImageUtils
import com.atridad.openclimb.utils.SessionShareUtils
import com.atridad.openclimb.widget.ClimbStatsWidgetProvider
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ClimbViewModel(private val repository: ClimbRepository, val syncService: SyncService) :
ViewModel() {
// UI State flows
private val _uiState = MutableStateFlow(ClimbUiState())
val uiState: StateFlow<ClimbUiState> = _uiState.asStateFlow()
// Data flows
val gyms =
repository
.getAllGyms()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val problems =
repository
.getAllProblems()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val sessions =
repository
.getAllSessions()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val activeSession =
repository
.getActiveSessionFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = null
)
val attempts =
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) {
viewModelScope.launch {
repository.insertGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
fun updateGym(gym: Gym) {
viewModelScope.launch { repository.updateGym(gym) }
}
fun updateGym(gym: Gym, context: Context) {
viewModelScope.launch {
repository.updateGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
fun deleteGym(gym: Gym) {
viewModelScope.launch { repository.deleteGym(gym) }
}
fun deleteGym(gym: Gym, context: Context) {
viewModelScope.launch {
repository.deleteGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
// Problem operations
fun addProblem(problem: Problem) {
viewModelScope.launch { repository.insertProblem(problem) }
}
fun addProblem(problem: Problem, context: Context) {
viewModelScope.launch {
repository.insertProblem(problem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
}
}
fun updateProblem(problem: Problem) {
viewModelScope.launch { repository.updateProblem(problem) }
}
fun updateProblem(problem: Problem, context: Context) {
viewModelScope.launch {
repository.updateProblem(problem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
fun deleteProblem(problem: Problem, context: Context) {
viewModelScope.launch {
// Delete associated images
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
repository.deleteProblem(problem)
cleanupOrphanedImages(context)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
private suspend fun cleanupOrphanedImages(context: Context) {
val allProblems = repository.getAllProblems().first()
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
}
fun getProblemById(id: String): Flow<Problem?> = flow { emit(repository.getProblemById(id)) }
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) {
viewModelScope.launch {
repository.updateSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
fun deleteSession(session: ClimbSession) {
viewModelScope.launch { repository.deleteSession(session) }
}
fun deleteSession(session: ClimbSession, context: Context) {
viewModelScope.launch {
repository.deleteSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
fun getSessionById(id: String): Flow<ClimbSession?> = flow {
emit(repository.getSessionById(id))
}
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
repository.getSessionsByGym(gymId)
// Get last used gym for shortcut functionality
suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym()
// Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch {
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context)
) {
android.util.Log.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."
)
return@launch
}
val existingActive = repository.getActiveSession()
if (existingActive != null) {
android.util.Log.d(
"ClimbViewModel",
"Active session already exists: ${existingActive.id}"
)
_uiState.value =
_uiState.value.copy(
error = "There's already an active session. Please end it first."
)
return@launch
}
android.util.Log.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}"
)
// Start the tracking service
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
context.startForegroundService(serviceIntent)
ClimbStatsWidgetProvider.updateAllWidgets(context)
android.util.Log.d("ClimbViewModel", "Session started successfully")
_uiState.value = _uiState.value.copy(message = "Session started successfully!")
}
}
fun endSession(context: Context, sessionId: String) {
viewModelScope.launch {
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context)
) {
_uiState.value =
_uiState.value.copy(
error =
"Notification permission is required to manage your climbing session. Please enable notifications in settings."
)
return@launch
}
val session = repository.getSessionById(sessionId)
if (session != null && session.status == SessionStatus.ACTIVE) {
val completedSession = with(ClimbSession) { session.complete() }
repository.updateSession(completedSession)
val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
context.startService(serviceIntent)
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
_uiState.value = _uiState.value.copy(message = "Session completed!")
}
}
}
fun ensureSessionTrackingServiceRunning(context: Context) {
viewModelScope.launch {
val activeSession = repository.getActiveSession()
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
val serviceIntent =
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) {
viewModelScope.launch {
repository.insertAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
// Auto-sync now happens automatically via repository callback
}
}
fun deleteAttempt(attempt: Attempt) {
viewModelScope.launch { repository.deleteAttempt(attempt) }
}
fun deleteAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch {
repository.deleteAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
fun updateAttempt(attempt: Attempt) {
viewModelScope.launch { repository.updateAttempt(attempt) }
}
fun updateAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch {
repository.updateAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
}
}
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
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..."
)
repository.exportAllDataToZipUri(context, uri)
_uiState.value =
_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}"
)
}
}
}
fun importData(file: File) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
if (!file.name.lowercase().endsWith(".zip")) {
throw Exception(
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
)
}
repository.importDataFromZip(file)
_uiState.value =
_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}"
)
}
}
}
// UI state operations
fun clearMessage() {
_uiState.value = _uiState.value.copy(message = null)
}
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
// Sync-related methods
suspend fun performManualSync() {
try {
syncService.syncWithServer()
} catch (e: Exception) {
setError("Sync failed: ${e.message}")
}
}
suspend fun testSyncConnection() {
try {
syncService.testConnection()
} catch (e: Exception) {
setError("Connection test failed: ${e.message}")
}
}
fun setError(message: String) {
_uiState.value = _uiState.value.copy(error = message)
}
fun resetAllData() {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
repository.resetAllData()
_uiState.value =
_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}")
}
}
}
// 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
)

View File

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

View File

@@ -0,0 +1,46 @@
package com.atridad.openclimb.utils
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
object DateFormatUtils {
// ISO 8601 formatter matching iOS date format exactly
private val ISO_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC)
/** Get current timestamp in iOS-compatible ISO 8601 format */
fun nowISO8601(): String {
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) {
try {
Instant.parse(dateString)
} catch (e2: Exception) {
null
}
}
}
/** Validate that a date string matches the expected iOS format */
fun isValidISO8601(dateString: String): Boolean {
return parseISO8601(dateString) != null
}
/** Convert milliseconds timestamp to iOS-compatible ISO 8601 format */
fun millisToISO8601(millis: Long): String {
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
}
}

View File

@@ -0,0 +1,101 @@
package com.atridad.openclimb.utils
import java.security.MessageDigest
import java.util.*
/**
* Utility for creating consistent image filenames across iOS and Android platforms. Uses
* deterministic naming based on problem ID and timestamp to ensure sync compatibility.
*/
object ImageNamingUtils {
private const val IMAGE_EXTENSION = ".jpg"
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
/** Generates a deterministic filename for a problem image */
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
// Create a deterministic hash from problemId + timestamp + index
val input = "${problemId}_${timestamp}_${imageIndex}"
val hash = createHash(input)
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
}
/** Generates a deterministic filename using current timestamp */
fun generateImageFilename(problemId: String, imageIndex: Int): String {
val timestamp = DateFormatUtils.nowISO8601()
return generateImageFilename(problemId, timestamp, imageIndex)
}
/** 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
}
// We can't extract the original problem ID from the hash,
// but we can validate the format
return parts[1] // Return the hash as identifier
}
/** 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 it's already using our convention, keep it
if (isValidImageFilename(oldFilename)) {
return oldFilename
}
// Generate new deterministic name
// Use a timestamp based on the old filename to maintain some consistency
val timestamp = DateFormatUtils.nowISO8601()
return generateImageFilename(problemId, timestamp, imageIndex)
}
/** Creates a deterministic hash from input string */
private fun createHash(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
val hashHex = hashBytes.joinToString("") { "%02x".format(it) }
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 = migrateFilename(oldFilename, problemId, index)
if (newFilename != oldFilename) {
renameMap[oldFilename] = newFilename
}
}
return renameMap
}
}

View File

@@ -0,0 +1,331 @@
package com.atridad.openclimb.utils
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.core.graphics.scale
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
object ImageUtils {
private const val IMAGES_DIR = "problem_images"
private const val MAX_IMAGE_SIZE = 1024
private const val IMAGE_QUALITY = 85
// Creates the images directory if it doesn't exist
private fun getImagesDirectory(context: Context): File {
val imagesDir = File(context.filesDir, IMAGES_DIR)
if (!imagesDir.exists()) {
imagesDir.mkdirs()
}
return imagesDir
}
/** Saves an image from a URI with compression and proper orientation */
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
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap)
val filename =
if (problemId != null && imageIndex != null) {
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
} else {
"${UUID.randomUUID()}.jpg"
}
val imageFile = File(getImagesDirectory(context), filename)
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle()
}
compressedBitmap.recycle()
"$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 = android.media.ExifInterface(input)
val orientation =
exif.getAttributeInt(
android.media.ExifInterface.TAG_ORIENTATION,
android.media.ExifInterface.ORIENTATION_NORMAL
)
val matrix = android.graphics.Matrix()
when (orientation) {
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
}
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
}
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
}
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
android.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 {
val width = original.width
val height = original.height
// 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
}
return if (scaleFactor < 1f) {
val newWidth = (width * scaleFactor).toInt()
val newHeight = (height * scaleFactor).toInt()
original.scale(newWidth, newHeight)
} else {
original
}
}
/** Gets the full file path for an image */
fun getImageFile(context: Context, relativePath: String): File {
// If relativePath already contains the directory, use it as-is
// Otherwise, assume it's just a filename and add the images directory
return if (relativePath.contains("/")) {
File(context.filesDir, relativePath)
} else {
// Just a filename - look in the images directory
File(getImagesDirectory(context), relativePath)
}
}
/** Deletes an image file */
fun deleteImage(context: Context, relativePath: String): Boolean {
return try {
val file = getImageFile(context, relativePath)
file.delete()
} catch (e: Exception) {
e.printStackTrace()
false
}
}
/** Imports an image file from the import directory */
fun importImageFile(context: Context, sourceFile: File): String? {
return try {
if (!sourceFile.exists()) return null
// Generate new filename to avoid conflicts
val extension = sourceFile.extension.ifEmpty { "jpg" }
val filename = "${UUID.randomUUID()}.$extension"
val destFile = File(getImagesDirectory(context), filename)
sourceFile.copyTo(destFile, overwrite = true)
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/** Gets all image files in the images directory */
fun getAllImages(context: Context): List<String> {
return try {
val imagesDir = getImagesDirectory(context)
imagesDir.listFiles()?.mapNotNull { file ->
if (file.isFile &&
(file.extension == "jpg" ||
file.extension == "jpeg" ||
file.extension == "png")
) {
"$IMAGES_DIR/${file.name}"
} else null
}
?: emptyList()
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
/** 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()
null
}
}
/** Saves image data with a specific filename */
fun saveImageFromBytesWithFilename(
context: Context,
imageData: ByteArray,
filename: String
): String? {
return try {
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap)
// Use the provided filename instead of generating a new UUID
val imageFile = File(getImagesDirectory(context), filename)
// Save compressed image
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Clean up bitmaps
bitmap.recycle()
compressedBitmap.recycle()
// Return relative path
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
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 {
val allImages = getAllImages(context)
val orphanedImages = allImages.filter { it !in referencedPaths }
orphanedImages.forEach { path -> deleteImage(context, path) }
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -0,0 +1,33 @@
package com.atridad.openclimb.utils
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
object NotificationPermissionUtils {
/**
* Check if notification permission is granted
*/
fun isNotificationPermissionGranted(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
/**
* Check if notification permission should be requested
*/
fun shouldRequestNotificationPermission(): Boolean {
return true
}
/**
* Get the notification permission string
*/
fun getNotificationPermissionString(): String {
return Manifest.permission.POST_NOTIFICATIONS
}
}

View File

@@ -0,0 +1,543 @@
package com.atridad.openclimb.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.openclimb.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("OpenClimb", 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! #OpenClimb")
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

@@ -0,0 +1,110 @@
package com.atridad.openclimb.utils
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.drawable.Icon
import android.os.Build
import androidx.annotation.RequiresApi
import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R
object AppShortcutManager {
const val SHORTCUT_START_SESSION = "start_session"
const val SHORTCUT_END_SESSION = "end_session"
const val ACTION_START_SESSION = "com.atridad.openclimb.action.START_SESSION"
const val ACTION_END_SESSION = "com.atridad.openclimb.action.END_SESSION"
/** Updates the app shortcuts based on current session state */
fun updateShortcuts(
context: Context,
hasActiveSession: Boolean,
hasGyms: Boolean,
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
val shortcuts = mutableListOf<ShortcutInfo>()
if (hasActiveSession) {
// Show "End Session" shortcut when there's an active session
shortcuts.add(createEndSessionShortcut(context))
} else if (hasGyms) {
// Show "Start Session" shortcut when no active session but gyms exist
shortcuts.add(createStartSessionShortcut(context, lastUsedGym))
}
shortcutManager.dynamicShortcuts = shortcuts
}
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun createStartSessionShortcut(
context: Context,
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
): ShortcutInfo {
val startIntent =
Intent(context, MainActivity::class.java).apply {
action = ACTION_START_SESSION
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
lastUsedGym?.let { gym -> putExtra("LAST_USED_GYM_ID", gym.id) }
}
val shortLabel =
if (lastUsedGym != null) {
"Start at ${lastUsedGym.name}"
} else {
context.getString(R.string.shortcut_start_session_short)
}
val longLabel =
if (lastUsedGym != null) {
"Start a new climbing session at ${lastUsedGym.name}"
} else {
context.getString(R.string.shortcut_start_session_long)
}
return ShortcutInfo.Builder(context, SHORTCUT_START_SESSION)
.setShortLabel(shortLabel)
.setLongLabel(longLabel)
.setIcon(Icon.createWithResource(context, R.drawable.ic_play_arrow_24))
.setIntent(startIntent)
.build()
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun createEndSessionShortcut(context: Context): ShortcutInfo {
val endIntent =
Intent(context, MainActivity::class.java).apply {
action = ACTION_END_SESSION
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
return ShortcutInfo.Builder(context, SHORTCUT_END_SESSION)
.setShortLabel(context.getString(R.string.shortcut_end_session_short))
.setLongLabel(context.getString(R.string.shortcut_end_session_long))
.setIcon(Icon.createWithResource(context, R.drawable.ic_stop_24))
.setIntent(endIntent)
.build()
}
/** Removes all dynamic shortcuts */
fun clearShortcuts(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
shortcutManager.removeAllDynamicShortcuts()
}
}
/** Disables a specific shortcut and shows a disabled message */
fun disableShortcut(context: Context, shortcutId: String, disabledMessage: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
shortcutManager.disableShortcuts(listOf(shortcutId), disabledMessage)
}
}
}

View File

@@ -0,0 +1,328 @@
package com.atridad.openclimb.utils
import android.content.Context
import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.time.LocalDateTime
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object ZipExportImportUtils {
private const val DATA_JSON_FILENAME = "data.json"
private const val IMAGES_DIR_NAME = "images"
private const val METADATA_FILENAME = "metadata.txt"
/** Creates a ZIP file containing the JSON data and all referenced images */
fun createExportZip(
context: Context,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>,
directory: File? = null
): File {
val exportDir =
directory
?: File(
context.getExternalFilesDir(
android.os.Environment.DIRECTORY_DOCUMENTS
),
"OpenClimb"
)
if (!exportDir.exists()) {
exportDir.mkdirs()
}
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
val zipFile = File(exportDir, "openclimb_export_$timestamp.zip")
try {
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
// Add metadata file first
val metadata = createMetadata(exportData, referencedImagePaths)
val metadataEntry = ZipEntry(METADATA_FILENAME)
zipOut.putNextEntry(metadataEntry)
zipOut.write(metadata.toByteArray())
zipOut.closeEntry()
// Add JSON data file
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
zipOut.putNextEntry(jsonEntry)
zipOut.write(jsonString.toByteArray())
zipOut.closeEntry()
// Add images with validation
var successfulImages = 0
referencedImagePaths.forEach { imagePath ->
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (imageFile.exists() && imageFile.length() > 0) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry)
FileInputStream(imageFile).use { imageInput ->
imageInput.copyTo(zipOut)
}
zipOut.closeEntry()
successfulImages++
} else {
android.util.Log.w(
"ZipExportImportUtils",
"Image file not found or empty: $imagePath"
)
}
} catch (e: Exception) {
android.util.Log.e(
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}"
)
}
}
// Log export summary
android.util.Log.i(
"ZipExportImportUtils",
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
)
}
// Validate the created ZIP file
if (!zipFile.exists() || zipFile.length() == 0L) {
throw IOException("Failed to create ZIP file: file is empty or doesn't exist")
}
return zipFile
} catch (e: Exception) {
// Clean up failed export
if (zipFile.exists()) {
zipFile.delete()
}
throw IOException("Failed to create export ZIP: ${e.message}")
}
}
/** Creates a ZIP file and writes it to a provided URI */
fun createExportZipToUri(
context: Context,
uri: android.net.Uri,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
ZipOutputStream(outputStream).use { zipOut ->
// Add metadata file first
val metadata = createMetadata(exportData, referencedImagePaths)
val metadataEntry = ZipEntry(METADATA_FILENAME)
zipOut.putNextEntry(metadataEntry)
zipOut.write(metadata.toByteArray())
zipOut.closeEntry()
// Add JSON data file
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
zipOut.putNextEntry(jsonEntry)
zipOut.write(jsonString.toByteArray())
zipOut.closeEntry()
// Add images with validation
var successfulImages = 0
referencedImagePaths.forEach { imagePath ->
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (imageFile.exists() && imageFile.length() > 0) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry)
FileInputStream(imageFile).use { imageInput ->
imageInput.copyTo(zipOut)
}
zipOut.closeEntry()
successfulImages++
}
} catch (e: Exception) {
android.util.Log.e(
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}"
)
}
}
android.util.Log.i(
"ZipExportImportUtils",
"Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included"
)
}
}
?: 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>
): String {
return buildString {
appendLine("OpenClimb Export Metadata")
appendLine("=======================")
appendLine("Export Date: ${exportData.exportedAt}")
appendLine("Version: ${exportData.version}")
appendLine("Gyms: ${exportData.gyms.size}")
appendLine("Problems: ${exportData.problems.size}")
appendLine("Sessions: ${exportData.sessions.size}")
appendLine("Attempts: ${exportData.attempts.size}")
appendLine("Referenced Images: ${referencedImagePaths.size}")
appendLine("Format: ZIP with embedded JSON data and images")
}
}
/** Data class to hold extraction results */
data class ImportResult(
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 */
fun extractImportZip(context: Context, zipFile: File): ImportResult {
var jsonContent = ""
val importedImagePaths = mutableMapOf<String, String>()
var foundRequiredFiles = mutableSetOf<String>()
try {
ZipInputStream(FileInputStream(zipFile)).use { zipIn ->
var entry = zipIn.nextEntry
while (entry != null) {
when {
entry.name == METADATA_FILENAME -> {
// 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()}"
)
}
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/")
try {
// Create temporary file to hold the extracted image
val tempFile =
File.createTempFile(
"import_image_",
"_$originalFilename",
context.cacheDir
)
FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) }
// Validate the extracted image
if (tempFile.exists() && tempFile.length() > 0) {
// Import the image to permanent storage
val newPath = ImageUtils.importImageFile(context, tempFile)
if (newPath != null) {
importedImagePaths[originalFilename] = newPath
android.util.Log.d(
"ZipExportImportUtils",
"Successfully imported image: $originalFilename -> $newPath"
)
} else {
android.util.Log.w(
"ZipExportImportUtils",
"Failed to import image: $originalFilename"
)
}
} else {
android.util.Log.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}"
)
}
}
else -> {
android.util.Log.d(
"ZipExportImportUtils",
"Skipping ZIP entry: ${entry.name}"
)
}
}
zipIn.closeEntry()
entry = zipIn.nextEntry
}
}
// Validate that we found the required files
if (!foundRequiredFiles.contains("data")) {
throw IOException("Invalid ZIP file: data.json not found")
}
if (jsonContent.isBlank()) {
throw IOException("Invalid ZIP file: data.json is empty")
}
android.util.Log.i(
"ZipExportImportUtils",
"Import extraction completed: ${importedImagePaths.size} images processed"
)
return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) {
throw IOException("Failed to extract import ZIP: ${e.message}")
}
}
/**
* Updates image paths in a problem list after import This function maps the old image paths to
* the new ones after import
*/
fun updateProblemImagePaths(
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.withUpdatedImagePaths(updatedImagePaths)
}
}
}

View File

@@ -0,0 +1,150 @@
package com.atridad.openclimb.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class ClimbStatsWidgetProvider : AppWidgetProvider() {
private val job = SupervisorJob()
private val coroutineScope = CoroutineScope(Dispatchers.IO + job)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
override fun onEnabled(context: Context) {}
override fun onDisabled(context: Context) {
job.cancel()
}
private fun updateAppWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
coroutineScope.launch {
try {
val database = OpenClimbDatabase.getDatabase(context)
val repository = ClimbRepository(database, context)
// Fetch stats data
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.openclimb.data.model
.AttemptResult.SUCCESS ||
attempt.result ==
com.atridad.openclimb.data.model
.AttemptResult.FLASH)
}
}
.size
val favoriteGym =
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
(gymId, _) ->
gyms.find { it.id == gymId }?.name
}
?: "No sessions yet"
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)
val intent = Intent(context, MainActivity::class.java)
val pendingIntent =
PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
} catch (e: 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")
val intent = Intent(context, MainActivity::class.java)
val pendingIntent =
PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
}
}
companion object {
fun updateAllWidgets(context: Context) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, ClimbStatsWidgetProvider::class.java)
val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
val intent =
Intent(context, ClimbStatsWidgetProvider::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
}
context.sendBroadcast(intent)
}
}
}

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="#81C784"
android:pathData="M8,5v14l11,-7z"/>
</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="#EF5350"
android:pathData="M6,6h12v12H6z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<?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">
<!-- Clean white background -->
<path android:fillColor="#FFFFFF"
android:pathData="M0,0h108v108h-108z"/>
</vector>

View File

@@ -0,0 +1,30 @@
<?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">
<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>
</vector>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
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>

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="#4CAF50"
android:pathData="M8,5v14l11,-7z"/>
</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="#F44336"
android:pathData="M6,6h12v12H6z"/>
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_background" />
<corners android:radius="24dp" />
<stroke
android:width="1dp"
android:color="@color/widget_outline" />
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_surface" />
<corners android:radius="16dp" />
<stroke
android:width="0.5dp"
android:color="@color/widget_outline" />
</shape>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_accent" />
<corners android:radius="12dp" />
<stroke
android:width="0.5dp"
android:color="@color/widget_text_primary" />
<padding
android:left="6dp"
android:top="3dp"
android:right="6dp"
android:bottom="3dp" />
</shape>

View File

@@ -0,0 +1,195 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:padding="12dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:layout_marginBottom="12dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
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="OpenClimb"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Climbing Stats"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary" />
</LinearLayout>
<!-- Stats Grid -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<!-- Top Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<!-- 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_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" />
<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" />
</LinearLayout>
<!-- 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>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Splash background (dark) -->
<color name="splash_background">#FF121212</color>
<!-- Widget colors (dark theme) -->
<color name="widget_background">#FF1E1E1E</color>
<color name="widget_surface">#FF2D2D2D</color>
<color name="widget_outline">#FF404040</color>
<color name="widget_primary">#FF90CAF9</color>
<color name="widget_secondary">#FFA5D6A7</color>
<color name="widget_accent">#FFFF8A65</color>
<color name="widget_text_primary">#FFFFFFFF</color>
<color name="widget_text_secondary">#FFBDBDBD</color>
</resources>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Splash background (light) -->
<color name="splash_background">#FFFFFFFF</color>
<!-- Widget colors (light theme) -->
<color name="widget_background">#FFFFFFFF</color>
<color name="widget_surface">#FFF8F9FA</color>
<color name="widget_outline">#FFE0E0E0</color>
<color name="widget_primary">#FF1976D2</color>
<color name="widget_secondary">#FF388E3C</color>
<color name="widget_accent">#FFFF5722</color>
<color name="widget_text_primary">#FF212121</color>
<color name="widget_text_secondary">#FF757575</color>
</resources>

View File

@@ -0,0 +1,16 @@
<resources>
<string name="app_name">OpenClimb</string>
<string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string>
<!-- App Shortcuts -->
<string name="shortcut_start_session_short">Start Session</string>
<string name="shortcut_start_session_long">Start a new climbing session</string>
<string name="shortcut_start_session_disabled">No gyms available to start session</string>
<string name="shortcut_end_session_short">End Session</string>
<string name="shortcut_end_session_long">End current climbing session</string>
<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>
</resources>

View File

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

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="share_images" path="shares/" />
<external-files-path name="external_files" path="." />
</paths>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialKeyguardLayout="@layout/widget_climb_stats"
android:initialLayout="@layout/widget_climb_stats"
android:minWidth="250dp"
android:minHeight="180dp"
android:previewImage="@drawable/ic_mountains"
android:previewLayout="@layout/widget_climb_stats"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable"
android:maxResizeWidth="320dp"
android:maxResizeHeight="240dp" />

View File

@@ -0,0 +1,603 @@
package com.atridad.openclimb
import com.atridad.openclimb.data.format.*
import com.atridad.openclimb.data.model.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import org.junit.Assert.*
import org.junit.Test
/**
* Business logic and integration tests for OpenClimb Android app. Tests complex workflows, business
* rules, and data relationships.
*/
class BusinessLogicTests {
@Test
fun testClimbSessionLifecycle() {
val gym = createTestGym()
val session = ClimbSession.create(gym.id, "Test session notes")
assertEquals(gym.id, session.gymId)
assertEquals(SessionStatus.ACTIVE, session.status)
assertNotNull(session.startTime)
assertNull(session.endTime)
assertNull(session.duration)
val completedSession =
session.copy(
status = SessionStatus.COMPLETED,
endTime = getCurrentTimestamp(),
duration = 7200L
)
assertEquals(SessionStatus.COMPLETED, completedSession.status)
assertNotNull(completedSession.endTime)
assertNotNull(completedSession.duration)
}
@Test
fun testAttemptCreationAndValidation() {
val gym = createTestGym()
val problem = createTestProblem(gym.id)
val session = ClimbSession.create(gym.id)
val attempt =
Attempt.create(
sessionId = session.id,
problemId = problem.id,
result = AttemptResult.SUCCESS,
notes = "Clean send!"
)
assertEquals(session.id, attempt.sessionId)
assertEquals(problem.id, attempt.problemId)
assertEquals(AttemptResult.SUCCESS, attempt.result)
assertEquals("Clean send!", attempt.notes)
assertNotNull(attempt.timestamp)
assertNotNull(attempt.createdAt)
}
@Test
fun testGymProblemRelationship() {
val gym = createTestGym()
val boulderProblem = createTestProblem(gym.id, ClimbType.BOULDER)
val ropeProblem = createTestProblem(gym.id, ClimbType.ROPE)
// Verify boulder problem uses compatible difficulty system
assertTrue(gym.supportedClimbTypes.contains(boulderProblem.climbType))
assertTrue(gym.difficultySystems.contains(boulderProblem.difficulty.system))
// Verify rope problem uses compatible difficulty system
assertTrue(gym.supportedClimbTypes.contains(ropeProblem.climbType))
assertTrue(gym.difficultySystems.contains(ropeProblem.difficulty.system))
}
@Test
fun testSessionAttemptAggregation() {
val gym = createTestGym()
val session = ClimbSession.create(gym.id)
val problem1 = createTestProblem(gym.id)
val problem2 = createTestProblem(gym.id)
val attempts =
listOf(
Attempt.create(session.id, problem1.id, AttemptResult.SUCCESS),
Attempt.create(session.id, problem1.id, AttemptResult.FALL),
Attempt.create(session.id, problem2.id, AttemptResult.FLASH),
Attempt.create(session.id, problem2.id, AttemptResult.SUCCESS)
)
val sessionStats = calculateSessionStatistics(session, attempts)
assertEquals(4, sessionStats.totalAttempts)
assertEquals(3, sessionStats.successfulAttempts)
assertEquals(2, sessionStats.uniqueProblems)
assertEquals(75.0, sessionStats.successRate, 0.01)
}
@Test
fun testDifficultyProgressionTracking() {
val gym = createTestGym()
val session = ClimbSession.create(gym.id)
val problems =
listOf(
createTestProblemWithGrade(gym.id, "V3"),
createTestProblemWithGrade(gym.id, "V4"),
createTestProblemWithGrade(gym.id, "V5"),
createTestProblemWithGrade(gym.id, "V6")
)
val attempts =
problems.map { problem ->
Attempt.create(session.id, problem.id, AttemptResult.SUCCESS)
}
val progression = calculateDifficultyProgression(attempts, problems)
assertEquals("V3", progression.minGrade)
assertEquals("V6", progression.maxGrade)
assertEquals(4.5, progression.averageGrade, 0.1)
assertTrue(progression.showsProgression)
}
@Test
fun testBackupDataIntegrity() {
val gym = createTestGym()
val problems = listOf(createTestProblem(gym.id), createTestProblem(gym.id))
val session = ClimbSession.create(gym.id)
val attempts =
problems.map { problem ->
Attempt.create(session.id, problem.id, AttemptResult.SUCCESS)
}
val backup =
createBackupData(
gyms = listOf(gym),
problems = problems,
sessions = listOf(session),
attempts = attempts
)
validateBackupIntegrity(backup)
assertEquals(1, backup.gyms.size)
assertEquals(2, backup.problems.size)
assertEquals(1, backup.sessions.size)
assertEquals(2, backup.attempts.size)
}
@Test
fun testClimbTypeCompatibilityRules() {
val boulderGym =
Gym(
id = "boulder_gym",
name = "Boulder Gym",
location = "Boulder City",
supportedClimbTypes = listOf(ClimbType.BOULDER),
difficultySystems = listOf(DifficultySystem.V_SCALE, DifficultySystem.FONT),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = getCurrentTimestamp(),
updatedAt = getCurrentTimestamp()
)
val ropeGym =
Gym(
id = "rope_gym",
name = "Rope Gym",
location = "Rope City",
supportedClimbTypes = listOf(ClimbType.ROPE),
difficultySystems = listOf(DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = getCurrentTimestamp(),
updatedAt = getCurrentTimestamp()
)
// Boulder gym should support boulder problems with V-Scale
assertTrue(isCompatibleClimbType(boulderGym, ClimbType.BOULDER, DifficultySystem.V_SCALE))
assertTrue(isCompatibleClimbType(boulderGym, ClimbType.BOULDER, DifficultySystem.FONT))
assertFalse(isCompatibleClimbType(boulderGym, ClimbType.ROPE, DifficultySystem.YDS))
// Rope gym should support rope problems with YDS
assertTrue(isCompatibleClimbType(ropeGym, ClimbType.ROPE, DifficultySystem.YDS))
assertFalse(isCompatibleClimbType(ropeGym, ClimbType.BOULDER, DifficultySystem.V_SCALE))
}
@Test
fun testSessionDurationCalculation() {
val startTime = "2024-01-01T10:00:00Z"
val endTime = "2024-01-01T12:30:00Z"
val calculatedDuration = calculateSessionDuration(startTime, endTime)
assertEquals(9000L, calculatedDuration) // 2.5 hours = 9000 seconds
}
@Test
fun testAttemptSequenceValidation() {
val gym = createTestGym()
val problem = createTestProblem(gym.id)
val session = ClimbSession.create(gym.id)
val attempts =
listOf(
createAttemptWithTimestamp(
session.id,
problem.id,
"2024-01-01T10:00:00Z",
AttemptResult.FALL
),
createAttemptWithTimestamp(
session.id,
problem.id,
"2024-01-01T10:05:00Z",
AttemptResult.FALL
),
createAttemptWithTimestamp(
session.id,
problem.id,
"2024-01-01T10:10:00Z",
AttemptResult.SUCCESS
)
)
val sequence = AttemptSequence(attempts)
assertEquals(3, sequence.totalAttempts)
assertEquals(2, sequence.failedAttempts)
assertEquals(1, sequence.successfulAttempts)
assertTrue(sequence.isValidSequence())
assertEquals(AttemptResult.SUCCESS, sequence.finalResult)
}
@Test
fun testGradeConsistencyValidation() {
val validCombinations =
listOf(
Pair(ClimbType.BOULDER, DifficultySystem.V_SCALE),
Pair(ClimbType.BOULDER, DifficultySystem.FONT),
Pair(ClimbType.ROPE, DifficultySystem.YDS),
Pair(ClimbType.BOULDER, DifficultySystem.CUSTOM),
Pair(ClimbType.ROPE, DifficultySystem.CUSTOM)
)
val invalidCombinations =
listOf(
Pair(ClimbType.BOULDER, DifficultySystem.YDS),
Pair(ClimbType.ROPE, DifficultySystem.V_SCALE),
Pair(ClimbType.ROPE, DifficultySystem.FONT)
)
validCombinations.forEach { (climbType, difficultySystem) ->
assertTrue(
"$climbType should be compatible with $difficultySystem",
isValidGradeCombination(climbType, difficultySystem)
)
}
invalidCombinations.forEach { (climbType, difficultySystem) ->
assertFalse(
"$climbType should not be compatible with $difficultySystem",
isValidGradeCombination(climbType, difficultySystem)
)
}
}
@Test
fun testProblemTagNormalization() {
val rawTags = listOf("OVERHANG", "crimpy", " Technical ", "DYNAMIC", "")
val normalizedTags = normalizeTags(rawTags)
assertEquals(4, normalizedTags.size)
assertTrue(normalizedTags.contains("overhang"))
assertTrue(normalizedTags.contains("crimpy"))
assertTrue(normalizedTags.contains("technical"))
assertTrue(normalizedTags.contains("dynamic"))
assertFalse(normalizedTags.contains(""))
}
@Test
fun testImagePathHandling() {
val originalPaths =
listOf(
"/storage/images/problem1.jpg",
"/data/cache/problem2.png",
"relative/path/problem3.jpeg"
)
val relativePaths = convertToRelativePaths(originalPaths)
assertEquals(3, relativePaths.size)
assertTrue(relativePaths.all { !it.startsWith("/") })
assertTrue(relativePaths.contains("problem1.jpg"))
assertTrue(relativePaths.contains("problem2.png"))
assertTrue(relativePaths.contains("problem3.jpeg"))
}
// Helper functions and data classes
private fun createTestGym(): Gym {
return Gym(
id = "test_gym_1",
name = "Test Climbing Gym",
location = "Test City",
supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.ROPE),
difficultySystems = listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
notes = "Test gym for unit testing",
createdAt = getCurrentTimestamp(),
updatedAt = getCurrentTimestamp()
)
}
private fun createTestProblem(
gymId: String,
climbType: ClimbType = ClimbType.BOULDER
): Problem {
val difficulty =
when (climbType) {
ClimbType.BOULDER -> DifficultyGrade(DifficultySystem.V_SCALE, "V5")
ClimbType.ROPE -> DifficultyGrade(DifficultySystem.YDS, "5.10a")
}
return Problem(
id = "test_problem_${java.util.UUID.randomUUID()}",
gymId = gymId,
name = "Test Problem",
description = "A test climbing problem",
climbType = climbType,
difficulty = difficulty,
tags = listOf("test", "overhang"),
location = "Wall A",
imagePaths = emptyList(),
isActive = true,
dateSet = "2024-01-01",
notes = null,
createdAt = getCurrentTimestamp(),
updatedAt = getCurrentTimestamp()
)
}
private fun createTestProblemWithGrade(gymId: String, grade: String): Problem {
return Problem(
id = "test_problem_${java.util.UUID.randomUUID()}",
gymId = gymId,
name = "Test Problem $grade",
description = null,
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, grade),
tags = emptyList(),
location = null,
imagePaths = emptyList(),
isActive = true,
dateSet = null,
notes = null,
createdAt = getCurrentTimestamp(),
updatedAt = getCurrentTimestamp()
)
}
private fun createAttemptWithTimestamp(
sessionId: String,
problemId: String,
timestamp: String,
result: AttemptResult
): Attempt {
return Attempt.create(
sessionId = sessionId,
problemId = problemId,
result = result,
timestamp = timestamp
)
}
private fun getCurrentTimestamp(): String {
return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + "Z"
}
private fun calculateSessionStatistics(
session: ClimbSession,
attempts: List<Attempt>
): SessionStatistics {
val successful =
attempts.count {
it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH
}
val uniqueProblems = attempts.map { it.problemId }.toSet().size
val successRate = (successful.toDouble() / attempts.size) * 100
return SessionStatistics(
totalAttempts = attempts.size,
successfulAttempts = successful,
uniqueProblems = uniqueProblems,
successRate = successRate
)
}
private fun calculateDifficultyProgression(
attempts: List<Attempt>,
problems: List<Problem>
): DifficultyProgression {
val problemMap = problems.associateBy { it.id }
val grades =
attempts
.mapNotNull { attempt -> problemMap[attempt.problemId]?.difficulty?.grade }
.filter { it.startsWith("V") }
val numericGrades =
grades.mapNotNull { grade ->
when (grade) {
"VB" -> 0
else -> grade.removePrefix("V").toIntOrNull()
}
}
val minGrade = "V${numericGrades.minOrNull() ?: 0}".replace("V0", "VB")
val maxGrade = "V${numericGrades.maxOrNull() ?: 0}".replace("V0", "VB")
val avgGrade = numericGrades.average()
val showsProgression =
numericGrades.size > 1 &&
(numericGrades.maxOrNull() ?: 0) > (numericGrades.minOrNull() ?: 0)
return DifficultyProgression(minGrade, maxGrade, avgGrade, showsProgression)
}
private fun createBackupData(
gyms: List<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
): ClimbDataBackup {
return ClimbDataBackup(
exportedAt = getCurrentTimestamp(),
version = "2.0",
formatVersion = "2.0",
gyms =
gyms.map { gym ->
BackupGym(
id = gym.id,
name = gym.name,
location = gym.location,
supportedClimbTypes = gym.supportedClimbTypes,
difficultySystems = gym.difficultySystems,
customDifficultyGrades = gym.customDifficultyGrades,
notes = gym.notes,
createdAt = gym.createdAt,
updatedAt = gym.updatedAt
)
},
problems =
problems.map { problem ->
BackupProblem(
id = problem.id,
gymId = problem.gymId,
name = problem.name,
description = problem.description,
climbType = problem.climbType,
difficulty = problem.difficulty,
tags = problem.tags,
location = problem.location,
imagePaths = problem.imagePaths,
isActive = problem.isActive,
dateSet = problem.dateSet,
notes = problem.notes,
createdAt = problem.createdAt,
updatedAt = problem.updatedAt
)
},
sessions =
sessions.map { session ->
BackupClimbSession(
id = session.id,
gymId = session.gymId,
date = session.date,
startTime = session.startTime,
endTime = session.endTime,
duration = session.duration,
status = session.status,
notes = session.notes,
createdAt = session.createdAt,
updatedAt = session.updatedAt
)
},
attempts =
attempts.map { attempt ->
BackupAttempt(
id = attempt.id,
sessionId = attempt.sessionId,
problemId = attempt.problemId,
result = attempt.result,
highestHold = attempt.highestHold,
notes = attempt.notes,
duration = attempt.duration,
restTime = attempt.restTime,
timestamp = attempt.timestamp,
createdAt = attempt.createdAt
)
}
)
}
private fun validateBackupIntegrity(backup: ClimbDataBackup) {
// Verify all gym references exist
val gymIds = backup.gyms.map { it.id }.toSet()
backup.problems.forEach { problem ->
assertTrue(
"Problem ${problem.id} references non-existent gym ${problem.gymId}",
gymIds.contains(problem.gymId)
)
}
// Verify all session references exist
val sessionIds = backup.sessions.map { it.id }.toSet()
backup.attempts.forEach { attempt ->
assertTrue(
"Attempt ${attempt.id} references non-existent session ${attempt.sessionId}",
sessionIds.contains(attempt.sessionId)
)
}
// Verify all problem references exist
val problemIds = backup.problems.map { it.id }.toSet()
backup.attempts.forEach { attempt ->
assertTrue(
"Attempt ${attempt.id} references non-existent problem ${attempt.problemId}",
problemIds.contains(attempt.problemId)
)
}
}
private fun isCompatibleClimbType(
gym: Gym,
climbType: ClimbType,
difficultySystem: DifficultySystem
): Boolean {
return gym.supportedClimbTypes.contains(climbType) &&
gym.difficultySystems.contains(difficultySystem)
}
private fun calculateSessionDuration(startTime: String, endTime: String): Long {
// Simplified duration calculation (in seconds)
// In real implementation, would use proper date parsing
return 9000L // 2.5 hours for test
}
private fun isValidGradeCombination(
climbType: ClimbType,
difficultySystem: DifficultySystem
): Boolean {
return when (climbType) {
ClimbType.BOULDER ->
difficultySystem in
listOf(
DifficultySystem.V_SCALE,
DifficultySystem.FONT,
DifficultySystem.CUSTOM
)
ClimbType.ROPE ->
difficultySystem in listOf(DifficultySystem.YDS, DifficultySystem.CUSTOM)
}
}
private fun normalizeTags(tags: List<String>): List<String> {
return tags.map { it.trim().lowercase() }.filter { it.isNotEmpty() }
}
private fun convertToRelativePaths(paths: List<String>): List<String> {
return paths.map { path -> path.substringAfterLast('/') }
}
// Data classes for testing
data class SessionStatistics(
val totalAttempts: Int,
val successfulAttempts: Int,
val uniqueProblems: Int,
val successRate: Double
)
data class DifficultyProgression(
val minGrade: String,
val maxGrade: String,
val averageGrade: Double,
val showsProgression: Boolean
)
data class AttemptSequence(val attempts: List<Attempt>) {
val totalAttempts = attempts.size
val failedAttempts =
attempts.count {
it.result == AttemptResult.FALL || it.result == AttemptResult.NO_PROGRESS
}
val successfulAttempts =
attempts.count {
it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH
}
val finalResult = attempts.lastOrNull()?.result
fun isValidSequence(): Boolean {
return attempts.isNotEmpty() && attempts.all { it.timestamp.isNotEmpty() }
}
}
}

View File

@@ -0,0 +1,575 @@
package com.atridad.openclimb
import com.atridad.openclimb.data.format.*
import com.atridad.openclimb.data.model.*
import java.time.Instant
import java.time.format.DateTimeFormatter
import org.junit.Assert.*
import org.junit.Test
/**
* Comprehensive unit tests for OpenClimb Android data models and utilities. These tests verify core
* functionality without requiring Android context.
*/
class DataModelTests {
@Test
fun testClimbTypeEnumValues() {
val expectedTypes = setOf("ROPE", "BOULDER")
val actualTypes = ClimbType.entries.map { it.name }.toSet()
assertEquals(expectedTypes, actualTypes)
}
@Test
fun testClimbTypeDisplayNames() {
assertEquals("Rope", ClimbType.ROPE.getDisplayName())
assertEquals("Bouldering", ClimbType.BOULDER.getDisplayName())
}
@Test
fun testDifficultySystemEnumValues() {
val systems = DifficultySystem.entries
assertTrue(systems.contains(DifficultySystem.V_SCALE))
assertTrue(systems.contains(DifficultySystem.YDS))
assertTrue(systems.contains(DifficultySystem.FONT))
assertTrue(systems.contains(DifficultySystem.CUSTOM))
assertEquals(4, systems.size)
}
@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())
}
@Test
fun testDifficultySystemClimbTypeCompatibility() {
// Test bouldering systems
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())
}
@Test
fun testDifficultySystemAvailableGrades() {
val vScaleGrades = DifficultySystem.V_SCALE.getAvailableGrades()
assertTrue(vScaleGrades.contains("VB"))
assertTrue(vScaleGrades.contains("V0"))
assertTrue(vScaleGrades.contains("V17"))
assertEquals("VB", vScaleGrades.first())
val ydsGrades = DifficultySystem.YDS.getAvailableGrades()
assertTrue(ydsGrades.contains("5.0"))
assertTrue(ydsGrades.contains("5.15d"))
assertTrue(ydsGrades.contains("5.10a"))
val fontGrades = DifficultySystem.FONT.getAvailableGrades()
assertTrue(fontGrades.contains("3"))
assertTrue(fontGrades.contains("8C+"))
assertTrue(fontGrades.contains("6A"))
val customGrades = DifficultySystem.CUSTOM.getAvailableGrades()
assertTrue(customGrades.isEmpty())
}
@Test
fun testDifficultySystemsForClimbType() {
val boulderSystems = DifficultySystem.getSystemsForClimbType(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)
assertTrue(ropeSystems.contains(DifficultySystem.YDS))
assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM))
assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE))
assertFalse(ropeSystems.contains(DifficultySystem.FONT))
}
@Test
fun testDifficultyGradeCreation() {
val grade = DifficultyGrade(DifficultySystem.V_SCALE, "V5")
assertEquals(DifficultySystem.V_SCALE, grade.system)
assertEquals("V5", grade.grade)
assertEquals(5, grade.numericValue)
}
@Test
fun testDifficultyGradeNumericValueCalculation() {
val vbGrade = DifficultyGrade(DifficultySystem.V_SCALE, "VB")
assertEquals(0, vbGrade.numericValue)
val v5Grade = DifficultyGrade(DifficultySystem.V_SCALE, "V5")
assertEquals(5, v5Grade.numericValue)
val ydsGrade = DifficultyGrade(DifficultySystem.YDS, "5.9")
assertTrue(ydsGrade.numericValue > 0)
}
@Test
fun testDifficultyGradeComparison() {
val v3 = DifficultyGrade(DifficultySystem.V_SCALE, "V3")
val v5 = DifficultyGrade(DifficultySystem.V_SCALE, "V5")
val vb = DifficultyGrade(DifficultySystem.V_SCALE, "VB")
assertTrue(v3.compareTo(v5) < 0) // V3 is easier than V5
assertTrue(v5.compareTo(v3) > 0) // V5 is harder than V3
assertTrue(vb.compareTo(v3) < 0) // VB is easier than V3
assertEquals(0, v3.compareTo(v3)) // Same grade
}
@Test
fun testAttemptResultEnumValues() {
val expectedResults = setOf("SUCCESS", "FALL", "NO_PROGRESS", "FLASH")
val actualResults = AttemptResult.entries.map { it.name }.toSet()
assertEquals(expectedResults, actualResults)
}
@Test
fun testSessionStatusEnumValues() {
val expectedStatuses = setOf("ACTIVE", "COMPLETED", "PAUSED")
val actualStatuses = SessionStatus.entries.map { it.name }.toSet()
assertEquals(expectedStatuses, actualStatuses)
}
@Test
fun testBackupGymCreationAndValidation() {
val gym =
BackupGym(
id = "gym123",
name = "Test Climbing Gym",
location = "Test City",
supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.ROPE),
difficultySystems = listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
notes = "Great gym for beginners",
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertEquals("gym123", gym.id)
assertEquals("Test Climbing Gym", gym.name)
assertEquals("Test City", gym.location)
assertEquals(2, gym.supportedClimbTypes.size)
assertTrue(gym.supportedClimbTypes.contains(ClimbType.BOULDER))
assertTrue(gym.supportedClimbTypes.contains(ClimbType.ROPE))
assertEquals(2, gym.difficultySystems.size)
assertTrue(gym.difficultySystems.contains(DifficultySystem.V_SCALE))
assertTrue(gym.difficultySystems.contains(DifficultySystem.YDS))
}
@Test
fun testBackupProblemCreationAndValidation() {
val problem =
BackupProblem(
id = "problem123",
gymId = "gym123",
name = "Test Problem",
description = "A challenging boulder problem",
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V5"),
tags = listOf("overhang", "crimpy"),
location = "Wall A",
imagePaths = listOf("image1.jpg", "image2.jpg"),
isActive = true,
dateSet = "2024-01-01",
notes = "Watch the start holds",
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertEquals("problem123", problem.id)
assertEquals("gym123", problem.gymId)
assertEquals("Test Problem", problem.name)
assertEquals(ClimbType.BOULDER, problem.climbType)
assertEquals("V5", problem.difficulty.grade)
assertTrue(problem.isActive)
assertEquals(2, problem.tags.size)
assertEquals(2, problem.imagePaths?.size ?: 0)
}
@Test
fun testBackupClimbSessionCreationAndValidation() {
val session =
BackupClimbSession(
id = "session123",
gymId = "gym123",
date = "2024-01-01",
startTime = "2024-01-01T10:00:00Z",
endTime = "2024-01-01T12:00:00Z",
duration = 7200,
status = SessionStatus.COMPLETED,
notes = "Great session today",
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T12:00:00Z"
)
assertEquals("session123", session.id)
assertEquals("gym123", session.gymId)
assertEquals("2024-01-01", session.date)
assertEquals(SessionStatus.COMPLETED, session.status)
assertEquals(7200L, session.duration)
}
@Test
fun testBackupAttemptCreationAndValidation() {
val attempt =
BackupAttempt(
id = "attempt123",
sessionId = "session123",
problemId = "problem123",
result = AttemptResult.SUCCESS,
highestHold = "Top",
notes = "Stuck it on second try",
duration = 300,
restTime = 120,
timestamp = "2024-01-01T10:30:00Z",
createdAt = "2024-01-01T10:30:00Z"
)
assertEquals("attempt123", attempt.id)
assertEquals("session123", attempt.sessionId)
assertEquals("problem123", attempt.problemId)
assertEquals(AttemptResult.SUCCESS, attempt.result)
assertEquals("Top", attempt.highestHold)
assertEquals(300L, attempt.duration)
assertEquals(120L, attempt.restTime)
}
@Test
fun testClimbDataBackupCreationAndValidation() {
val backup =
ClimbDataBackup(
exportedAt = "2024-01-01T10:00:00Z",
version = "2.0",
formatVersion = "2.0",
gyms = emptyList(),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
assertEquals("2.0", backup.version)
assertEquals("2.0", backup.formatVersion)
assertTrue(backup.gyms.isEmpty())
assertTrue(backup.problems.isEmpty())
assertTrue(backup.sessions.isEmpty())
assertTrue(backup.attempts.isEmpty())
}
@Test
fun testDateFormatValidation() {
val validDate = "2024-01-01T10:00:00Z"
val formatter = DateTimeFormatter.ISO_INSTANT
try {
val instant = Instant.from(formatter.parse(validDate))
assertNotNull(instant)
} catch (e: Exception) {
fail("Should not throw exception for valid date: $e")
}
}
@Test
fun testSessionDurationCalculation() {
val session =
BackupClimbSession(
id = "test",
gymId = "gym1",
date = "2024-01-01",
startTime = "2024-01-01T10:00:00Z",
endTime = "2024-01-01T12:00:00Z",
duration = 7200,
status = SessionStatus.COMPLETED,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T12:00:00Z"
)
assertEquals(7200L, session.duration)
val hours = session.duration!! / 3600
assertEquals(2L, hours)
}
@Test
fun testEmptyCollectionsHandling() {
val gym =
BackupGym(
id = "gym1",
name = "Test Gym",
location = null,
supportedClimbTypes = emptyList(),
difficultySystems = emptyList(),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertTrue(gym.supportedClimbTypes.isEmpty())
assertTrue(gym.difficultySystems.isEmpty())
assertTrue(gym.customDifficultyGrades.isEmpty())
assertNull(gym.location)
assertNull(gym.notes)
}
@Test
fun testNullableFieldsHandling() {
val problem =
BackupProblem(
id = "problem1",
gymId = "gym1",
name = null,
description = null,
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V1"),
tags = emptyList(),
location = null,
imagePaths = null,
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertNull(problem.name)
assertNull(problem.description)
assertNull(problem.location)
assertNull(problem.dateSet)
assertNull(problem.notes)
assertTrue(problem.tags.isEmpty())
assertNull(problem.imagePaths)
}
@Test
fun testUniqueIdGeneration() {
val id1 = java.util.UUID.randomUUID().toString()
val id2 = java.util.UUID.randomUUID().toString()
assertNotEquals(id1, id2)
assertEquals(36, id1.length)
assertTrue(id1.contains("-"))
}
@Test
fun testBackupDataFormatValidation() {
val testJson =
"""
{
"exportedAt": "2024-01-01T10:00:00Z",
"version": "2.0",
"formatVersion": "2.0",
"gyms": [],
"problems": [],
"sessions": [],
"attempts": []
}
""".trimIndent()
assertTrue(testJson.contains("exportedAt"))
assertTrue(testJson.contains("version"))
assertTrue(testJson.contains("gyms"))
assertTrue(testJson.contains("problems"))
assertTrue(testJson.contains("sessions"))
assertTrue(testJson.contains("attempts"))
}
@Test
fun testDateTimeFormatting() {
val currentTime = System.currentTimeMillis()
assertTrue(currentTime > 0)
val timeString = java.time.Instant.ofEpochMilli(currentTime).toString()
assertTrue(timeString.isNotEmpty())
assertTrue(timeString.contains("T"))
assertTrue(timeString.endsWith("Z"))
}
@Test
fun testClimbTypeAndDifficultySystemCompatibility() {
// Test that V_SCALE works with BOULDER
val boulderProblem =
BackupProblem(
id = "boulder1",
gymId = "gym1",
name = "Boulder Problem",
description = null,
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V3"),
tags = emptyList(),
location = null,
imagePaths = null,
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertEquals(ClimbType.BOULDER, boulderProblem.climbType)
assertEquals(DifficultySystem.V_SCALE, boulderProblem.difficulty.system)
// Test that YDS works with ROPE
val ropeProblem =
BackupProblem(
id = "rope1",
gymId = "gym1",
name = "Rope Problem",
description = null,
climbType = ClimbType.ROPE,
difficulty = DifficultyGrade(DifficultySystem.YDS, "5.10a"),
tags = emptyList(),
location = null,
imagePaths = null,
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertEquals(ClimbType.ROPE, ropeProblem.climbType)
assertEquals(DifficultySystem.YDS, ropeProblem.difficulty.system)
}
@Test
fun testStringOperations() {
val problemName = " Test Problem V5 "
val trimmedName = problemName.trim()
val uppercaseName = trimmedName.uppercase()
val lowercaseName = trimmedName.lowercase()
assertEquals("Test Problem V5", trimmedName)
assertEquals("TEST PROBLEM V5", uppercaseName)
assertEquals("test problem v5", lowercaseName)
val components = trimmedName.split(" ")
assertEquals(3, components.size)
assertEquals("V5", components.last())
}
@Test
fun testNumericOperations() {
val grades = listOf(3, 5, 7, 4, 6)
val sum = grades.sum()
val average = grades.average()
val maxGrade = grades.maxOrNull() ?: 0
val minGrade = grades.minOrNull() ?: 0
assertEquals(25, sum)
assertEquals(5.0, average, 0.01)
assertEquals(7, maxGrade)
assertEquals(3, minGrade)
}
@Test
fun testAttemptResultValidation() {
val validResults =
listOf(
AttemptResult.SUCCESS,
AttemptResult.FALL,
AttemptResult.NO_PROGRESS,
AttemptResult.FLASH
)
assertEquals(4, validResults.size)
assertTrue(validResults.contains(AttemptResult.SUCCESS))
assertTrue(validResults.contains(AttemptResult.FALL))
assertTrue(validResults.contains(AttemptResult.NO_PROGRESS))
assertTrue(validResults.contains(AttemptResult.FLASH))
}
@Test
fun testSessionStatusValidation() {
val validStatuses =
listOf(SessionStatus.ACTIVE, SessionStatus.COMPLETED, SessionStatus.PAUSED)
assertEquals(3, validStatuses.size)
assertTrue(validStatuses.contains(SessionStatus.ACTIVE))
assertTrue(validStatuses.contains(SessionStatus.COMPLETED))
assertTrue(validStatuses.contains(SessionStatus.PAUSED))
}
@Test
fun testClimbDataIntegrity() {
val gym =
BackupGym(
id = "gym1",
name = "Test Gym",
location = "Test City",
supportedClimbTypes = listOf(ClimbType.BOULDER),
difficultySystems = listOf(DifficultySystem.V_SCALE),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
val problem =
BackupProblem(
id = "problem1",
gymId = gym.id,
name = "Test Problem",
description = null,
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V3"),
tags = emptyList(),
location = null,
imagePaths = null,
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
val session =
BackupClimbSession(
id = "session1",
gymId = gym.id,
date = "2024-01-01",
startTime = "2024-01-01T10:00:00Z",
endTime = "2024-01-01T11:00:00Z",
duration = 3600,
status = SessionStatus.COMPLETED,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T11:00:00Z"
)
val attempt =
BackupAttempt(
id = "attempt1",
sessionId = session.id,
problemId = problem.id,
result = AttemptResult.SUCCESS,
highestHold = null,
notes = null,
duration = 120,
restTime = null,
timestamp = "2024-01-01T10:30:00Z",
createdAt = "2024-01-01T10:30:00Z"
)
// Verify referential integrity
assertEquals(gym.id, problem.gymId)
assertEquals(gym.id, session.gymId)
assertEquals(session.id, attempt.sessionId)
assertEquals(problem.id, attempt.problemId)
// Verify climb type compatibility
assertTrue(gym.supportedClimbTypes.contains(problem.climbType))
assertTrue(gym.difficultySystems.contains(problem.difficulty.system))
}
}

View File

@@ -0,0 +1,451 @@
package com.atridad.openclimb
import com.atridad.openclimb.data.format.*
import com.atridad.openclimb.data.model.*
import org.junit.Assert.*
import org.junit.Test
class SyncMergeLogicTest {
@Test
fun `test intelligent merge preserves all data`() {
// Create local data
val localGyms =
listOf(
BackupGym(
id = "gym1",
name = "Local Gym 1",
location = "Local Location",
supportedClimbTypes = listOf(ClimbType.BOULDER),
difficultySystems = listOf(DifficultySystem.V_SCALE),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T10:00:00"
)
)
val localProblems =
listOf(
BackupProblem(
id = "problem1",
gymId = "gym1",
name = "Local Problem",
description = "Local description",
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V5"),
tags = listOf("local"),
location = null,
imagePaths = listOf("local_image.jpg"),
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T10:00:00"
)
)
val localSessions =
listOf(
BackupClimbSession(
id = "session1",
gymId = "gym1",
date = "2024-01-01",
startTime = "2024-01-01T10:00:00",
endTime = "2024-01-01T12:00:00",
duration = 7200,
status = SessionStatus.COMPLETED,
notes = null,
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T10:00:00"
)
)
val localAttempts =
listOf(
BackupAttempt(
id = "attempt1",
sessionId = "session1",
problemId = "problem1",
result = AttemptResult.SUCCESS,
highestHold = null,
notes = null,
duration = 300,
restTime = null,
timestamp = "2024-01-01T10:30:00",
createdAt = "2024-01-01T10:30:00"
)
)
val localBackup =
ClimbDataBackup(
exportedAt = "2024-01-01T10:00:00",
version = "2.0",
formatVersion = "2.0",
gyms = localGyms,
problems = localProblems,
sessions = localSessions,
attempts = localAttempts
)
// Create server data with some overlapping and some unique data
val serverGyms =
listOf(
// Same gym but with newer update
BackupGym(
id = "gym1",
name = "Updated Gym 1",
location = "Updated Location",
supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.ROPE),
difficultySystems =
listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
notes = "Updated notes",
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T12:00:00" // Newer update
),
// Unique server gym
BackupGym(
id = "gym2",
name = "Server Gym 2",
location = "Server Location",
supportedClimbTypes = listOf(ClimbType.ROPE),
difficultySystems = listOf(DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = "2024-01-01T11:00:00",
updatedAt = "2024-01-01T11:00:00"
)
)
val serverProblems =
listOf(
// Same problem but with newer update and different images
BackupProblem(
id = "problem1",
gymId = "gym1",
name = "Updated Problem",
description = "Updated description",
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V5"),
tags = listOf("updated", "server"),
location = "Updated location",
imagePaths = listOf("server_image.jpg"),
isActive = true,
dateSet = "2024-01-01",
notes = "Updated notes",
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T11:00:00" // Newer update
),
// Unique server problem
BackupProblem(
id = "problem2",
gymId = "gym2",
name = "Server Problem",
description = "Server description",
climbType = ClimbType.ROPE,
difficulty = DifficultyGrade(DifficultySystem.YDS, "5.10a"),
tags = listOf("server"),
location = null,
imagePaths = null,
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T11:00:00",
updatedAt = "2024-01-01T11:00:00"
)
)
val serverSessions =
listOf(
// Unique server session
BackupClimbSession(
id = "session2",
gymId = "gym2",
date = "2024-01-02",
startTime = "2024-01-02T14:00:00",
endTime = "2024-01-02T16:00:00",
duration = 7200,
status = SessionStatus.COMPLETED,
notes = "Server session",
createdAt = "2024-01-02T14:00:00",
updatedAt = "2024-01-02T14:00:00"
)
)
val serverAttempts =
listOf(
// Unique server attempt
BackupAttempt(
id = "attempt2",
sessionId = "session2",
problemId = "problem2",
result = AttemptResult.FALL,
highestHold = "Last move",
notes = "Almost had it",
duration = 180,
restTime = 60,
timestamp = "2024-01-02T14:30:00",
createdAt = "2024-01-02T14:30:00"
)
)
val serverBackup =
ClimbDataBackup(
exportedAt = "2024-01-01T12:00:00",
version = "2.0",
formatVersion = "2.0",
gyms = serverGyms,
problems = serverProblems,
sessions = serverSessions,
attempts = serverAttempts
)
// Simulate merge logic
val mergedBackup = performIntelligentMerge(localBackup, serverBackup)
// Verify merge results
assertEquals("Should have 2 gyms (1 updated, 1 new)", 2, mergedBackup.gyms.size)
assertEquals("Should have 2 problems (1 updated, 1 new)", 2, mergedBackup.problems.size)
assertEquals("Should have 2 sessions (1 local, 1 server)", 2, mergedBackup.sessions.size)
assertEquals("Should have 2 attempts (1 local, 1 server)", 2, mergedBackup.attempts.size)
// Verify gym merge - server version should win (newer update)
val mergedGym1 = mergedBackup.gyms.find { it.id == "gym1" }!!
assertEquals("Updated Gym 1", mergedGym1.name)
assertEquals("Updated Location", mergedGym1.location)
assertEquals("Updated notes", mergedGym1.notes)
assertEquals("2024-01-01T12:00:00", mergedGym1.updatedAt)
// Verify unique server gym is preserved
val mergedGym2 = mergedBackup.gyms.find { it.id == "gym2" }!!
assertEquals("Server Gym 2", mergedGym2.name)
// Verify problem merge - server version should win but images should be merged
val mergedProblem1 = mergedBackup.problems.find { it.id == "problem1" }!!
assertEquals("Updated Problem", mergedProblem1.name)
assertEquals("Updated description", mergedProblem1.description)
assertEquals("2024-01-01T11:00:00", mergedProblem1.updatedAt)
// Images should be merged (both local and server images preserved)
assertTrue(
"Should contain local image",
mergedProblem1.imagePaths!!.contains("local_image.jpg")
)
assertTrue(
"Should contain server image",
mergedProblem1.imagePaths!!.contains("server_image.jpg")
)
assertEquals("Should have 2 images total", 2, mergedProblem1.imagePaths!!.size)
// Verify unique server problem is preserved
val mergedProblem2 = mergedBackup.problems.find { it.id == "problem2" }!!
assertEquals("Server Problem", mergedProblem2.name)
// Verify all sessions are preserved
assertTrue(
"Should contain local session",
mergedBackup.sessions.any { it.id == "session1" }
)
assertTrue(
"Should contain server session",
mergedBackup.sessions.any { it.id == "session2" }
)
// Verify all attempts are preserved
assertTrue(
"Should contain local attempt",
mergedBackup.attempts.any { it.id == "attempt1" }
)
assertTrue(
"Should contain server attempt",
mergedBackup.attempts.any { it.id == "attempt2" }
)
}
@Test
fun `test date comparison logic`() {
assertTrue(
"ISO instant should be newer",
isNewerThan("2024-01-01T12:00:00Z", "2024-01-01T10:00:00Z")
)
assertFalse(
"ISO instant should be older",
isNewerThan("2024-01-01T10:00:00Z", "2024-01-01T12:00:00Z")
)
assertTrue(
"String comparison should work as fallback",
isNewerThan("2024-01-02T10:00:00", "2024-01-01T10:00:00")
)
}
@Test
fun `test empty data scenarios`() {
val emptyBackup =
ClimbDataBackup(
exportedAt = "2024-01-01T10:00:00",
version = "2.0",
formatVersion = "2.0",
gyms = emptyList(),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
val dataBackup =
ClimbDataBackup(
exportedAt = "2024-01-01T10:00:00",
version = "2.0",
formatVersion = "2.0",
gyms =
listOf(
BackupGym(
id = "gym1",
name = "Test Gym",
location = null,
supportedClimbTypes = listOf(ClimbType.BOULDER),
difficultySystems =
listOf(DifficultySystem.V_SCALE),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T10:00:00"
)
),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
// Test merging empty with data
val merged1 = performIntelligentMerge(emptyBackup, dataBackup)
assertEquals("Should preserve data from non-empty backup", 1, merged1.gyms.size)
// Test merging data with empty
val merged2 = performIntelligentMerge(dataBackup, emptyBackup)
assertEquals("Should preserve data from non-empty backup", 1, merged2.gyms.size)
// Test merging empty with empty
val merged3 = performIntelligentMerge(emptyBackup, emptyBackup)
assertEquals("Should remain empty", 0, merged3.gyms.size)
}
// Helper methods that simulate the merge logic from SyncService
private fun performIntelligentMerge(
local: ClimbDataBackup,
server: ClimbDataBackup
): ClimbDataBackup {
val mergedGyms = mergeGyms(local.gyms, server.gyms)
val mergedProblems = mergeProblems(local.problems, server.problems)
val mergedSessions = mergeSessions(local.sessions, server.sessions)
val mergedAttempts = mergeAttempts(local.attempts, server.attempts)
return ClimbDataBackup(
exportedAt = "2024-01-01T12:00:00",
version = "2.0",
formatVersion = "2.0",
gyms = mergedGyms,
problems = mergedProblems,
sessions = mergedSessions,
attempts = mergedAttempts
)
}
private fun mergeGyms(local: List<BackupGym>, server: List<BackupGym>): List<BackupGym> {
val merged = mutableMapOf<String, BackupGym>()
// Add all local gyms
local.forEach { gym -> merged[gym.id] = gym }
// Add server gyms, preferring newer updates
server.forEach { serverGym ->
val localGym = merged[serverGym.id]
if (localGym == null || isNewerThan(serverGym.updatedAt, localGym.updatedAt)) {
merged[serverGym.id] = serverGym
}
}
return merged.values.toList()
}
private fun mergeProblems(
local: List<BackupProblem>,
server: List<BackupProblem>
): List<BackupProblem> {
val merged = mutableMapOf<String, BackupProblem>()
// Add all local problems
local.forEach { problem -> merged[problem.id] = problem }
// Add server problems, preferring newer updates
server.forEach { serverProblem ->
val localProblem = merged[serverProblem.id]
if (localProblem == null || isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
) {
// Merge image paths to preserve all images
val allImagePaths = mutableSetOf<String>()
localProblem?.imagePaths?.let { allImagePaths.addAll(it) }
serverProblem.imagePaths?.let { allImagePaths.addAll(it) }
merged[serverProblem.id] =
serverProblem.withUpdatedImagePaths(allImagePaths.toList())
}
}
return merged.values.toList()
}
private fun mergeSessions(
local: List<BackupClimbSession>,
server: List<BackupClimbSession>
): List<BackupClimbSession> {
val merged = mutableMapOf<String, BackupClimbSession>()
// Add all local sessions
local.forEach { session -> merged[session.id] = session }
// Add server sessions, preferring newer updates
server.forEach { serverSession ->
val localSession = merged[serverSession.id]
if (localSession == null || isNewerThan(serverSession.updatedAt, localSession.updatedAt)
) {
merged[serverSession.id] = serverSession
}
}
return merged.values.toList()
}
private fun mergeAttempts(
local: List<BackupAttempt>,
server: List<BackupAttempt>
): List<BackupAttempt> {
val merged = mutableMapOf<String, BackupAttempt>()
// Add all local attempts
local.forEach { attempt -> merged[attempt.id] = attempt }
// Add server attempts, preferring newer updates
server.forEach { serverAttempt ->
val localAttempt = merged[serverAttempt.id]
if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt)
) {
merged[serverAttempt.id] = serverAttempt
}
}
return merged.values.toList()
}
private fun isNewerThan(dateString1: String, dateString2: String): Boolean {
return try {
// Try parsing as instant first
val date1 = java.time.Instant.parse(dateString1)
val date2 = java.time.Instant.parse(dateString2)
date1.isAfter(date2)
} catch (e: Exception) {
// Fallback to string comparison
dateString1 > dateString2
}
}
}

View File

@@ -0,0 +1,374 @@
package com.atridad.openclimb
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
import org.junit.Assert.*
import org.junit.Test
/**
* Comprehensive utility and service tests for OpenClimb Android app. Tests core utility functions,
* date handling, string operations, and business logic.
*/
class UtilityTests {
@Test
fun testDateTimeUtilities() {
val now = System.currentTimeMillis()
val dateTime = LocalDateTime.now()
assertTrue(now > 0)
assertNotNull(dateTime)
val formatted = dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
assertFalse(formatted.isEmpty())
assertTrue(formatted.contains("T"))
}
@Test
fun testDurationCalculations() {
val startTime = 1000L
val endTime = 4000L
val duration = endTime - startTime
assertEquals(3000L, duration)
val minutes = TimeUnit.MILLISECONDS.toMinutes(duration)
val seconds = TimeUnit.MILLISECONDS.toSeconds(duration)
assertEquals(0L, minutes)
assertEquals(3L, seconds)
}
@Test
fun testStringValidation() {
val validName = "Test Gym"
val emptyName = ""
val whitespaceName = " "
val nullName: String? = null
assertTrue(isValidString(validName))
assertFalse(isValidString(emptyName))
assertFalse(isValidString(whitespaceName))
assertFalse(isValidString(nullName))
}
@Test
fun testGradeConversion() {
val vGrade = "V5"
val ydsGrade = "5.10a"
val fontGrade = "6A"
assertTrue(isValidVGrade(vGrade))
assertTrue(isValidYDSGrade(ydsGrade))
assertTrue(isValidFontGrade(fontGrade))
assertFalse(isValidVGrade("Invalid"))
assertFalse(isValidYDSGrade("Invalid"))
assertFalse(isValidFontGrade("Invalid"))
}
@Test
fun testNumericGradeExtraction() {
assertEquals(0, extractVGradeNumber("VB"))
assertEquals(5, extractVGradeNumber("V5"))
assertEquals(12, extractVGradeNumber("V12"))
assertEquals(-1, extractVGradeNumber("Invalid"))
}
@Test
fun testClimbingStatistics() {
val attempts =
listOf(
AttemptData("SUCCESS", 120),
AttemptData("FALL", 90),
AttemptData("SUCCESS", 150),
AttemptData("FLASH", 60),
AttemptData("FALL", 110)
)
val stats = calculateAttemptStatistics(attempts)
assertEquals(5, stats.totalAttempts)
assertEquals(3, stats.successfulAttempts)
assertEquals(60.0, stats.successRate, 0.01)
assertEquals(106.0, stats.averageDuration, 0.01)
}
@Test
fun testSessionDurationFormatting() {
assertEquals("0m", formatDuration(0))
assertEquals("1m", formatDuration(60))
assertEquals("1h 30m", formatDuration(5400))
assertEquals("2h", formatDuration(7200))
assertEquals("2h 5m", formatDuration(7500))
}
@Test
fun testDifficultyComparison() {
assertTrue(compareVGrades("V3", "V5") < 0)
assertTrue(compareVGrades("V5", "V3") > 0)
assertEquals(0, compareVGrades("V5", "V5"))
assertTrue(compareVGrades("VB", "V1") < 0)
assertTrue(compareVGrades("V1", "VB") > 0)
}
@Test
fun testClimbTypeValidation() {
val validTypes = listOf("BOULDER", "ROPE")
val invalidTypes = listOf("INVALID", "", "sport", "trad")
validTypes.forEach { type -> assertTrue("$type should be valid", isValidClimbType(type)) }
invalidTypes.forEach { type ->
assertFalse("$type should be invalid", isValidClimbType(type))
}
}
@Test
fun testImagePathValidation() {
val validPaths = listOf("image.jpg", "photo.jpeg", "picture.png", "diagram.webp")
val invalidPaths = listOf("", "file.txt", "document.pdf", "video.mp4")
validPaths.forEach { path ->
assertTrue("$path should be valid image", isValidImagePath(path))
}
invalidPaths.forEach { path ->
assertFalse("$path should be invalid image", isValidImagePath(path))
}
}
@Test
fun testLocationValidation() {
assertTrue(isValidLocation("Wall A"))
assertTrue(isValidLocation("Area 51"))
assertTrue(isValidLocation("Overhang Section"))
assertFalse(isValidLocation(""))
assertFalse(isValidLocation(" "))
assertFalse(isValidLocation(null))
}
@Test
fun testTagProcessing() {
val rawTags = "overhang, crimpy, technical,DYNAMIC "
val processedTags = processTags(rawTags)
assertEquals(4, processedTags.size)
assertTrue(processedTags.contains("overhang"))
assertTrue(processedTags.contains("crimpy"))
assertTrue(processedTags.contains("technical"))
assertTrue(processedTags.contains("dynamic"))
}
@Test
fun testSearchFiltering() {
val problems =
listOf(
ProblemData(
"id1",
"Crimpy Problem",
"BOULDER",
"V5",
listOf("crimpy", "overhang")
),
ProblemData("id2", "Easy Route", "ROPE", "5.6", listOf("beginner", "slab")),
ProblemData(
"id3",
"Hard Boulder",
"BOULDER",
"V10",
listOf("powerful", "roof")
)
)
val boulderProblems = filterByClimbType(problems, "BOULDER")
assertEquals(2, boulderProblems.size)
val crimpyProblems = filterByTag(problems, "crimpy")
assertEquals(1, crimpyProblems.size)
val easyProblems = filterByDifficultyRange(problems, "VB", "V6")
assertEquals(2, easyProblems.size)
}
@Test
fun testDataSynchronization() {
val localData = mapOf("key1" to "local_value", "key2" to "shared_value")
val serverData = mapOf("key2" to "server_value", "key3" to "new_value")
val merged = mergeData(localData, serverData)
assertEquals(3, merged.size)
assertEquals("local_value", merged["key1"])
assertEquals("server_value", merged["key2"]) // Server wins
assertEquals("new_value", merged["key3"])
}
@Test
fun testBackupValidation() {
val validBackup =
BackupData(
version = "2.0",
formatVersion = "2.0",
exportedAt = "2024-01-01T10:00:00Z",
dataCount = 5
)
val invalidBackup =
BackupData(
version = "1.0",
formatVersion = "2.0",
exportedAt = "invalid-date",
dataCount = -1
)
assertTrue(isValidBackup(validBackup))
assertFalse(isValidBackup(invalidBackup))
}
// Helper functions and data classes
private fun isValidString(str: String?): Boolean {
return str != null && str.trim().isNotEmpty()
}
private fun isValidVGrade(grade: String): Boolean {
return grade.matches(Regex("^V(B|[0-9]|1[0-7])$"))
}
private fun isValidYDSGrade(grade: String): Boolean {
return grade.matches(Regex("^5\\.[0-9]+([abcd])?$"))
}
private fun isValidFontGrade(grade: String): Boolean {
return grade.matches(Regex("^[3-8][ABC]?\\+?$"))
}
private fun extractVGradeNumber(grade: String): Int {
return when {
grade == "VB" -> 0
grade.startsWith("V") -> grade.substring(1).toIntOrNull() ?: -1
else -> -1
}
}
private fun calculateAttemptStatistics(attempts: List<AttemptData>): AttemptStatistics {
val successful = attempts.count { it.result == "SUCCESS" || it.result == "FLASH" }
val avgDuration = attempts.map { it.duration }.average()
val successRate = (successful.toDouble() / attempts.size) * 100
return AttemptStatistics(
totalAttempts = attempts.size,
successfulAttempts = successful,
successRate = successRate,
averageDuration = avgDuration
)
}
private fun formatDuration(seconds: Long): String {
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
return when {
hours > 0 && minutes > 0 -> "${hours}h ${minutes}m"
hours > 0 -> "${hours}h"
minutes > 0 -> "${minutes}m"
else -> "0m"
}
}
private fun compareVGrades(grade1: String, grade2: String): Int {
val num1 = extractVGradeNumber(grade1)
val num2 = extractVGradeNumber(grade2)
return num1.compareTo(num2)
}
private fun isValidClimbType(type: String): Boolean {
return type in listOf("BOULDER", "ROPE")
}
private fun isValidImagePath(path: String): Boolean {
val validExtensions = listOf(".jpg", ".jpeg", ".png", ".webp")
return path.isNotEmpty() && validExtensions.any { path.endsWith(it, ignoreCase = true) }
}
private fun isValidLocation(location: String?): Boolean {
return isValidString(location)
}
private fun processTags(rawTags: String): List<String> {
return rawTags.split(",").map { it.trim().lowercase() }.filter { it.isNotEmpty() }
}
private fun filterByClimbType(
problems: List<ProblemData>,
climbType: String
): List<ProblemData> {
return problems.filter { it.climbType == climbType }
}
private fun filterByTag(problems: List<ProblemData>, tag: String): List<ProblemData> {
return problems.filter { it.tags.contains(tag) }
}
private fun filterByDifficultyRange(
problems: List<ProblemData>,
minGrade: String,
maxGrade: String
): List<ProblemData> {
return problems.filter { problem ->
if (problem.climbType == "BOULDER" && problem.difficulty.startsWith("V")) {
val gradeNum = extractVGradeNumber(problem.difficulty)
val minNum = extractVGradeNumber(minGrade)
val maxNum = extractVGradeNumber(maxGrade)
gradeNum in minNum..maxNum
} else {
true // Simplified for other grade systems
}
}
}
private fun mergeData(
local: Map<String, String>,
server: Map<String, String>
): Map<String, String> {
return (local.keys + server.keys).associateWith { key -> server[key] ?: local[key]!! }
}
private fun isValidBackup(backup: BackupData): Boolean {
return backup.version == "2.0" &&
backup.formatVersion == "2.0" &&
backup.exportedAt.matches(Regex("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z")) &&
backup.dataCount >= 0
}
// Data classes for testing
data class AttemptData(val result: String, val duration: Int)
data class AttemptStatistics(
val totalAttempts: Int,
val successfulAttempts: Int,
val successRate: Double,
val averageDuration: Double
)
data class ProblemData(
val id: String,
val name: String,
val climbType: String,
val difficulty: String,
val tags: List<String>
)
data class BackupData(
val version: String,
val formatVersion: String,
val exportedAt: String,
val dataCount: Int
)
}

6
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

23
android/gradle.properties Normal file
View File

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,74 @@
[versions]
agp = "8.12.3"
kotlin = "2.2.20"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
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.09.01"
room = "2.8.1"
navigation = "2.9.5"
viewmodel = "2.9.4"
kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2"
coil = "2.7.0"
ksp = "2.2.10-2.0.2"
okhttp = "5.1.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
androidx-test-ext = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Room Database
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
# Navigation
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
# ViewModel
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewmodel" }
# Serialization
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
# Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Testing
mockk = { group = "io.mockk", name = "mockk", version = "1.14.6" }
# Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

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