Compare commits
85 Commits
87195aabf1
...
ANDROID_1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
1980ff802a
|
|||
|
73c4e41cac
|
|||
|
3ccd0ec7ea
|
|||
|
8fbb40d453
|
|||
| 016d427ff8 | |||
| 7cbb333287 | |||
| 2160ce30bd | |||
| de21e3270e | |||
|
f6e1cdcb5b
|
|||
|
fab587c351
|
|||
| d570b8a70c | |||
|
c676e25a3d
|
|||
|
56c501cef6
|
|||
|
f7f1fba9aa
|
|||
|
6e490d1598
|
|||
|
036becb5be
|
|||
|
dcc3f9cc9d
|
|||
|
2a48908dd2
|
|||
|
5d1748765f
|
|||
|
298ba6149b
|
|||
|
416b68e96a
|
|||
|
f68963afbc
|
|||
|
f1bc61d202
|
|||
|
57b16c89ad
|
|||
|
44b9b7bb9e
|
|||
|
7839d52001
|
|||
|
fff8123978
|
|||
|
6172074509
|
|||
|
0235b5d506
|
|||
|
7c18b56674
|
|||
|
cccdc2dd66
|
|||
|
62703cf2eb
|
|||
|
2c0ae23417
|
|||
|
87dcd08189
|
|||
|
f3dabbd3aa
|
|||
|
e4c6440758
|
|||
|
b478f05260
|
|||
|
afd954785a
|
|||
| d95c45abbb | |||
| 9df0b29ada | |||
|
ff9f0d6cc6
|
|||
|
61384623bd
|
|||
|
7da1893748
|
|||
|
f106244e57
|
|||
|
76a9120184
|
|||
|
abeed46c90
|
|||
|
7770997fd4
|
|||
|
f45ff8963d
|
|||
|
5988cbf1fb
|
|||
| 13654cde70 | |||
| 9064dbe2ef | |||
|
0537da79e4
|
|||
|
4804049274
|
|||
|
8db6ed0e82
|
|||
|
8b9901383a
|
|||
|
cf2adeef7a
|
|||
|
a7481135b4
|
|||
|
748a23e1c0
|
|||
|
f078cfc6e1
|
|||
|
8bb1f422c1
|
|||
|
327dfba425
|
|||
|
96759e402d
|
|||
|
ed76fb2fb2
|
|||
|
870278f240
|
|||
|
4eef77bd3b
|
|||
|
2d957db948
|
|||
| 22bed6a961 | |||
|
b443c18a19
|
|||
|
89f1e350b3
|
|||
|
0f976f685f
|
|||
|
c07186a7df
|
|||
|
15a5e217a5
|
|||
|
b86ab591fe
|
|||
|
70c85d159e
|
|||
|
d6c5e937df
|
|||
|
829bbbff7a
|
|||
|
e1ebf412bd
|
|||
|
5c133b655e
|
|||
|
cc1edbc65c
|
|||
|
ca770b9db3
|
|||
|
7edb7c8191
|
|||
|
1ca6b33882
|
|||
|
bd6b5cc652
|
|||
|
6e16a30429
|
|||
|
66fdef78d9
|
43
.github/workflows/deploy.yml
vendored
Normal file
43
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: OpenClimb Docker Deploy
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "sync/**"
|
||||||
|
- ".github/workflows/deploy.yml"
|
||||||
|
pull_request:
|
||||||
|
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
36
.gitignore
vendored
Normal 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
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
#Fri Aug 15 12:27:13 MDT 2025
|
|
||||||
gradle.version=8.11.1
|
|
||||||
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
#Fri Aug 15 12:29:02 MDT 2025
|
|
||||||
java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home
|
|
||||||
Binary file not shown.
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
6
.idea/AndroidProjectSystem.xml
generated
6
.idea/AndroidProjectSystem.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AndroidProjectSystem">
|
|
||||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
835
.idea/caches/deviceStreaming.xml
generated
835
.idea/caches/deviceStreaming.xml
generated
@@ -1,835 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DeviceStreaming">
|
|
||||||
<option name="deviceSelectionList">
|
|
||||||
<list>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="Sony" />
|
|
||||||
<option name="codename" value="A402SO" />
|
|
||||||
<option name="id" value="A402SO" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Sony" />
|
|
||||||
<option name="name" value="Xperia 10" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2520" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="27" />
|
|
||||||
<option name="brand" value="DOCOMO" />
|
|
||||||
<option name="codename" value="F01L" />
|
|
||||||
<option name="id" value="F01L" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="FUJITSU" />
|
|
||||||
<option name="name" value="F-01L" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1280" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="OnePlus" />
|
|
||||||
<option name="codename" value="OP535DL1" />
|
|
||||||
<option name="id" value="OP535DL1" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="OnePlus" />
|
|
||||||
<option name="name" value="CPH2409" />
|
|
||||||
<option name="screenDensity" value="401" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2412" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="OnePlus" />
|
|
||||||
<option name="codename" value="OP5552L1" />
|
|
||||||
<option name="id" value="OP5552L1" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="OnePlus" />
|
|
||||||
<option name="name" value="CPH2415" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2412" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="OPPO" />
|
|
||||||
<option name="codename" value="OP573DL1" />
|
|
||||||
<option name="id" value="OP573DL1" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="OPPO" />
|
|
||||||
<option name="name" value="CPH2557" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="28" />
|
|
||||||
<option name="brand" value="DOCOMO" />
|
|
||||||
<option name="codename" value="SH-01L" />
|
|
||||||
<option name="id" value="SH-01L" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="SHARP" />
|
|
||||||
<option name="name" value="AQUOS sense2 SH-01L" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2160" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="a14m" />
|
|
||||||
<option name="id" value="a14m" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="SM-A145R" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2408" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="a15" />
|
|
||||||
<option name="id" value="a15" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="A15" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="a15x" />
|
|
||||||
<option name="id" value="a15x" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="A15 5G" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="a16x" />
|
|
||||||
<option name="id" value="a16x" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="A16 5G" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="a35x" />
|
|
||||||
<option name="id" value="a35x" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="A35" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="akita" />
|
|
||||||
<option name="id" value="akita" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="35" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="akita" />
|
|
||||||
<option name="id" value="akita" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="arcfox" />
|
|
||||||
<option name="id" value="arcfox" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="razr plus 2024" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="1272" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="austin" />
|
|
||||||
<option name="id" value="austin" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="moto g 5G (2022)" />
|
|
||||||
<option name="screenDensity" value="280" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1600" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="b0q" />
|
|
||||||
<option name="id" value="b0q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S22 Ultra" />
|
|
||||||
<option name="screenDensity" value="600" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3088" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="b6q" />
|
|
||||||
<option name="id" value="b6q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Flip 6" />
|
|
||||||
<option name="screenDensity" value="340" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2640" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="32" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="bluejay" />
|
|
||||||
<option name="id" value="bluejay" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 6a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="caiman" />
|
|
||||||
<option name="id" value="caiman" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="960" />
|
|
||||||
<option name="screenY" value="2142" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="35" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="caiman" />
|
|
||||||
<option name="id" value="caiman" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="960" />
|
|
||||||
<option name="screenY" value="2142" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="comet" />
|
|
||||||
<option name="default" value="true" />
|
|
||||||
<option name="id" value="comet" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro Fold" />
|
|
||||||
<option name="screenDensity" value="390" />
|
|
||||||
<option name="screenX" value="2076" />
|
|
||||||
<option name="screenY" value="2152" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="35" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="comet" />
|
|
||||||
<option name="default" value="true" />
|
|
||||||
<option name="id" value="comet" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro Fold" />
|
|
||||||
<option name="screenDensity" value="390" />
|
|
||||||
<option name="screenX" value="2076" />
|
|
||||||
<option name="screenY" value="2152" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="29" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="crownqlteue" />
|
|
||||||
<option name="id" value="crownqlteue" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Note9" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2220" />
|
|
||||||
<option name="screenY" value="1080" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="dm2q" />
|
|
||||||
<option name="id" value="dm2q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="S23 Plus" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="dm3q" />
|
|
||||||
<option name="id" value="dm3q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S23 Ultra" />
|
|
||||||
<option name="screenDensity" value="600" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3088" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="dubai" />
|
|
||||||
<option name="id" value="dubai" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="edge 30" />
|
|
||||||
<option name="screenDensity" value="405" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="e1q" />
|
|
||||||
<option name="default" value="true" />
|
|
||||||
<option name="id" value="e1q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S24" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="e3q" />
|
|
||||||
<option name="id" value="e3q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S24 Ultra" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3120" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="eos" />
|
|
||||||
<option name="id" value="eos" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Eos" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="384" />
|
|
||||||
<option name="screenY" value="384" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="35" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="eqe" />
|
|
||||||
<option name="id" value="eqe" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="edge 50 pro" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1220" />
|
|
||||||
<option name="screenY" value="2712" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix" />
|
|
||||||
<option name="id" value="felix" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix" />
|
|
||||||
<option name="id" value="felix" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix_camera" />
|
|
||||||
<option name="id" value="felix_camera" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold (Camera-enabled)" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="fogona" />
|
|
||||||
<option name="id" value="fogona" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="moto g play - 2024" />
|
|
||||||
<option name="screenDensity" value="280" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1600" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="fogos" />
|
|
||||||
<option name="id" value="fogos" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="moto g34 5G" />
|
|
||||||
<option name="screenDensity" value="280" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1600" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="g0q" />
|
|
||||||
<option name="id" value="g0q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="SM-S906U1" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="gta9pwifi" />
|
|
||||||
<option name="id" value="gta9pwifi" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="SM-X210" />
|
|
||||||
<option name="screenDensity" value="240" />
|
|
||||||
<option name="screenX" value="1200" />
|
|
||||||
<option name="screenY" value="1920" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="gts7lwifi" />
|
|
||||||
<option name="id" value="gts7lwifi" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="SM-T870" />
|
|
||||||
<option name="screenDensity" value="340" />
|
|
||||||
<option name="screenX" value="1600" />
|
|
||||||
<option name="screenY" value="2560" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="gts7xllite" />
|
|
||||||
<option name="id" value="gts7xllite" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="SM-T738U" />
|
|
||||||
<option name="screenDensity" value="340" />
|
|
||||||
<option name="screenX" value="1600" />
|
|
||||||
<option name="screenY" value="2560" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="gts8uwifi" />
|
|
||||||
<option name="formFactor" value="Tablet" />
|
|
||||||
<option name="id" value="gts8uwifi" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Tab S8 Ultra" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="1848" />
|
|
||||||
<option name="screenY" value="2960" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="gts8wifi" />
|
|
||||||
<option name="formFactor" value="Tablet" />
|
|
||||||
<option name="id" value="gts8wifi" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Tab S8" />
|
|
||||||
<option name="screenDensity" value="274" />
|
|
||||||
<option name="screenX" value="1600" />
|
|
||||||
<option name="screenY" value="2560" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="gts9fe" />
|
|
||||||
<option name="id" value="gts9fe" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Tab S9 FE 5G" />
|
|
||||||
<option name="screenDensity" value="280" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="2304" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="gts9wifi" />
|
|
||||||
<option name="id" value="gts9wifi" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="SM-X710" />
|
|
||||||
<option name="screenDensity" value="340" />
|
|
||||||
<option name="screenX" value="1600" />
|
|
||||||
<option name="screenY" value="2560" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="husky" />
|
|
||||||
<option name="id" value="husky" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8 Pro" />
|
|
||||||
<option name="screenDensity" value="390" />
|
|
||||||
<option name="screenX" value="1008" />
|
|
||||||
<option name="screenY" value="2244" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="java" />
|
|
||||||
<option name="id" value="java" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="G20" />
|
|
||||||
<option name="screenDensity" value="280" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1600" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="komodo" />
|
|
||||||
<option name="id" value="komodo" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro XL" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="1008" />
|
|
||||||
<option name="screenY" value="2244" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="35" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="komodo" />
|
|
||||||
<option name="id" value="komodo" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro XL" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="1008" />
|
|
||||||
<option name="screenY" value="2244" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="lion" />
|
|
||||||
<option name="id" value="lion" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="moto g04" />
|
|
||||||
<option name="screenDensity" value="280" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1612" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="lynx" />
|
|
||||||
<option name="id" value="lynx" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 7a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="lyriq" />
|
|
||||||
<option name="id" value="lyriq" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="edge 40" />
|
|
||||||
<option name="screenDensity" value="400" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="manaus" />
|
|
||||||
<option name="id" value="manaus" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="edge 40 neo" />
|
|
||||||
<option name="screenDensity" value="400" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="maui" />
|
|
||||||
<option name="id" value="maui" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="moto g play - 2023" />
|
|
||||||
<option name="screenDensity" value="280" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1600" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="o1q" />
|
|
||||||
<option name="id" value="o1q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S21" />
|
|
||||||
<option name="screenDensity" value="421" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="31" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="oriole" />
|
|
||||||
<option name="id" value="oriole" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 6" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="35" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="pa3q" />
|
|
||||||
<option name="id" value="pa3q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S25 Ultra" />
|
|
||||||
<option name="screenDensity" value="600" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3120" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="panther" />
|
|
||||||
<option name="id" value="panther" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 7" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="q5q" />
|
|
||||||
<option name="id" value="q5q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Z Fold5" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1812" />
|
|
||||||
<option name="screenY" value="2176" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="q6q" />
|
|
||||||
<option name="id" value="q6q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Z Fold6" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1856" />
|
|
||||||
<option name="screenY" value="2160" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="r11" />
|
|
||||||
<option name="formFactor" value="Wear OS" />
|
|
||||||
<option name="id" value="r11" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Watch" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="384" />
|
|
||||||
<option name="screenY" value="384" />
|
|
||||||
<option name="type" value="WEAR_OS" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="r11q" />
|
|
||||||
<option name="id" value="r11q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="SM-S711U" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="redfin" />
|
|
||||||
<option name="id" value="redfin" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 5" />
|
|
||||||
<option name="screenDensity" value="440" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="shiba" />
|
|
||||||
<option name="id" value="shiba" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="t2q" />
|
|
||||||
<option name="id" value="t2q" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S21 Plus" />
|
|
||||||
<option name="screenDensity" value="394" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tangorpro" />
|
|
||||||
<option name="formFactor" value="Tablet" />
|
|
||||||
<option name="id" value="tangorpro" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Tablet" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="1600" />
|
|
||||||
<option name="screenY" value="2560" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="35" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tegu" />
|
|
||||||
<option name="id" value="tegu" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2424" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tokay" />
|
|
||||||
<option name="default" value="true" />
|
|
||||||
<option name="id" value="tokay" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2424" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="35" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tokay" />
|
|
||||||
<option name="default" value="true" />
|
|
||||||
<option name="id" value="tokay" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2424" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="xcover7" />
|
|
||||||
<option name="id" value="xcover7" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="SM-G556B" />
|
|
||||||
<option name="screenDensity" value="450" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2408" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
10
.idea/deploymentTargetSelector.xml
generated
10
.idea/deploymentTargetSelector.xml
generated
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="deploymentTargetSelector">
|
|
||||||
<selectionStates>
|
|
||||||
<SelectionState runConfigName="app">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
</SelectionState>
|
|
||||||
</selectionStates>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
18
.idea/gradle.xml
generated
18
.idea/gradle.xml
generated
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="GradleSettings">
|
|
||||||
<option name="linkedExternalProjectsSettings">
|
|
||||||
<GradleProjectSettings>
|
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
|
||||||
<option name="modules">
|
|
||||||
<set>
|
|
||||||
<option value="$PROJECT_DIR$" />
|
|
||||||
<option value="$PROJECT_DIR$/app" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
</GradleProjectSettings>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
50
.idea/inspectionProfiles/Project_Default.xml
generated
50
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,50 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<profile version="1.0">
|
|
||||||
<option name="myName" value="Project Default" />
|
|
||||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
</profile>
|
|
||||||
</component>
|
|
||||||
6
.idea/kotlinc.xml
generated
6
.idea/kotlinc.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="KotlinJpsPluginSettings">
|
|
||||||
<option name="version" value="2.0.21" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
10
.idea/migrations.xml
generated
10
.idea/migrations.xml
generated
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectMigrations">
|
|
||||||
<option name="MigrateToGradleLocalJavaHome">
|
|
||||||
<set>
|
|
||||||
<option value="$PROJECT_DIR$" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
10
.idea/misc.xml
generated
10
.idea/misc.xml
generated
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectType">
|
|
||||||
<option name="id" value="Android" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
17
.idea/runConfigurations.xml
generated
17
.idea/runConfigurations.xml
generated
@@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="RunConfigurationProducerService">
|
|
||||||
<option name="ignoredProducers">
|
|
||||||
<set>
|
|
||||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
|
||||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
|
||||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
|
||||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
|
||||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
|
||||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
|
||||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
|
||||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
kotlin version: 2.0.21
|
|
||||||
error message: java.lang.IllegalStateException: Storage for [/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] is already registered
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.set(PersistentStorage.kt:96)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addFileIfNeeded(LookupStorage.kt:165)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addAll$lambda$4(LookupStorage.kt:117)
|
|
||||||
at org.jetbrains.kotlin.utils.CollectionsKt.keysToMap(collections.kt:117)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addAll(LookupStorage.kt:117)
|
|
||||||
at org.jetbrains.kotlin.incremental.BuildUtilKt.update(buildUtil.kt:134)
|
|
||||||
at com.google.devtools.ksp.LookupStorageWrapperImpl.update(IncrementalContext.kt:231)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateLookupCache(IncrementalContextBase.kt:133)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateCaches(IncrementalContextBase.kt:365)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:471)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:362)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
|
|
||||||
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555)
|
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
|
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
|
|
||||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
|
|
||||||
at java.base/java.lang.Thread.run(Thread.java:1583)
|
|
||||||
Suppressed: java.lang.Exception: Storage[/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] registration stack trace
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageIfExists(LazyStorage.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.get(LazyStorage.kt:76)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.get(PersistentStorage.kt:92)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.get(LookupStorage.kt:99)
|
|
||||||
at com.google.devtools.ksp.LookupStorageWrapperImpl.get(IncrementalContext.kt:224)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.calcDirtyFiles(IncrementalContextBase.kt:234)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:196)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:189)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.handleException(KotlinSymbolProcessingExtension.kt:414)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:189)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
|
|
||||||
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
|
|
||||||
... 23 more
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
kotlin version: 2.0.21
|
|
||||||
error message: java.lang.IllegalStateException: Storage for [/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] is already registered
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.set(PersistentStorage.kt:96)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addFileIfNeeded(LookupStorage.kt:165)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addAll$lambda$4(LookupStorage.kt:117)
|
|
||||||
at org.jetbrains.kotlin.utils.CollectionsKt.keysToMap(collections.kt:117)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addAll(LookupStorage.kt:117)
|
|
||||||
at org.jetbrains.kotlin.incremental.BuildUtilKt.update(buildUtil.kt:134)
|
|
||||||
at com.google.devtools.ksp.LookupStorageWrapperImpl.update(IncrementalContext.kt:231)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateLookupCache(IncrementalContextBase.kt:133)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateCaches(IncrementalContextBase.kt:365)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:471)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:362)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
|
|
||||||
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555)
|
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
|
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
|
|
||||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
|
|
||||||
at java.base/java.lang.Thread.run(Thread.java:1583)
|
|
||||||
Suppressed: java.lang.Exception: Storage[/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] registration stack trace
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageIfExists(LazyStorage.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.get(LazyStorage.kt:76)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.get(PersistentStorage.kt:92)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.get(LookupStorage.kt:99)
|
|
||||||
at com.google.devtools.ksp.LookupStorageWrapperImpl.get(IncrementalContext.kt:224)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.calcDirtyFiles(IncrementalContextBase.kt:234)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:196)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:189)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.handleException(KotlinSymbolProcessingExtension.kt:414)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:189)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
|
|
||||||
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
|
|
||||||
... 23 more
|
|
||||||
|
|
||||||
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"java.configuration.updateBuildConfiguration": "disabled"
|
|
||||||
}
|
|
||||||
31
README.md
31
README.md
@@ -1,18 +1,37 @@
|
|||||||
# OpenClimb
|
# OpenClimb
|
||||||
|
|
||||||
This is a FOSS Android 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.
|
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.
|
||||||
|
|
||||||
|
## Versions
|
||||||
|
|
||||||
|
- Android: 1.7.0
|
||||||
|
- iOS: 1.2.0
|
||||||
|
- Sync: 1.0.0
|
||||||
|
|
||||||
|
## Stability
|
||||||
|
- Clients: 8/10
|
||||||
|
- Server: 10/10
|
||||||
|
- Schema: 9/10 (No more breaking changes)
|
||||||
|
|
||||||
|
## 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. See the server docker-compose file for an example.
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
You have two options:
|
For Android do one of the following:
|
||||||
|
|
||||||
1. Download the latest APK from the Released page
|
1. Download the latest APK from the Releases page
|
||||||
2. Use <a href="">Obtainium</a>
|
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)!
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Android 15+
|
- Android 12+ or iOS 17+
|
||||||
|
|
||||||
## Contribution
|
## Contribution
|
||||||
|
|
||||||
As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out.
|
As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out.
|
||||||
|
|||||||
0
app/.gitignore → android/app/.gitignore
vendored
0
app/.gitignore → android/app/.gitignore
vendored
97
android/app/build.gradle.kts
Normal file
97
android/app/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
98
android/app/build.gradle.kts.backup
Normal file
98
android/app/build.gradle.kts.backup
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
@@ -8,81 +10,89 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.atridad.openclimb"
|
namespace = "com.atridad.openclimb"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.openclimb"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 27
|
||||||
versionName = "0.1.0"
|
versionName = "1.6.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "11"
|
|
||||||
}
|
|
||||||
buildFeatures {
|
|
||||||
compose = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
|
||||||
|
|
||||||
|
buildFeatures { compose = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Core Android libraries
|
// Core Android libraries
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
||||||
// Compose BOM and UI
|
// Compose BOM and UI
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.ui)
|
implementation(libs.androidx.ui)
|
||||||
implementation(libs.androidx.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.material.icons.extended)
|
||||||
|
|
||||||
// Room Database
|
// Room Database
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
|
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
// ViewModel
|
// ViewModel
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
|
||||||
// Serialization
|
// Serialization
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
// Image Loading
|
// Image Loading
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
// Charts - Placeholder for future implementation
|
// HTTP Client
|
||||||
// Charts will be implemented with a stable library in future versions
|
implementation(libs.okhttp)
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.mockk)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
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(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
}
|
}
|
||||||
@@ -7,12 +7,18 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<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 -->
|
<!-- Permissions for notifications and foreground service -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -27,14 +33,16 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.OpenClimb">
|
android:theme="@style/Theme.OpenClimb.Splash">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- FileProvider for sharing images -->
|
<!-- FileProvider for sharing images -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
@@ -45,17 +53,30 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_provider_paths" />
|
android:resource="@xml/file_provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<!-- Session tracking service -->
|
<!-- Session tracking service -->
|
||||||
<service
|
<service
|
||||||
android:name=".service.SessionTrackingService"
|
android:name=".service.SessionTrackingService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="specialUse">
|
android:foregroundServiceType="specialUse"
|
||||||
|
android:description="@string/session_tracking_service_description">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.foreground_service_type"
|
android:name="android.app.foreground_service_type"
|
||||||
android:value="specialUse" />
|
android:value="specialUse" />
|
||||||
</service>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,9 @@ interface AttemptDao {
|
|||||||
@Query("SELECT COUNT(*) FROM attempts")
|
@Query("SELECT COUNT(*) FROM attempts")
|
||||||
suspend fun getAttemptsCount(): Int
|
suspend fun getAttemptsCount(): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM attempts")
|
||||||
|
suspend fun deleteAllAttempts()
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId")
|
@Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId")
|
||||||
suspend fun getAttemptsCountBySession(sessionId: String): Int
|
suspend fun getAttemptsCountBySession(sessionId: String): Int
|
||||||
|
|
||||||
@@ -59,6 +59,9 @@ interface ClimbSessionDao {
|
|||||||
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
||||||
suspend fun getActiveSession(): ClimbSession?
|
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")
|
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
||||||
fun getActiveSessionFlow(): Flow<ClimbSession?>
|
fun getActiveSessionFlow(): Flow<ClimbSession?>
|
||||||
}
|
}
|
||||||
@@ -37,4 +37,7 @@ interface GymDao {
|
|||||||
|
|
||||||
@Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'")
|
@Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'")
|
||||||
fun searchGyms(searchQuery: String): Flow<List<Gym>>
|
fun searchGyms(searchQuery: String): Flow<List<Gym>>
|
||||||
|
|
||||||
|
@Query("DELETE FROM gyms")
|
||||||
|
suspend fun deleteAllGyms()
|
||||||
}
|
}
|
||||||
@@ -7,56 +7,58 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface ProblemDao {
|
interface ProblemDao {
|
||||||
|
|
||||||
@Query("SELECT * FROM problems ORDER BY updatedAt DESC")
|
@Query("SELECT * FROM problems ORDER BY updatedAt DESC")
|
||||||
fun getAllProblems(): Flow<List<Problem>>
|
fun getAllProblems(): Flow<List<Problem>>
|
||||||
|
|
||||||
@Query("SELECT * FROM problems WHERE id = :id")
|
@Query("SELECT * FROM problems WHERE id = :id") suspend fun getProblemById(id: String): Problem?
|
||||||
suspend fun getProblemById(id: String): Problem?
|
|
||||||
|
|
||||||
@Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC")
|
@Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC")
|
||||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>>
|
fun getProblemsByGym(gymId: String): Flow<List<Problem>>
|
||||||
|
|
||||||
@Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC")
|
@Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC")
|
||||||
fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>>
|
fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>>
|
||||||
|
|
||||||
@Query("SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC")
|
@Query(
|
||||||
|
"SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC"
|
||||||
|
)
|
||||||
fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow<List<Problem>>
|
fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow<List<Problem>>
|
||||||
|
|
||||||
@Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC")
|
@Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC")
|
||||||
fun getActiveProblems(): Flow<List<Problem>>
|
fun getActiveProblems(): Flow<List<Problem>>
|
||||||
|
|
||||||
@Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC")
|
@Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC")
|
||||||
fun getActiveProblemsByGym(gymId: String): Flow<List<Problem>>
|
fun getActiveProblemsByGym(gymId: String): Flow<List<Problem>>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertProblem(problem: Problem)
|
||||||
suspend fun insertProblem(problem: Problem)
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertProblems(problems: List<Problem>)
|
suspend fun insertProblems(problems: List<Problem>)
|
||||||
|
|
||||||
@Update
|
@Update suspend fun updateProblem(problem: Problem)
|
||||||
suspend fun updateProblem(problem: Problem)
|
|
||||||
|
@Delete suspend fun deleteProblem(problem: Problem)
|
||||||
@Delete
|
|
||||||
suspend fun deleteProblem(problem: Problem)
|
@Query("DELETE FROM problems WHERE id = :id") suspend fun deleteProblemById(id: String)
|
||||||
|
|
||||||
@Query("DELETE FROM problems WHERE id = :id")
|
|
||||||
suspend fun deleteProblemById(id: String)
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId")
|
@Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId")
|
||||||
suspend fun getProblemsCountByGym(gymId: String): Int
|
suspend fun getProblemsCountByGym(gymId: String): Int
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM problems WHERE isActive = 1")
|
@Query("SELECT COUNT(*) FROM problems WHERE isActive = 1")
|
||||||
suspend fun getActiveProblemsCount(): Int
|
suspend fun getActiveProblemsCount(): Int
|
||||||
|
|
||||||
@Query("""
|
@Query(
|
||||||
SELECT * FROM problems
|
"""
|
||||||
WHERE (name LIKE '%' || :searchQuery || '%'
|
SELECT * FROM problems
|
||||||
|
WHERE (name LIKE '%' || :searchQuery || '%'
|
||||||
OR description LIKE '%' || :searchQuery || '%'
|
OR description LIKE '%' || :searchQuery || '%'
|
||||||
OR location LIKE '%' || :searchQuery || '%'
|
OR location LIKE '%' || :searchQuery || '%')
|
||||||
OR setter LIKE '%' || :searchQuery || '%')
|
|
||||||
ORDER BY updatedAt DESC
|
ORDER BY updatedAt DESC
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
fun searchProblems(searchQuery: String): Flow<List<Problem>>
|
fun searchProblems(searchQuery: String): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM problems") suspend fun getProblemsCount(): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM problems") suspend fun deleteAllProblems()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
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, // ISO 8601 format
|
||||||
|
val updatedAt: String // ISO 8601 format
|
||||||
|
) {
|
||||||
|
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, // ISO 8601 format
|
||||||
|
val notes: String? = null,
|
||||||
|
val createdAt: String, // ISO 8601 format
|
||||||
|
val updatedAt: String // ISO 8601 format
|
||||||
|
) {
|
||||||
|
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 ->
|
||||||
|
// Store just the filename to match iOS format
|
||||||
|
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, // ISO 8601 format
|
||||||
|
val startTime: String? = null, // ISO 8601 format
|
||||||
|
val endTime: String? = null, // ISO 8601 format
|
||||||
|
val duration: Long? = null, // Duration in seconds
|
||||||
|
val status: SessionStatus,
|
||||||
|
val notes: String? = null,
|
||||||
|
val createdAt: String, // ISO 8601 format
|
||||||
|
val updatedAt: String // ISO 8601 format
|
||||||
|
) {
|
||||||
|
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, // Duration in seconds
|
||||||
|
val restTime: Long? = null, // Rest time in seconds
|
||||||
|
val timestamp: String, // ISO 8601 format
|
||||||
|
val createdAt: String // ISO 8601 format
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 problem with new 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 our 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
|
||||||
|
}
|
||||||
@@ -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, // Description of the highest hold reached
|
||||||
|
val notes: String? = null,
|
||||||
|
val duration: Long? = null, // Attempt duration in seconds
|
||||||
|
val restTime: Long? = null, // Rest time before this attempt in seconds
|
||||||
|
val timestamp: String, // When this attempt was made
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package com.atridad.openclimb.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DifficultySystem {
|
||||||
|
// Bouldering
|
||||||
|
V_SCALE, // V-Scale (VB - V17)
|
||||||
|
FONT, // Fontainebleau (3 - 8C+)
|
||||||
|
|
||||||
|
// Rope
|
||||||
|
YDS, // Yosemite Decimal System (5.0 - 5.15d)
|
||||||
|
|
||||||
|
// Custom difficulty systems
|
||||||
|
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 // Custom is available for all
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 -> {
|
||||||
|
// Simplified numeric mapping for YDS grades
|
||||||
|
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 -> {
|
||||||
|
// Simplified Font grade mapping
|
||||||
|
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 {
|
||||||
|
// Handle VB (easiest) specially
|
||||||
|
if (grade1 == "VB" && grade2 != "VB") return -1
|
||||||
|
if (grade2 == "VB" && grade1 != "VB") return 1
|
||||||
|
if (grade1 == "VB" && grade2 == "VB") return 0
|
||||||
|
|
||||||
|
// Extract numeric values for V grades
|
||||||
|
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 {
|
||||||
|
// Simple string comparison for Font grades
|
||||||
|
return grade1.compareTo(grade2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareYDSGrades(grade1: String, grade2: String): Int {
|
||||||
|
// Simple string comparison for YDS grades
|
||||||
|
return grade1.compareTo(grade2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
// Callback interface for auto-sync functionality
|
||||||
|
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 {
|
||||||
|
// Collect all data
|
||||||
|
val allGyms = gymDao.getAllGyms().first()
|
||||||
|
val allProblems = problemDao.getAllProblems().first()
|
||||||
|
val allSessions = sessionDao.getAllSessions().first()
|
||||||
|
val allAttempts = attemptDao.getAllAttempts().first()
|
||||||
|
|
||||||
|
// Validate data integrity before export
|
||||||
|
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
|
||||||
|
|
||||||
|
// Create backup data using platform-neutral format
|
||||||
|
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) }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect all referenced image paths and validate they exist
|
||||||
|
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 {
|
||||||
|
// Validate the ZIP file
|
||||||
|
if (!file.exists() || file.length() == 0L) {
|
||||||
|
throw Exception("Invalid ZIP file: file is empty or doesn't exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate the ZIP contents
|
||||||
|
val importResult = ZipExportImportUtils.extractImportZip(context, file)
|
||||||
|
|
||||||
|
// Validate JSON content
|
||||||
|
if (importResult.jsonContent.isBlank()) {
|
||||||
|
throw Exception("Invalid ZIP file: no data.json found or empty content")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate the data structure
|
||||||
|
val importData =
|
||||||
|
try {
|
||||||
|
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Invalid data format: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate data integrity
|
||||||
|
validateImportData(importData)
|
||||||
|
|
||||||
|
// Clear existing data to avoid conflicts
|
||||||
|
attemptDao.deleteAllAttempts()
|
||||||
|
sessionDao.deleteAllSessions()
|
||||||
|
problemDao.deleteAllProblems()
|
||||||
|
gymDao.deleteAllGyms()
|
||||||
|
|
||||||
|
// Import gyms first (problems depend on gyms) - use DAO directly to avoid multiple data
|
||||||
|
// state updates
|
||||||
|
importData.gyms.forEach { backupGym ->
|
||||||
|
try {
|
||||||
|
gymDao.insertGym(backupGym.toGym())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Failed to import gym '${backupGym.name}': ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import problems with updated image paths
|
||||||
|
val updatedBackupProblems =
|
||||||
|
ZipExportImportUtils.updateProblemImagePaths(
|
||||||
|
importData.problems,
|
||||||
|
importResult.importedImagePaths
|
||||||
|
)
|
||||||
|
|
||||||
|
// Import problems (depends on gyms) - use DAO directly
|
||||||
|
updatedBackupProblems.forEach { backupProblem ->
|
||||||
|
try {
|
||||||
|
problemDao.insertProblem(backupProblem.toProblem())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception(
|
||||||
|
"Failed to import problem '${backupProblem.name}': ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import sessions - use DAO directly
|
||||||
|
importData.sessions.forEach { backupSession ->
|
||||||
|
try {
|
||||||
|
sessionDao.insertSession(backupSession.toClimbSession())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Failed to import session '${backupSession.id}': ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import attempts last (depends on problems and sessions) - use DAO directly
|
||||||
|
importData.attempts.forEach { backupAttempt ->
|
||||||
|
try {
|
||||||
|
attemptDao.insertAttempt(backupAttempt.toAttempt())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update data state once at the end to current time since we just imported new data
|
||||||
|
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>
|
||||||
|
) {
|
||||||
|
// Validate that all problems reference valid gyms
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that all sessions reference valid gyms
|
||||||
|
val invalidSessions = sessions.filter { it.gymId !in gymIds }
|
||||||
|
if (invalidSessions.isNotEmpty()) {
|
||||||
|
throw Exception(
|
||||||
|
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that all attempts reference valid problems and sessions
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reasonable data sizes to prevent malicious imports
|
||||||
|
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 {
|
||||||
|
// Temporarily disable auto-sync during reset
|
||||||
|
val originalCallback = autoSyncCallback
|
||||||
|
autoSyncCallback = null
|
||||||
|
|
||||||
|
// Clear all data from database
|
||||||
|
attemptDao.deleteAllAttempts()
|
||||||
|
sessionDao.deleteAllSessions()
|
||||||
|
problemDao.deleteAllProblems()
|
||||||
|
gymDao.deleteAllGyms()
|
||||||
|
|
||||||
|
// Clear all images from storage
|
||||||
|
clearAllImages()
|
||||||
|
|
||||||
|
// Restore auto-sync callback
|
||||||
|
autoSyncCallback = originalCallback
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Reset failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import methods that bypass auto-sync to avoid triggering sync during data restoration
|
||||||
|
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 {
|
||||||
|
// Get the images directory
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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 {
|
||||||
|
// Initialize with current timestamp if this is the first time
|
||||||
|
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()})"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,839 @@
|
|||||||
|
package com.atridad.openclimb.data.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
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
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
|
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 flows
|
||||||
|
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 {
|
||||||
|
// Initialize state from preferences
|
||||||
|
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
|
||||||
|
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
||||||
|
|
||||||
|
// Register auto-sync callback with repository
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Log problems with images
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server expects filename as query parameter and raw image data in body
|
||||||
|
// Extract just the filename without directory path
|
||||||
|
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 // Success
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent concurrent sync operations
|
||||||
|
syncMutex.withLock {
|
||||||
|
_isSyncing.value = true
|
||||||
|
_syncError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fix existing image paths first
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate images to consistent naming second
|
||||||
|
Log.d(TAG, "Performing image migration before sync")
|
||||||
|
val migrationSuccess = migrateImagesForSync()
|
||||||
|
if (!migrationSuccess) {
|
||||||
|
Log.w(TAG, "Image migration failed, but continuing with sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get local backup data
|
||||||
|
val localBackup = createBackupFromRepository()
|
||||||
|
|
||||||
|
// Download server data
|
||||||
|
val serverBackup = downloadData()
|
||||||
|
|
||||||
|
// Check if we have any local data
|
||||||
|
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 -> {
|
||||||
|
// Case 1: No local data - do full restore from server
|
||||||
|
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 -> {
|
||||||
|
// Case 2: No server data - upload local data to server
|
||||||
|
Log.d(TAG, "No server data found, uploading local data to server")
|
||||||
|
uploadData(localBackup)
|
||||||
|
syncImagesForBackup(localBackup)
|
||||||
|
Log.d(TAG, "Initial upload completed")
|
||||||
|
}
|
||||||
|
hasLocalData && hasServerData -> {
|
||||||
|
// Case 3: Both have data - compare timestamps (last writer wins)
|
||||||
|
val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt)
|
||||||
|
val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt)
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Comparing timestamps: local=$localTimestamp, server=$serverTimestamp"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (localTimestamp > serverTimestamp) {
|
||||||
|
// Local is newer - replace server with local data
|
||||||
|
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) {
|
||||||
|
// Server is newer - replace local with server data
|
||||||
|
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 {
|
||||||
|
// Timestamps are equal - no sync needed
|
||||||
|
Log.d(TAG, "Data is in sync (timestamps equal), no action needed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.d(TAG, "No data to sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last sync time
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Extract filename and ensure it follows our naming convention
|
||||||
|
val serverFilename = imagePath.substringAfterLast('/')
|
||||||
|
val consistentFilename =
|
||||||
|
if (ImageNamingUtils.isValidImageFilename(serverFilename)) {
|
||||||
|
serverFilename
|
||||||
|
} else {
|
||||||
|
// Generate consistent filename using problem ID and index
|
||||||
|
ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
val localImagePath =
|
||||||
|
ImageUtils.saveImageFromBytesWithFilename(
|
||||||
|
context,
|
||||||
|
imageData,
|
||||||
|
consistentFilename
|
||||||
|
)
|
||||||
|
|
||||||
|
if (localImagePath != null) {
|
||||||
|
// Map original server filename to the full local relative path
|
||||||
|
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('/')
|
||||||
|
|
||||||
|
// Ensure filename follows our naming convention
|
||||||
|
val consistentFilename =
|
||||||
|
if (ImageNamingUtils.isValidImageFilename(filename)) {
|
||||||
|
filename
|
||||||
|
} else {
|
||||||
|
// Generate consistent filename and rename the local file
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
// Update the problem's image path in memory for next sync
|
||||||
|
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()
|
||||||
|
) {
|
||||||
|
// Clear existing data to avoid conflicts
|
||||||
|
repository.resetAllData()
|
||||||
|
|
||||||
|
// Import gyms first (problems depend on gyms)
|
||||||
|
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 // Stop import if gym fails since problems depend on it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import problems with updated image paths
|
||||||
|
backup.problems.forEach { backupProblem ->
|
||||||
|
try {
|
||||||
|
val updatedProblem =
|
||||||
|
if (imagePathMapping.isNotEmpty()) {
|
||||||
|
val newImagePaths =
|
||||||
|
backupProblem.imagePaths?.map { oldPath ->
|
||||||
|
// Extract filename and check mapping
|
||||||
|
val filename = oldPath.substringAfterLast('/')
|
||||||
|
// Use mapped full path or fallback to consistent naming
|
||||||
|
// with full path
|
||||||
|
imagePathMapping[filename]
|
||||||
|
?: if (ImageNamingUtils.isValidImageFilename(
|
||||||
|
filename
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
"problem_images/$filename"
|
||||||
|
} else {
|
||||||
|
// Generate consistent filename as fallback with
|
||||||
|
// full path
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import sessions
|
||||||
|
backup.sessions.forEach { backupSession ->
|
||||||
|
try {
|
||||||
|
repository.insertSessionWithoutSync(backupSession.toClimbSession())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to import session '${backupSession.id}': ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import attempts last
|
||||||
|
backup.attempts.forEach { backupAttempt ->
|
||||||
|
try {
|
||||||
|
repository.insertAttemptWithoutSync(backupAttempt.toAttempt())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to import attempt '${backupAttempt.id}': ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local data state to match imported data timestamp
|
||||||
|
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("/")) {
|
||||||
|
// Just a filename, add the directory prefix
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if sync is already running to prevent duplicate attempts
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
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))
|
||||||
|
|
||||||
|
// Notification permission state
|
||||||
|
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
|
||||||
|
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Permission launcher
|
||||||
|
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) }
|
||||||
|
|
||||||
|
// Trigger auto-sync on app launch
|
||||||
|
LaunchedEffect(Unit) { syncService.triggerAutoSync() }
|
||||||
|
|
||||||
|
val activeSession by viewModel.activeSession.collectAsState()
|
||||||
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
|
|
||||||
|
// Update last used gym when gyms change
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process shortcut actions after data is loaded
|
||||||
|
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 {
|
||||||
|
// Try to get the last used gym from the intent or fallback to state
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the shortcut action after processing to prevent repeated execution
|
||||||
|
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 {
|
||||||
|
// Always show gym selection for FAB when
|
||||||
|
// multiple gyms
|
||||||
|
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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification permission dialog
|
||||||
|
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) {
|
||||||
|
// Clear the entire back stack and go to the selected tab's root screen
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
// Avoid multiple copies of the same destination when
|
||||||
|
// reselecting the same item
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -3,18 +3,17 @@ package com.atridad.openclimb.ui.components
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.rounded.Close
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.data.model.ClimbSession
|
import com.atridad.openclimb.data.model.ClimbSession
|
||||||
import com.atridad.openclimb.data.model.Gym
|
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.LocalDateTime
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
@@ -26,6 +25,16 @@ fun ActiveSessionBanner(
|
|||||||
onEndSession: () -> Unit
|
onEndSession: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (activeSession != null) {
|
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(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -69,7 +78,7 @@ fun ActiveSessionBanner(
|
|||||||
)
|
)
|
||||||
|
|
||||||
activeSession.startTime?.let { startTime ->
|
activeSession.startTime?.let { startTime ->
|
||||||
val duration = calculateDuration(startTime)
|
val duration = calculateDuration(startTime, currentTime)
|
||||||
Text(
|
Text(
|
||||||
text = duration,
|
text = duration,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -86,7 +95,7 @@ fun ActiveSessionBanner(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Close,
|
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
|
||||||
contentDescription = "End session"
|
contentDescription = "End session"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -95,94 +104,20 @@ fun ActiveSessionBanner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String {
|
||||||
fun StartSessionButton(
|
|
||||||
gyms: List<Gym>,
|
|
||||||
onStartSession: (String) -> Unit
|
|
||||||
) {
|
|
||||||
var showGymSelection by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (gyms.isEmpty()) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "No gyms available",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Add a gym first to start a session",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button(
|
|
||||||
onClick = { showGymSelection = true },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text("Start Session")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showGymSelection) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showGymSelection = false },
|
|
||||||
title = { Text("Select Gym") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
gyms.forEach { gym ->
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
onStartSession(gym.id)
|
|
||||||
showGymSelection = false
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = gym.name,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { showGymSelection = false }) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateDuration(startTimeString: String): String {
|
|
||||||
return try {
|
return try {
|
||||||
val startTime = LocalDateTime.parse(startTimeString)
|
val startTime = LocalDateTime.parse(startTimeString)
|
||||||
val now = LocalDateTime.now()
|
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
|
||||||
val minutes = ChronoUnit.MINUTES.between(startTime, now)
|
val hours = totalSeconds / 3600
|
||||||
val hours = minutes / 60
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
val remainingMinutes = minutes % 60
|
val seconds = totalSeconds % 60
|
||||||
|
|
||||||
when {
|
when {
|
||||||
hours > 0 -> "${hours}h ${remainingMinutes}m"
|
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||||
remainingMinutes > 0 -> "${remainingMinutes}m"
|
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||||
else -> "< 1m"
|
else -> "${totalSeconds}s"
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
"Active"
|
"Active"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 for proper ordering
|
||||||
|
val sortedData = data.sortedBy { it.gradeNumeric }
|
||||||
|
|
||||||
|
// Calculate max value for scaling
|
||||||
|
val maxValue = sortedData.maxOfOrNull { it.value } ?: 1
|
||||||
|
|
||||||
|
// Calculate bar dimensions
|
||||||
|
val barCount = sortedData.size
|
||||||
|
val totalSpacing = chartWidth * 0.2f // 20% of width for spacing
|
||||||
|
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 top of bar (if there's space)
|
||||||
|
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 on top of bar or inside if bar is tall enough
|
||||||
|
val textY =
|
||||||
|
if (barHeight > textSize.size.height + 8.dp.toPx()) {
|
||||||
|
barY + 8.dp.toPx() // Inside bar
|
||||||
|
} else {
|
||||||
|
barY - 4.dp.toPx() // Above bar
|
||||||
|
}
|
||||||
|
|
||||||
|
val textColor =
|
||||||
|
if (barHeight > textSize.size.height + 8.dp.toPx()) {
|
||||||
|
Color.White // White text inside bar
|
||||||
|
} else {
|
||||||
|
style.textColor // Regular color above bar
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Draw horizontal grid lines (Y-axis)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ fun FullscreenImageViewer(
|
|||||||
LaunchedEffect(pagerState.currentPage) {
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
thumbnailListState.animateScrollToItem(
|
thumbnailListState.animateScrollToItem(
|
||||||
index = pagerState.currentPage,
|
index = pagerState.currentPage,
|
||||||
scrollOffset = -200 // Center the item
|
scrollOffset = -200
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.atridad.openclimb.ui.components
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -20,7 +19,6 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.atridad.openclimb.utils.ImageUtils
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImagePicker(
|
fun ImagePicker(
|
||||||
@@ -41,7 +39,7 @@ fun ImagePicker(
|
|||||||
val remainingSlots = maxImages - currentCount
|
val remainingSlots = maxImages - currentCount
|
||||||
val urisToProcess = uris.take(remainingSlots)
|
val urisToProcess = uris.take(remainingSlots)
|
||||||
|
|
||||||
// Process each selected image
|
// Process images
|
||||||
val newImagePaths = mutableListOf<String>()
|
val newImagePaths = mutableListOf<String>()
|
||||||
urisToProcess.forEach { uri ->
|
urisToProcess.forEach { uri ->
|
||||||
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,13 +63,4 @@ val ClimbNeutralVariant30 = Color(0xFF484848)
|
|||||||
val ClimbNeutralVariant50 = Color(0xFF797979)
|
val ClimbNeutralVariant50 = Color(0xFF797979)
|
||||||
val ClimbNeutralVariant60 = Color(0xFF939393)
|
val ClimbNeutralVariant60 = Color(0xFF939393)
|
||||||
val ClimbNeutralVariant80 = Color(0xFFC7C7C7)
|
val ClimbNeutralVariant80 = Color(0xFFC7C7C7)
|
||||||
val ClimbNeutralVariant90 = Color(0xFFE3E3E3)
|
val ClimbNeutralVariant90 = Color(0xFFE3E3E3)
|
||||||
|
|
||||||
// Legacy colors for backward compatibility
|
|
||||||
val Purple80 = ClimbOrange80
|
|
||||||
val PurpleGrey80 = ClimbGrey80
|
|
||||||
val Pink80 = ClimbBlue80
|
|
||||||
|
|
||||||
val Purple40 = ClimbOrange40
|
|
||||||
val PurpleGrey40 = ClimbGrey40
|
|
||||||
val Pink40 = ClimbBlue40
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.atridad.openclimb.ui.theme
|
package com.atridad.openclimb.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
@@ -10,7 +9,6 @@ import androidx.compose.material3.dynamicLightColorScheme
|
|||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
@@ -98,7 +96,7 @@ fun OpenClimbTheme(
|
|||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
dynamicColor && true -> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
}
|
}
|
||||||
@@ -110,8 +108,8 @@ fun OpenClimbTheme(
|
|||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as Activity).window
|
val window = (view.context as Activity).window
|
||||||
window.statusBarColor = colorScheme.primary.toArgb()
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -3,15 +3,17 @@ package com.atridad.openclimb.ui.viewmodel
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||||
|
import com.atridad.openclimb.data.sync.SyncService
|
||||||
|
|
||||||
class ClimbViewModelFactory(
|
class ClimbViewModelFactory(
|
||||||
private val repository: ClimbRepository
|
private val repository: ClimbRepository,
|
||||||
|
private val syncService: SyncService
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
|
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
|
||||||
return ClimbViewModel(repository) as T
|
return ClimbViewModel(repository, syncService) as T
|
||||||
}
|
}
|
||||||
throw IllegalArgumentException("Unknown ViewModel class")
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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 Produces dates like:
|
||||||
|
* "2025-09-07T22:00:40.014Z"
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @return Current timestamp as "2025-09-07T22:00:40.014Z"
|
||||||
|
*/
|
||||||
|
fun nowISO8601(): String {
|
||||||
|
return ISO_FORMATTER.format(Instant.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an Instant to iOS-compatible ISO 8601 format
|
||||||
|
* @param instant The instant to format
|
||||||
|
* @return Formatted timestamp as "2025-09-07T22:00:40.014Z"
|
||||||
|
*/
|
||||||
|
fun formatISO8601(instant: Instant): String {
|
||||||
|
return ISO_FORMATTER.format(instant)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an iOS-compatible ISO 8601 date string back to Instant
|
||||||
|
* @param dateString ISO 8601 formatted date string
|
||||||
|
* @return Instant object, or null if parsing fails
|
||||||
|
*/
|
||||||
|
fun parseISO8601(dateString: String): Instant? {
|
||||||
|
return try {
|
||||||
|
Instant.from(ISO_FORMATTER.parse(dateString))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fallback - try standard Instant parsing
|
||||||
|
try {
|
||||||
|
Instant.parse(dateString)
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a date string matches the expected iOS format
|
||||||
|
* @param dateString The date string to validate
|
||||||
|
* @return True if the format matches iOS expectations
|
||||||
|
*/
|
||||||
|
fun isValidISO8601(dateString: String): Boolean {
|
||||||
|
return parseISO8601(dateString) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert milliseconds timestamp to iOS-compatible ISO 8601 format
|
||||||
|
* @param millis Milliseconds since epoch
|
||||||
|
* @return Formatted timestamp as "2025-09-07T22:00:40.014Z"
|
||||||
|
*/
|
||||||
|
fun millisToISO8601(millis: Long): String {
|
||||||
|
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
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. Format:
|
||||||
|
* "problem_{problemId}_{timestamp}_{index}.jpg"
|
||||||
|
*
|
||||||
|
* @param problemId The ID of the problem this image belongs to
|
||||||
|
* @param timestamp ISO8601 timestamp when the image was created
|
||||||
|
* @param imageIndex The index of this image for the problem (0, 1, 2, etc.)
|
||||||
|
* @return A consistent filename that will be the same across platforms
|
||||||
|
*/
|
||||||
|
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 for a problem image using current timestamp.
|
||||||
|
*
|
||||||
|
* @param problemId The ID of the problem this image belongs to
|
||||||
|
* @param imageIndex The index of this image for the problem (0, 1, 2, etc.)
|
||||||
|
* @return A consistent filename
|
||||||
|
*/
|
||||||
|
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
||||||
|
val timestamp = DateFormatUtils.nowISO8601()
|
||||||
|
return generateImageFilename(problemId, timestamp, imageIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts problem ID from an image filename created by this utility. Returns null if the
|
||||||
|
* filename doesn't match our naming convention.
|
||||||
|
*
|
||||||
|
* @param filename The image filename
|
||||||
|
* @return The problem ID or null if not a valid 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.
|
||||||
|
*
|
||||||
|
* @param filename The filename to validate
|
||||||
|
* @return true if it matches our convention, false otherwise
|
||||||
|
*/
|
||||||
|
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 UUID-based filename to our naming convention. This is used during sync
|
||||||
|
* to rename downloaded images.
|
||||||
|
*
|
||||||
|
* @param oldFilename The existing filename (UUID-based)
|
||||||
|
* @param problemId The problem ID this image belongs to
|
||||||
|
* @param imageIndex The index of this image
|
||||||
|
* @return The new filename following our 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. Uses SHA-256 and takes first 12 characters
|
||||||
|
* for filename safety.
|
||||||
|
*
|
||||||
|
* @param input The input string to hash
|
||||||
|
* @return First 12 characters of SHA-256 hash in lowercase
|
||||||
|
*/
|
||||||
|
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. Returns a mapping of old
|
||||||
|
* filename -> new filename.
|
||||||
|
*
|
||||||
|
* @param problemId The problem ID
|
||||||
|
* @param existingFilenames List of current image filenames for this problem
|
||||||
|
* @return Map of old filename to new filename
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
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
|
||||||
|
* @param context Android context
|
||||||
|
* @param imageUri URI of the image to save
|
||||||
|
* @param problemId The problem ID this image belongs to (optional)
|
||||||
|
* @param imageIndex The index of this image for the problem (optional)
|
||||||
|
* @return The relative file path if successful, null otherwise
|
||||||
|
*/
|
||||||
|
fun saveImageFromUri(
|
||||||
|
context: Context,
|
||||||
|
imageUri: Uri,
|
||||||
|
problemId: String? = null,
|
||||||
|
imageIndex: Int? = null
|
||||||
|
): String? {
|
||||||
|
return try {
|
||||||
|
// Decode bitmap from a fresh stream to avoid mark/reset dependency
|
||||||
|
val originalBitmap =
|
||||||
|
context.contentResolver.openInputStream(imageUri)?.use { input ->
|
||||||
|
BitmapFactory.decodeStream(input)
|
||||||
|
}
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||||
|
val compressedBitmap = compressImage(orientedBitmap)
|
||||||
|
|
||||||
|
// Generate filename using naming convention if problem info provided
|
||||||
|
val filename =
|
||||||
|
if (problemId != null && imageIndex != null) {
|
||||||
|
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
||||||
|
} else {
|
||||||
|
"${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
|
||||||
|
originalBitmap.recycle()
|
||||||
|
if (orientedBitmap != originalBitmap) {
|
||||||
|
orientedBitmap.recycle()
|
||||||
|
}
|
||||||
|
compressedBitmap.recycle()
|
||||||
|
|
||||||
|
// Return relative path
|
||||||
|
"$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
|
||||||
|
* @param context Android context
|
||||||
|
* @param relativePath The relative path returned by saveImageFromUri
|
||||||
|
* @return Full file path
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @param context Android context
|
||||||
|
* @param relativePath The relative path of the image to delete
|
||||||
|
* @return true if deleted successfully, false otherwise
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @param context Android context
|
||||||
|
* @param sourceFile The source image file to import
|
||||||
|
* @return The relative path in app storage, null if failed
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @param context Android context
|
||||||
|
* @return List of relative paths for all images
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @param context Android context
|
||||||
|
* @param imageData Byte array of the image data
|
||||||
|
* @return The relative file path if successful, null otherwise
|
||||||
|
*/
|
||||||
|
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 (used for sync to preserve server filenames)
|
||||||
|
* @param context Android context
|
||||||
|
* @param imageData The image data as byte array
|
||||||
|
* @param filename The specific filename to use (including extension)
|
||||||
|
* @return The relative file path if successful, null otherwise
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @param context Android context
|
||||||
|
* @param problemId The problem ID these images belong to
|
||||||
|
* @param currentImagePaths List of current image paths for this problem
|
||||||
|
* @return Map of old path -> new path for successfully migrated images
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @param context Android context
|
||||||
|
* @param problemImageMap Map of problem ID -> list of current image paths
|
||||||
|
* @return Map of old path -> new path for all migrated images
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @param context Android context
|
||||||
|
* @param referencedPaths Set of image paths that are still being used
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
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
|
||||||
|
* @param context Android context
|
||||||
|
* @param exportData The data to export (should be serializable)
|
||||||
|
* @param referencedImagePaths Set of image paths referenced in the data
|
||||||
|
* @param directory Optional directory to save to, uses default if null
|
||||||
|
* @return The created ZIP file
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @param context Android context
|
||||||
|
* @param uri The URI to write to
|
||||||
|
* @param exportData The data to export
|
||||||
|
* @param referencedImagePaths Set of image paths referenced in the data
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @param context Android context
|
||||||
|
* @param zipFile The ZIP file to extract
|
||||||
|
* @return ImportResult containing the JSON and image path mappings
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
9
android/app/src/main/res/drawable-night/ic_stop_24.xml
Normal file
9
android/app/src/main/res/drawable-night/ic_stop_24.xml
Normal 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>
|
||||||
11
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
11
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||||
30
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
30
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||||
32
android/app/src/main/res/drawable/ic_mountains.xml
Normal file
32
android/app/src/main/res/drawable/ic_mountains.xml
Normal 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>
|
||||||
9
android/app/src/main/res/drawable/ic_play_arrow_24.xml
Normal file
9
android/app/src/main/res/drawable/ic_play_arrow_24.xml
Normal 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>
|
||||||
9
android/app/src/main/res/drawable/ic_stop_24.xml
Normal file
9
android/app/src/main/res/drawable/ic_stop_24.xml
Normal 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>
|
||||||
15
android/app/src/main/res/drawable/widget_background.xml
Normal file
15
android/app/src/main/res/drawable/widget_background.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
195
android/app/src/main/res/layout/widget_climb_stats.xml
Normal file
195
android/app/src/main/res/layout/widget_climb_stats.xml
Normal 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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user