Compare commits
18 Commits
f1a00b52c2
...
0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
15a5e217a5
|
|||
|
b86ab591fe
|
|||
|
70c85d159e
|
|||
|
d6c5e937df
|
|||
|
829bbbff7a
|
|||
|
e1ebf412bd
|
|||
|
5c133b655e
|
|||
|
cc1edbc65c
|
|||
|
ca770b9db3
|
|||
|
7edb7c8191
|
|||
|
1ca6b33882
|
|||
|
bd6b5cc652
|
|||
|
6e16a30429
|
|||
|
66fdef78d9
|
|||
| 87195aabf1 | |||
|
92f43daec1
|
|||
|
4617f02e4f
|
|||
|
87b0334bad
|
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
release/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Log/OS Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 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
|
||||||
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?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
Normal file
@@ -0,0 +1,835 @@
|
|||||||
|
<?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
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?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>
|
||||||
19
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="gradleJvm" value="#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
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<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
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?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
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?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>
|
||||||
5
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?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="temurin-21" project-jdk-type="JavaSDK" />
|
||||||
|
</project>
|
||||||
17
.idea/runConfigurations.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?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
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
16
README.md
@@ -1,2 +1,18 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
You have two options:
|
||||||
|
|
||||||
|
1. Download the latest APK from the Released page
|
||||||
|
2. Use <a href="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">Obtainium</a>
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Android 15+
|
||||||
|
|
||||||
|
## Contribution
|
||||||
|
|
||||||
|
As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out.
|
||||||
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
88
app/build.gradle.kts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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 = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.atridad.openclimb"
|
||||||
|
minSdk = 31
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 8
|
||||||
|
versionName = "0.4.1"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "11"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// Charts - Placeholder for future implementation
|
||||||
|
// Charts will be implemented with a stable library in future versions
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
|
}
|
||||||
21
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.atridad.openclimb
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("com.atridad.openclimb", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Permissions for file access and camera -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<!-- Permissions for notifications and foreground service -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.OpenClimb"
|
||||||
|
tools:targetApi="31">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.OpenClimb">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- FileProvider for sharing images -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_provider_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
<!-- Session tracking service -->
|
||||||
|
<service
|
||||||
|
android:name=".service.SessionTrackingService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.foreground_service_type"
|
||||||
|
android:value="specialUse" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
27
app/src/main/java/com/atridad/openclimb/MainActivity.kt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package com.atridad.openclimb
|
||||||
|
|
||||||
|
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.ui.Modifier
|
||||||
|
import com.atridad.openclimb.ui.OpenClimbApp
|
||||||
|
import com.atridad.openclimb.ui.theme.OpenClimbTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
OpenClimbTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
OpenClimbApp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.atridad.openclimb.data.database
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import com.atridad.openclimb.data.model.*
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
class Converters {
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromClimbTypeList(value: List<ClimbType>): String {
|
||||||
|
return Json.encodeToString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toClimbTypeList(value: String): List<ClimbType> {
|
||||||
|
return Json.decodeFromString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromDifficultySystemList(value: List<DifficultySystem>): String {
|
||||||
|
return Json.encodeToString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toDifficultySystemList(value: String): List<DifficultySystem> {
|
||||||
|
return Json.decodeFromString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromStringList(value: List<String>): String {
|
||||||
|
return Json.encodeToString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toStringList(value: String): List<String> {
|
||||||
|
return Json.decodeFromString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromDifficultyGrade(value: DifficultyGrade): String {
|
||||||
|
return Json.encodeToString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toDifficultyGrade(value: String): DifficultyGrade {
|
||||||
|
return Json.decodeFromString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromClimbType(value: ClimbType): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toClimbType(value: String): ClimbType {
|
||||||
|
return ClimbType.valueOf(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromAttemptResult(value: AttemptResult): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toAttemptResult(value: String): AttemptResult {
|
||||||
|
return AttemptResult.valueOf(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromSessionStatus(value: SessionStatus): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toSessionStatus(value: String): SessionStatus {
|
||||||
|
return SessionStatus.valueOf(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.atridad.openclimb.data.database
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
import android.content.Context
|
||||||
|
import com.atridad.openclimb.data.database.dao.*
|
||||||
|
import com.atridad.openclimb.data.model.*
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
Gym::class,
|
||||||
|
Problem::class,
|
||||||
|
ClimbSession::class,
|
||||||
|
Attempt::class
|
||||||
|
],
|
||||||
|
version = 5,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
abstract class OpenClimbDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun gymDao(): GymDao
|
||||||
|
abstract fun problemDao(): ProblemDao
|
||||||
|
abstract fun climbSessionDao(): ClimbSessionDao
|
||||||
|
abstract fun attemptDao(): AttemptDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: OpenClimbDatabase? = null
|
||||||
|
|
||||||
|
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
val cursor = database.query("PRAGMA table_info(climb_sessions)")
|
||||||
|
val existingColumns = mutableSetOf<String>()
|
||||||
|
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val columnName = cursor.getString(cursor.getColumnIndexOrThrow("name"))
|
||||||
|
existingColumns.add(columnName)
|
||||||
|
}
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
if (!existingColumns.contains("startTime")) {
|
||||||
|
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT")
|
||||||
|
}
|
||||||
|
if (!existingColumns.contains("endTime")) {
|
||||||
|
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT")
|
||||||
|
}
|
||||||
|
if (!existingColumns.contains("status")) {
|
||||||
|
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'")
|
||||||
|
}
|
||||||
|
|
||||||
|
database.execSQL("UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL")
|
||||||
|
database.execSQL("UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDatabase(context: Context): OpenClimbDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
OpenClimbDatabase::class.java,
|
||||||
|
"openclimb_database"
|
||||||
|
)
|
||||||
|
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
|
||||||
|
.enableMultiInstanceInvalidation()
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.atridad.openclimb.data.database.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.atridad.openclimb.data.model.Attempt
|
||||||
|
import com.atridad.openclimb.data.model.AttemptResult
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface AttemptDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts ORDER BY timestamp DESC")
|
||||||
|
fun getAllAttempts(): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE id = :id")
|
||||||
|
suspend fun getAttemptById(id: String): Attempt?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE sessionId = :sessionId ORDER BY timestamp ASC")
|
||||||
|
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE problemId = :problemId ORDER BY timestamp DESC")
|
||||||
|
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE sessionId = :sessionId AND problemId = :problemId ORDER BY timestamp ASC")
|
||||||
|
fun getAttemptsBySessionAndProblem(sessionId: String, problemId: String): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE result = :result ORDER BY timestamp DESC")
|
||||||
|
fun getAttemptsByResult(result: AttemptResult): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE timestamp BETWEEN :startDate AND :endDate ORDER BY timestamp DESC")
|
||||||
|
fun getAttemptsInDateRange(startDate: String, endDate: String): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAttempt(attempt: Attempt)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAttempts(attempts: List<Attempt>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateAttempt(attempt: Attempt)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteAttempt(attempt: Attempt)
|
||||||
|
|
||||||
|
@Query("DELETE FROM attempts WHERE id = :id")
|
||||||
|
suspend fun deleteAttemptById(id: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM attempts WHERE sessionId = :sessionId")
|
||||||
|
suspend fun deleteAttemptsBySession(sessionId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM attempts WHERE problemId = :problemId")
|
||||||
|
suspend fun deleteAttemptsByProblem(problemId: String)
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM attempts")
|
||||||
|
suspend fun getAttemptsCount(): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId")
|
||||||
|
suspend fun getAttemptsCountBySession(sessionId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM attempts WHERE problemId = :problemId")
|
||||||
|
suspend fun getAttemptsCountByProblem(problemId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM attempts WHERE result = :result")
|
||||||
|
suspend fun getAttemptsCountByResult(result: AttemptResult): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM attempts WHERE problemId = :problemId AND result IN ('SUCCESS', 'FLASH', 'REDPOINT', 'ONSIGHT')")
|
||||||
|
suspend fun getSuccessfulAttemptsCountByProblem(problemId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE problemId = :problemId AND result IN ('SUCCESS', 'FLASH', 'REDPOINT', 'ONSIGHT') ORDER BY timestamp ASC LIMIT 1")
|
||||||
|
suspend fun getFirstSuccessfulAttempt(problemId: String): Attempt?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE problemId = :problemId ORDER BY timestamp DESC LIMIT 1")
|
||||||
|
suspend fun getLatestAttemptForProblem(problemId: String): Attempt?
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.atridad.openclimb.data.database.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.atridad.openclimb.data.model.ClimbSession
|
||||||
|
import com.atridad.openclimb.data.model.SessionStatus
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ClimbSessionDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions ORDER BY date DESC")
|
||||||
|
fun getAllSessions(): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE id = :id")
|
||||||
|
suspend fun getSessionById(id: String): ClimbSession?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE gymId = :gymId ORDER BY date DESC")
|
||||||
|
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE date = :date ORDER BY createdAt DESC")
|
||||||
|
fun getSessionsByDate(date: String): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
||||||
|
fun getSessionsInDateRange(startDate: String, endDate: String): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions ORDER BY date DESC LIMIT :limit")
|
||||||
|
fun getRecentSessions(limit: Int = 10): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertSession(session: ClimbSession)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertSessions(sessions: List<ClimbSession>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateSession(session: ClimbSession)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteSession(session: ClimbSession)
|
||||||
|
|
||||||
|
@Query("DELETE FROM climb_sessions WHERE id = :id")
|
||||||
|
suspend fun deleteSessionById(id: String)
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM climb_sessions")
|
||||||
|
suspend fun getSessionsCount(): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM climb_sessions WHERE gymId = :gymId")
|
||||||
|
suspend fun getSessionsCountByGym(gymId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM climb_sessions WHERE date BETWEEN :startDate AND :endDate")
|
||||||
|
suspend fun getSessionsCountInDateRange(startDate: String, endDate: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT DISTINCT date FROM climb_sessions ORDER BY date DESC")
|
||||||
|
suspend fun getUniqueDates(): List<String>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE status = :status ORDER BY date DESC")
|
||||||
|
fun getSessionsByStatus(status: SessionStatus): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
||||||
|
suspend fun getActiveSession(): ClimbSession?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
||||||
|
fun getActiveSessionFlow(): Flow<ClimbSession?>
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.atridad.openclimb.data.database.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.atridad.openclimb.data.model.ClimbType
|
||||||
|
import com.atridad.openclimb.data.model.Gym
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface GymDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM gyms ORDER BY name ASC")
|
||||||
|
fun getAllGyms(): Flow<List<Gym>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM gyms WHERE id = :id")
|
||||||
|
suspend fun getGymById(id: String): Gym?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM gyms WHERE :climbType IN (supportedClimbTypes)")
|
||||||
|
fun getGymsByClimbType(climbType: ClimbType): Flow<List<Gym>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertGym(gym: Gym)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertGyms(gyms: List<Gym>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateGym(gym: Gym)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteGym(gym: Gym)
|
||||||
|
|
||||||
|
@Query("DELETE FROM gyms WHERE id = :id")
|
||||||
|
suspend fun deleteGymById(id: String)
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM gyms")
|
||||||
|
suspend fun getGymsCount(): Int
|
||||||
|
|
||||||
|
@Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'")
|
||||||
|
fun searchGyms(searchQuery: String): Flow<List<Gym>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.atridad.openclimb.data.database.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.atridad.openclimb.data.model.ClimbType
|
||||||
|
import com.atridad.openclimb.data.model.Problem
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ProblemDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems ORDER BY updatedAt DESC")
|
||||||
|
fun getAllProblems(): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE id = :id")
|
||||||
|
suspend fun getProblemById(id: String): Problem?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC")
|
||||||
|
fun getProblemsByGym(gymId: String): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC")
|
||||||
|
fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC")
|
||||||
|
fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC")
|
||||||
|
fun getActiveProblems(): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC")
|
||||||
|
fun getActiveProblemsByGym(gymId: String): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertProblem(problem: Problem)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertProblems(problems: List<Problem>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateProblem(problem: Problem)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteProblem(problem: Problem)
|
||||||
|
|
||||||
|
@Query("DELETE FROM problems WHERE id = :id")
|
||||||
|
suspend fun deleteProblemById(id: String)
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId")
|
||||||
|
suspend fun getProblemsCountByGym(gymId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM problems WHERE isActive = 1")
|
||||||
|
suspend fun getActiveProblemsCount(): Int
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM problems
|
||||||
|
WHERE (name LIKE '%' || :searchQuery || '%'
|
||||||
|
OR description LIKE '%' || :searchQuery || '%'
|
||||||
|
OR location LIKE '%' || :searchQuery || '%'
|
||||||
|
OR setter LIKE '%' || :searchQuery || '%')
|
||||||
|
ORDER BY updatedAt DESC
|
||||||
|
""")
|
||||||
|
fun searchProblems(searchQuery: String): Flow<List<Problem>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.atridad.openclimb.data.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@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 = LocalDateTime.now().toString()
|
||||||
|
): Attempt {
|
||||||
|
val now = LocalDateTime.now().toString()
|
||||||
|
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,81 @@
|
|||||||
|
package com.atridad.openclimb.data.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@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 = LocalDateTime.now().toString()
|
||||||
|
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 = LocalDateTime.now().toString()
|
||||||
|
val durationMinutes = if (startTime != null) {
|
||||||
|
try {
|
||||||
|
val start = LocalDateTime.parse(startTime)
|
||||||
|
val end = LocalDateTime.parse(endTime)
|
||||||
|
java.time.Duration.between(start, end).toMinutes()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
|
||||||
|
return this.copy(
|
||||||
|
endTime = endTime,
|
||||||
|
duration = durationMinutes,
|
||||||
|
status = SessionStatus.COMPLETED,
|
||||||
|
updatedAt = LocalDateTime.now().toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,108 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/src/main/java/com/atridad/openclimb/data/model/Gym.kt
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package com.atridad.openclimb.data.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@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 = LocalDateTime.now().toString()
|
||||||
|
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,75 @@
|
|||||||
|
package com.atridad.openclimb.data.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@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 setter: String? = null,
|
||||||
|
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,
|
||||||
|
setter: String? = null,
|
||||||
|
tags: List<String> = emptyList(),
|
||||||
|
location: String? = null,
|
||||||
|
imagePaths: List<String> = emptyList(),
|
||||||
|
dateSet: String? = null,
|
||||||
|
notes: String? = null
|
||||||
|
): Problem {
|
||||||
|
val now = LocalDateTime.now().toString()
|
||||||
|
return Problem(
|
||||||
|
id = java.util.UUID.randomUUID().toString(),
|
||||||
|
gymId = gymId,
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
climbType = climbType,
|
||||||
|
difficulty = difficulty,
|
||||||
|
setter = setter,
|
||||||
|
tags = tags,
|
||||||
|
location = location,
|
||||||
|
imagePaths = imagePaths,
|
||||||
|
isActive = true,
|
||||||
|
dateSet = dateSet,
|
||||||
|
notes = notes,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package com.atridad.openclimb.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Environment
|
||||||
|
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||||
|
import com.atridad.openclimb.data.model.*
|
||||||
|
import com.atridad.openclimb.utils.ZipExportImportUtils
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
|
||||||
|
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
|
||||||
|
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
|
||||||
|
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
|
||||||
|
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
|
||||||
|
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
|
||||||
|
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// JSON Export
|
||||||
|
suspend fun exportAllDataToJson(directory: File? = null): File {
|
||||||
|
val exportDir = directory ?: File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "OpenClimb")
|
||||||
|
if (!exportDir.exists()) {
|
||||||
|
exportDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
|
||||||
|
val exportFile = File(exportDir, "openclimb_export_$timestamp.json")
|
||||||
|
|
||||||
|
val allGyms = gymDao.getAllGyms().first()
|
||||||
|
val allProblems = problemDao.getAllProblems().first()
|
||||||
|
val allSessions = sessionDao.getAllSessions().first()
|
||||||
|
val allAttempts = attemptDao.getAllAttempts().first()
|
||||||
|
|
||||||
|
val exportData = ClimbDataExport(
|
||||||
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
|
gyms = allGyms,
|
||||||
|
problems = allProblems,
|
||||||
|
sessions = allSessions,
|
||||||
|
attempts = allAttempts
|
||||||
|
)
|
||||||
|
|
||||||
|
val jsonString = json.encodeToString(exportData)
|
||||||
|
exportFile.writeText(jsonString)
|
||||||
|
|
||||||
|
return exportFile
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun exportAllDataToUri(context: Context, uri: android.net.Uri) {
|
||||||
|
val gyms = gymDao.getAllGyms().first()
|
||||||
|
val problems = problemDao.getAllProblems().first()
|
||||||
|
val sessions = sessionDao.getAllSessions().first()
|
||||||
|
val attempts = attemptDao.getAllAttempts().first()
|
||||||
|
|
||||||
|
val exportData = ClimbDataExport(
|
||||||
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
|
gyms = gyms,
|
||||||
|
problems = problems,
|
||||||
|
sessions = sessions,
|
||||||
|
attempts = attempts
|
||||||
|
)
|
||||||
|
|
||||||
|
val jsonString = json.encodeToString(exportData)
|
||||||
|
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
outputStream.write(jsonString.toByteArray())
|
||||||
|
} ?: throw Exception("Could not open output stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importDataFromJson(file: File) {
|
||||||
|
try {
|
||||||
|
val jsonContent = file.readText()
|
||||||
|
val importData = json.decodeFromString<ClimbDataExport>(jsonContent)
|
||||||
|
|
||||||
|
// Import gyms
|
||||||
|
importData.gyms.forEach { gym ->
|
||||||
|
try {
|
||||||
|
gymDao.insertGym(gym)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// If insertion fails, update instead
|
||||||
|
gymDao.updateGym(gym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import problems
|
||||||
|
importData.problems.forEach { problem ->
|
||||||
|
try {
|
||||||
|
problemDao.insertProblem(problem)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
problemDao.updateProblem(problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import sessions
|
||||||
|
importData.sessions.forEach { session ->
|
||||||
|
try {
|
||||||
|
sessionDao.insertSession(session)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
sessionDao.updateSession(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import attempts
|
||||||
|
importData.attempts.forEach { attempt ->
|
||||||
|
try {
|
||||||
|
attemptDao.insertAttempt(attempt)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
attemptDao.updateAttempt(attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Failed to import data: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZIP Export with images
|
||||||
|
suspend fun exportAllDataToZip(directory: File? = null): File {
|
||||||
|
val allGyms = gymDao.getAllGyms().first()
|
||||||
|
val allProblems = problemDao.getAllProblems().first()
|
||||||
|
val allSessions = sessionDao.getAllSessions().first()
|
||||||
|
val allAttempts = attemptDao.getAllAttempts().first()
|
||||||
|
|
||||||
|
val exportData = ClimbDataExport(
|
||||||
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
|
gyms = allGyms,
|
||||||
|
problems = allProblems,
|
||||||
|
sessions = allSessions,
|
||||||
|
attempts = allAttempts
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect all referenced image paths
|
||||||
|
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||||
|
|
||||||
|
return ZipExportImportUtils.createExportZip(
|
||||||
|
context = context,
|
||||||
|
exportData = exportData,
|
||||||
|
referencedImagePaths = referencedImagePaths,
|
||||||
|
directory = directory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||||
|
val gyms = gymDao.getAllGyms().first()
|
||||||
|
val problems = problemDao.getAllProblems().first()
|
||||||
|
val sessions = sessionDao.getAllSessions().first()
|
||||||
|
val attempts = attemptDao.getAllAttempts().first()
|
||||||
|
|
||||||
|
val exportData = ClimbDataExport(
|
||||||
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
|
gyms = gyms,
|
||||||
|
problems = problems,
|
||||||
|
sessions = sessions,
|
||||||
|
attempts = attempts
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect all image paths
|
||||||
|
val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet()
|
||||||
|
|
||||||
|
ZipExportImportUtils.createExportZipToUri(
|
||||||
|
context = context,
|
||||||
|
uri = uri,
|
||||||
|
exportData = exportData,
|
||||||
|
referencedImagePaths = referencedImagePaths
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importDataFromZip(file: File) {
|
||||||
|
try {
|
||||||
|
val importResult = ZipExportImportUtils.extractImportZip(context, file)
|
||||||
|
val importData = json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
|
||||||
|
|
||||||
|
// Update problem image paths with the new imported paths
|
||||||
|
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths(
|
||||||
|
importData.problems,
|
||||||
|
importResult.importedImagePaths
|
||||||
|
)
|
||||||
|
|
||||||
|
// Import gyms
|
||||||
|
importData.gyms.forEach { gym ->
|
||||||
|
try {
|
||||||
|
gymDao.insertGym(gym)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// If insertion fails update instead
|
||||||
|
gymDao.updateGym(gym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import problems with updated image paths
|
||||||
|
updatedProblems.forEach { problem ->
|
||||||
|
try {
|
||||||
|
problemDao.insertProblem(problem)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
problemDao.updateProblem(problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import sessions
|
||||||
|
importData.sessions.forEach { session ->
|
||||||
|
try {
|
||||||
|
sessionDao.insertSession(session)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sessionDao.updateSession(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import attempts
|
||||||
|
importData.attempts.forEach { attempt ->
|
||||||
|
try {
|
||||||
|
attemptDao.insertAttempt(attempt)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
attemptDao.updateAttempt(attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Failed to import data: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
data class ClimbDataExport(
|
||||||
|
val exportedAt: String,
|
||||||
|
val gyms: List<Gym>,
|
||||||
|
val problems: List<Problem>,
|
||||||
|
val sessions: List<ClimbSession>,
|
||||||
|
val attempts: List<Attempt>
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
)
|
||||||
42
app/src/main/java/com/atridad/openclimb/navigation/Screen.kt
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package com.atridad.openclimb.navigation
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class Screen {
|
||||||
|
@Serializable
|
||||||
|
data object Sessions : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Problems : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Analytics : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Gyms : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Settings : Screen()
|
||||||
|
|
||||||
|
// Detail screens
|
||||||
|
@Serializable
|
||||||
|
data class SessionDetail(val sessionId: String) : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProblemDetail(val problemId: String) : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GymDetail(val gymId: String) : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AddEditGym(val gymId: String? = null) : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen()
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
class SessionTrackingService : Service() {
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
|
private lateinit var repository: ClimbRepository
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
private fun startSessionTracking(sessionId: String) {
|
||||||
|
notificationJob?.cancel()
|
||||||
|
notificationJob = serviceScope.launch {
|
||||||
|
// Initial notification update
|
||||||
|
updateNotification(sessionId)
|
||||||
|
|
||||||
|
// Then update every second
|
||||||
|
while (isActive) {
|
||||||
|
delay(1000L)
|
||||||
|
updateNotification(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopSessionTracking() {
|
||||||
|
notificationJob?.cancel()
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateNotification(sessionId: String) {
|
||||||
|
try {
|
||||||
|
val session = repository.getSessionById(sessionId)
|
||||||
|
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
|
||||||
|
stopSessionTracking()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val gym = repository.getGymById(session.gymId)
|
||||||
|
val attempts = 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)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setContentIntent(createOpenAppIntent())
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_mountains,
|
||||||
|
"Open Session",
|
||||||
|
createOpenAppIntent()
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
android.R.drawable.ic_menu_close_clear_cancel,
|
||||||
|
"End Session",
|
||||||
|
createStopPendingIntent(sessionId)
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Force update the notification every second
|
||||||
|
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Handle errors gracefully
|
||||||
|
stopSessionTracking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createOpenAppIntent(): PendingIntent {
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
}
|
||||||
|
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_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Shows active climbing session information"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
notificationJob?.cancel()
|
||||||
|
serviceScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
264
app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
package com.atridad.openclimb.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
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.navigation.Screen
|
||||||
|
import com.atridad.openclimb.navigation.bottomNavigationItems
|
||||||
|
import com.atridad.openclimb.ui.screens.*
|
||||||
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun OpenClimbApp() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val database = remember { OpenClimbDatabase.getDatabase(context) }
|
||||||
|
val repository = remember { ClimbRepository(database, context) }
|
||||||
|
val viewModel: ClimbViewModel = viewModel(
|
||||||
|
factory = ClimbViewModelFactory(repository)
|
||||||
|
)
|
||||||
|
|
||||||
|
// FAB configuration
|
||||||
|
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)
|
||||||
|
) {
|
||||||
|
// Main screens
|
||||||
|
composable<Screen.Sessions> {
|
||||||
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
|
val activeSession by viewModel.activeSession.collectAsState()
|
||||||
|
LaunchedEffect(gyms, activeSession) {
|
||||||
|
fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
|
||||||
|
FabConfig(
|
||||||
|
icon = Icons.Default.Add,
|
||||||
|
contentDescription = "Start Session",
|
||||||
|
onClick = {
|
||||||
|
if (gyms.size == 1) {
|
||||||
|
viewModel.startSession(context, gyms.first().id)
|
||||||
|
} else {
|
||||||
|
navController.navigate(Screen.AddEditSession())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SessionsScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateToSessionDetail = { sessionId ->
|
||||||
|
navController.navigate(Screen.SessionDetail(sessionId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable<Screen.Problems> {
|
||||||
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
|
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 // No FAB for analytics
|
||||||
|
}
|
||||||
|
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 // No FAB for settings
|
||||||
|
}
|
||||||
|
SettingsScreen(viewModel = viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail screens
|
||||||
|
composable<Screen.SessionDetail> { backStackEntry ->
|
||||||
|
val args = backStackEntry.toRoute<Screen.SessionDetail>()
|
||||||
|
LaunchedEffect(Unit) { fabConfig = null }
|
||||||
|
SessionDetailScreen(
|
||||||
|
sessionId = args.sessionId,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.atridad.openclimb.data.model.ClimbSession
|
||||||
|
import com.atridad.openclimb.data.model.Gym
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ActiveSessionBanner(
|
||||||
|
activeSession: ClimbSession?,
|
||||||
|
gym: Gym?,
|
||||||
|
onSessionClick: () -> Unit,
|
||||||
|
onEndSession: () -> Unit
|
||||||
|
) {
|
||||||
|
if (activeSession != null) {
|
||||||
|
// Add a timer that updates every second for real-time duration counting
|
||||||
|
var currentTime by remember { mutableStateOf(LocalDateTime.now()) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(1000) // Update every second
|
||||||
|
currentTime = LocalDateTime.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSessionClick() },
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
contentDescription = "Active session",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Active Session",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = gym?.name ?: "Unknown Gym",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
|
||||||
|
activeSession.startTime?.let { startTime ->
|
||||||
|
val duration = calculateDuration(startTime, currentTime)
|
||||||
|
Text(
|
||||||
|
text = duration,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onEndSession,
|
||||||
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "End session"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String {
|
||||||
|
return try {
|
||||||
|
val startTime = LocalDateTime.parse(startTimeString)
|
||||||
|
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
|
||||||
|
val hours = totalSeconds / 3600
|
||||||
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
|
||||||
|
when {
|
||||||
|
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||||
|
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||||
|
else -> "${totalSeconds}s"
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
"Active"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun FullscreenImageViewer(
|
||||||
|
imagePaths: List<String>,
|
||||||
|
initialIndex: Int = 0,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = initialIndex,
|
||||||
|
pageCount = { imagePaths.size }
|
||||||
|
)
|
||||||
|
val thumbnailListState = rememberLazyListState()
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Auto-scroll thumbnail list to center current image
|
||||||
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
|
thumbnailListState.animateScrollToItem(
|
||||||
|
index = pagerState.currentPage,
|
||||||
|
scrollOffset = -200
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
decorFitsSystemWindows = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
) {
|
||||||
|
// Main image pager
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) { page ->
|
||||||
|
ZoomableImage(
|
||||||
|
imagePath = imagePaths[page],
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(16.dp)
|
||||||
|
.background(
|
||||||
|
Color.Black.copy(alpha = 0.5f),
|
||||||
|
CircleShape
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image counter
|
||||||
|
if (imagePaths.size > 1) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail strip (if multiple images)
|
||||||
|
if (imagePaths.size > 1) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
state = thumbnailListState,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(imagePaths) { index, imagePath ->
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
val isSelected = index == pagerState.currentPage
|
||||||
|
|
||||||
|
AsyncImage(
|
||||||
|
model = imageFile,
|
||||||
|
contentDescription = "Thumbnail ${index + 1}",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(60.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable {
|
||||||
|
coroutineScope.launch {
|
||||||
|
pagerState.animateScrollToPage(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.then(
|
||||||
|
if (isSelected) {
|
||||||
|
Modifier.background(
|
||||||
|
Color.White.copy(alpha = 0.3f),
|
||||||
|
RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
} else Modifier
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ZoomableImage(
|
||||||
|
imagePath: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
|
||||||
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
|
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTransformGestures(
|
||||||
|
onGesture = { _, pan, zoom, _ ->
|
||||||
|
scale = (scale * zoom).coerceIn(0.5f, 5f)
|
||||||
|
|
||||||
|
val maxOffsetX = (size.width * (scale - 1)) / 2
|
||||||
|
val maxOffsetY = (size.height * (scale - 1)) / 2
|
||||||
|
|
||||||
|
offsetX = (offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
|
||||||
|
offsetY = (offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageFile,
|
||||||
|
contentDescription = "Full screen image",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer(
|
||||||
|
scaleX = scale,
|
||||||
|
scaleY = scale,
|
||||||
|
translationX = offsetX,
|
||||||
|
translationY = offsetY
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImageDisplay(
|
||||||
|
imagePaths: List<String>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
imageSize: Int = 120,
|
||||||
|
onImageClick: ((Int) -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
if (imagePaths.isNotEmpty()) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(imagePaths) { index, imagePath ->
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
|
||||||
|
AsyncImage(
|
||||||
|
model = imageFile,
|
||||||
|
contentDescription = "Problem photo",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(imageSize.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable(enabled = onImageClick != null) {
|
||||||
|
onImageClick?.invoke(index)
|
||||||
|
},
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImageDisplaySection(
|
||||||
|
imagePaths: List<String>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String = "Photos",
|
||||||
|
onImageClick: ((Int) -> Unit)? = null
|
||||||
|
) {
|
||||||
|
if (imagePaths.isNotEmpty()) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
ImageDisplay(
|
||||||
|
imagePaths = imagePaths,
|
||||||
|
imageSize = 120,
|
||||||
|
onImageClick = onImageClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImagePicker(
|
||||||
|
imageUris: List<String>,
|
||||||
|
onImagesChanged: (List<String>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
maxImages: Int = 5
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var tempImageUris by remember { mutableStateOf(imageUris) }
|
||||||
|
|
||||||
|
// Image picker launcher
|
||||||
|
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetMultipleContents()
|
||||||
|
) { uris ->
|
||||||
|
if (uris.isNotEmpty()) {
|
||||||
|
val currentCount = tempImageUris.size
|
||||||
|
val remainingSlots = maxImages - currentCount
|
||||||
|
val urisToProcess = uris.take(remainingSlots)
|
||||||
|
|
||||||
|
// Process images
|
||||||
|
val newImagePaths = mutableListOf<String>()
|
||||||
|
urisToProcess.forEach { uri ->
|
||||||
|
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
||||||
|
if (imagePath != null) {
|
||||||
|
newImagePaths.add(imagePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newImagePaths.isNotEmpty()) {
|
||||||
|
val updatedUris = tempImageUris + newImagePaths
|
||||||
|
tempImageUris = updatedUris
|
||||||
|
onImagesChanged(updatedUris)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Photos (${tempImageUris.size}/$maxImages)",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tempImageUris.size < maxImages) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
imagePickerLauncher.launch("image/*")
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Add Photos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempImageUris.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(tempImageUris) { imagePath ->
|
||||||
|
ImageItem(
|
||||||
|
imagePath = imagePath,
|
||||||
|
onRemove = {
|
||||||
|
val updatedUris = tempImageUris.filter { it != imagePath }
|
||||||
|
tempImageUris = updatedUris
|
||||||
|
onImagesChanged(updatedUris)
|
||||||
|
|
||||||
|
// Delete the image file
|
||||||
|
ImageUtils.deleteImage(context, imagePath)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(100.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Add photos of this problem",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImageItem(
|
||||||
|
imagePath: String,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.size(80.dp)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageFile,
|
||||||
|
contentDescription = "Problem photo",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onRemove,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.size(24.dp)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Remove photo",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(2.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,849 @@
|
|||||||
|
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.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 kotlinx.coroutines.flow.first
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@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 setter 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
|
||||||
|
setter = p.setter ?: ""
|
||||||
|
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,
|
||||||
|
setter = setter.ifBlank { null },
|
||||||
|
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))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = setter,
|
||||||
|
onValueChange = { setter = it },
|
||||||
|
label = { Text("Route Setter (Optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
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 = { difficultyGrade = it },
|
||||||
|
label = { Text("Grade *") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
placeholder = { Text("Enter custom grade") }
|
||||||
|
)
|
||||||
|
} 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()
|
||||||
|
.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()
|
||||||
|
|
||||||
|
// 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 ->
|
||||||
|
val session = ClimbSession.create(
|
||||||
|
gymId = gym.id,
|
||||||
|
notes = sessionNotes.ifBlank { null }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
viewModel.updateSession(session.copy(id = sessionId))
|
||||||
|
} else {
|
||||||
|
viewModel.addSession(session)
|
||||||
|
}
|
||||||
|
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,255 @@
|
|||||||
|
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.ui.viewmodel.ClimbViewModel
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overall Stats
|
||||||
|
item {
|
||||||
|
OverallStatsCard(
|
||||||
|
totalSessions = sessions.size,
|
||||||
|
totalProblems = problems.size,
|
||||||
|
totalAttempts = attempts.size,
|
||||||
|
totalGyms = gyms.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success Rate
|
||||||
|
item {
|
||||||
|
val successfulAttempts = attempts.count {
|
||||||
|
it.result.name in listOf("SUCCESS", "FLASH", "REDPOINT", "ONSIGHT")
|
||||||
|
}
|
||||||
|
val successRate = if (attempts.isNotEmpty()) {
|
||||||
|
(successfulAttempts.toDouble() / attempts.size * 100).toInt()
|
||||||
|
} else 0
|
||||||
|
|
||||||
|
SuccessRateCard(
|
||||||
|
successRate = successRate,
|
||||||
|
successfulAttempts = successfulAttempts,
|
||||||
|
totalAttempts = attempts.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SuccessRateCard(
|
||||||
|
successRate: Int,
|
||||||
|
successfulAttempts: Int,
|
||||||
|
totalAttempts: Int
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Success Rate",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$successRate%",
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = "$successfulAttempts successful",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "out of $totalAttempts attempts",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2221
app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt
Normal file
137
app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
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.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,299 @@
|
|||||||
|
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.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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "Problems & Routes",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
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(filteredProblems) { 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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = gymName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "Inactive",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
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.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,393 @@
|
|||||||
|
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.viewmodel.ClimbViewModel
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
viewModel: ClimbViewModel
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val packageInfo = remember {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
}
|
||||||
|
val appVersion = packageInfo.versionName
|
||||||
|
|
||||||
|
// File picker launcher for import - accepts both ZIP and JSON 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"
|
||||||
|
|
||||||
|
val extension = fileName.substringAfterLast(".", "")
|
||||||
|
val tempFileName = if (extension.isNotEmpty()) "temp_import.$extension" else "temp_import"
|
||||||
|
val tempFile = File(context.cacheDir, tempFileName)
|
||||||
|
|
||||||
|
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 - save location (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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON export launcher
|
||||||
|
val exportJsonLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/json")
|
||||||
|
) { uri ->
|
||||||
|
uri?.let {
|
||||||
|
try {
|
||||||
|
viewModel.exportDataToUri(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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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("Export Data Only") },
|
||||||
|
supportingContent = { Text("Export climbing data to JSON without images") },
|
||||||
|
leadingContent = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||||
|
trailingContent = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val defaultFileName = "openclimb_export_${
|
||||||
|
java.time.LocalDateTime.now()
|
||||||
|
.toString()
|
||||||
|
.replace(":", "-")
|
||||||
|
.replace(".", "-")
|
||||||
|
}.json"
|
||||||
|
exportJsonLauncher.launch(defaultFileName)
|
||||||
|
},
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Export JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 or JSON file") },
|
||||||
|
leadingContent = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||||
|
trailingContent = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
importLauncher.launch("*/*")
|
||||||
|
},
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Import")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/src/main/java/com/atridad/openclimb/ui/theme/Color.kt
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package com.atridad.openclimb.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Climbing-themed Material You color palette
|
||||||
|
// Orange - Primary (represents rock/sandstone climbing)
|
||||||
|
val ClimbOrange10 = Color(0xFF1F0E00)
|
||||||
|
val ClimbOrange20 = Color(0xFF3E1C00)
|
||||||
|
val ClimbOrange30 = Color(0xFF5D2B00)
|
||||||
|
val ClimbOrange40 = Color(0xFF7C3900)
|
||||||
|
val ClimbOrange80 = Color(0xFFFFB786)
|
||||||
|
val ClimbOrange90 = Color(0xFFFFDCC2)
|
||||||
|
val ClimbOrange100 = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
// Grey - Secondary (represents granite/slate)
|
||||||
|
val ClimbGrey10 = Color(0xFF1F1F1F)
|
||||||
|
val ClimbGrey20 = Color(0xFF2F2F2F)
|
||||||
|
val ClimbGrey30 = Color(0xFF484848)
|
||||||
|
val ClimbGrey40 = Color(0xFF606060)
|
||||||
|
val ClimbGrey80 = Color(0xFFC7C7C7)
|
||||||
|
val ClimbGrey90 = Color(0xFFE3E3E3)
|
||||||
|
val ClimbGrey100 = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
// Blue - Tertiary (represents ice/water)
|
||||||
|
val ClimbBlue10 = Color(0xFF001F2A)
|
||||||
|
val ClimbBlue20 = Color(0xFF003544)
|
||||||
|
val ClimbBlue30 = Color(0xFF004D61)
|
||||||
|
val ClimbBlue40 = Color(0xFF00677F)
|
||||||
|
val ClimbBlue80 = Color(0xFF5DDBFF)
|
||||||
|
val ClimbBlue90 = Color(0xFFB8EAFF)
|
||||||
|
val ClimbBlue100 = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
// Red - Error colors
|
||||||
|
val ClimbRed10 = Color(0xFF410001)
|
||||||
|
val ClimbRed20 = Color(0xFF680003)
|
||||||
|
val ClimbRed30 = Color(0xFF930006)
|
||||||
|
val ClimbRed40 = Color(0xFFBA1B1B)
|
||||||
|
val ClimbRed80 = Color(0xFFFFB4A9)
|
||||||
|
val ClimbRed90 = Color(0xFFFFDAD4)
|
||||||
|
val ClimbRed100 = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
// Neutral colors for surfaces
|
||||||
|
val ClimbNeutral0 = Color(0xFF000000)
|
||||||
|
val ClimbNeutral4 = Color(0xFF0F0F0F)
|
||||||
|
val ClimbNeutral6 = Color(0xFF141414)
|
||||||
|
val ClimbNeutral10 = Color(0xFF1F1F1F)
|
||||||
|
val ClimbNeutral12 = Color(0xFF232323)
|
||||||
|
val ClimbNeutral17 = Color(0xFF2C2C2C)
|
||||||
|
val ClimbNeutral20 = Color(0xFF313131)
|
||||||
|
val ClimbNeutral22 = Color(0xFF363636)
|
||||||
|
val ClimbNeutral24 = Color(0xFF393939)
|
||||||
|
val ClimbNeutral87 = Color(0xFFDDDDDD)
|
||||||
|
val ClimbNeutral90 = Color(0xFFE6E6E6)
|
||||||
|
val ClimbNeutral92 = Color(0xFFEBEBEB)
|
||||||
|
val ClimbNeutral94 = Color(0xFFF0F0F0)
|
||||||
|
val ClimbNeutral95 = Color(0xFFF3F3F3)
|
||||||
|
val ClimbNeutral96 = Color(0xFFF5F5F5)
|
||||||
|
val ClimbNeutral98 = Color(0xFFFAFAFA)
|
||||||
|
val ClimbNeutral100 = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
// Neutral variant colors for outlines and variants
|
||||||
|
val ClimbNeutralVariant30 = Color(0xFF484848)
|
||||||
|
val ClimbNeutralVariant50 = Color(0xFF797979)
|
||||||
|
val ClimbNeutralVariant60 = Color(0xFF939393)
|
||||||
|
val ClimbNeutralVariant80 = Color(0xFFC7C7C7)
|
||||||
|
val ClimbNeutralVariant90 = Color(0xFFE3E3E3)
|
||||||
121
app/src/main/java/com/atridad/openclimb/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package com.atridad.openclimb.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
|
// Climbing-themed dark color scheme with full Material You compatibility
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = ClimbOrange80,
|
||||||
|
onPrimary = ClimbOrange20,
|
||||||
|
primaryContainer = ClimbOrange30,
|
||||||
|
onPrimaryContainer = ClimbOrange90,
|
||||||
|
secondary = ClimbGrey80,
|
||||||
|
onSecondary = ClimbGrey20,
|
||||||
|
secondaryContainer = ClimbGrey30,
|
||||||
|
onSecondaryContainer = ClimbGrey90,
|
||||||
|
tertiary = ClimbBlue80,
|
||||||
|
onTertiary = ClimbBlue20,
|
||||||
|
tertiaryContainer = ClimbBlue30,
|
||||||
|
onTertiaryContainer = ClimbBlue90,
|
||||||
|
error = ClimbRed80,
|
||||||
|
onError = ClimbRed20,
|
||||||
|
errorContainer = ClimbRed30,
|
||||||
|
onErrorContainer = ClimbRed90,
|
||||||
|
surface = ClimbNeutral10,
|
||||||
|
onSurface = ClimbNeutral90,
|
||||||
|
surfaceVariant = ClimbNeutralVariant30,
|
||||||
|
onSurfaceVariant = ClimbNeutralVariant80,
|
||||||
|
outline = ClimbNeutralVariant60,
|
||||||
|
outlineVariant = ClimbNeutralVariant30,
|
||||||
|
scrim = ClimbNeutral0,
|
||||||
|
inverseSurface = ClimbNeutral90,
|
||||||
|
inverseOnSurface = ClimbNeutral20,
|
||||||
|
inversePrimary = ClimbOrange40,
|
||||||
|
surfaceDim = ClimbNeutral6,
|
||||||
|
surfaceBright = ClimbNeutral24,
|
||||||
|
surfaceContainerLowest = ClimbNeutral4,
|
||||||
|
surfaceContainerLow = ClimbNeutral10,
|
||||||
|
surfaceContainer = ClimbNeutral12,
|
||||||
|
surfaceContainerHigh = ClimbNeutral17,
|
||||||
|
surfaceContainerHighest = ClimbNeutral22
|
||||||
|
)
|
||||||
|
|
||||||
|
// Climbing-themed light color scheme with full Material You compatibility
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = ClimbOrange40,
|
||||||
|
onPrimary = ClimbOrange100,
|
||||||
|
primaryContainer = ClimbOrange90,
|
||||||
|
onPrimaryContainer = ClimbOrange10,
|
||||||
|
secondary = ClimbGrey40,
|
||||||
|
onSecondary = ClimbGrey100,
|
||||||
|
secondaryContainer = ClimbGrey90,
|
||||||
|
onSecondaryContainer = ClimbGrey10,
|
||||||
|
tertiary = ClimbBlue40,
|
||||||
|
onTertiary = ClimbBlue100,
|
||||||
|
tertiaryContainer = ClimbBlue90,
|
||||||
|
onTertiaryContainer = ClimbBlue10,
|
||||||
|
error = ClimbRed40,
|
||||||
|
onError = ClimbRed100,
|
||||||
|
errorContainer = ClimbRed90,
|
||||||
|
onErrorContainer = ClimbRed10,
|
||||||
|
surface = ClimbNeutral98,
|
||||||
|
onSurface = ClimbNeutral10,
|
||||||
|
surfaceVariant = ClimbNeutralVariant90,
|
||||||
|
onSurfaceVariant = ClimbNeutralVariant30,
|
||||||
|
outline = ClimbNeutralVariant50,
|
||||||
|
outlineVariant = ClimbNeutralVariant80,
|
||||||
|
scrim = ClimbNeutral0,
|
||||||
|
inverseSurface = ClimbNeutral20,
|
||||||
|
inverseOnSurface = ClimbNeutral95,
|
||||||
|
inversePrimary = ClimbOrange80,
|
||||||
|
surfaceDim = ClimbNeutral87,
|
||||||
|
surfaceBright = ClimbNeutral98,
|
||||||
|
surfaceContainerLowest = ClimbNeutral100,
|
||||||
|
surfaceContainerLow = ClimbNeutral96,
|
||||||
|
surfaceContainer = ClimbNeutral94,
|
||||||
|
surfaceContainerHigh = ClimbNeutral92,
|
||||||
|
surfaceContainerHighest = ClimbNeutral90
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OpenClimbTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
// Dynamic color is available on Android 12+ and provides full Material You theming
|
||||||
|
// When enabled, it adapts to the user's system wallpaper colors
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && true -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
val view = LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as Activity).window
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
18
app/src/main/java/com/atridad/openclimb/ui/theme/Type.kt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package com.atridad.openclimb.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
// Set of Material typography styles to start with
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
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.service.SessionTrackingService
|
||||||
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
|
import com.atridad.openclimb.utils.SessionShareUtils
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ClimbViewModel(
|
||||||
|
private val repository: ClimbRepository
|
||||||
|
) : 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 updateGym(gym: Gym) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updateGym(gym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteGym(gym: Gym) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteGym(gym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGymById(id: String): Flow<Gym?> = flow {
|
||||||
|
emit(repository.getGymById(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problem operations
|
||||||
|
fun addProblem(problem: Problem) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.insertProblem(problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProblem(problem: Problem) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updateProblem(problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteProblem(problem: Problem, context: Context) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Delete associated images
|
||||||
|
problem.imagePaths.forEach { imagePath ->
|
||||||
|
ImageUtils.deleteImage(context, imagePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.deleteProblem(problem)
|
||||||
|
|
||||||
|
// Clean up any remaining orphaned images
|
||||||
|
cleanupOrphanedImages(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 updateSession(session: ClimbSession) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updateSession(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSession(session: ClimbSession) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteSession(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSessionById(id: String): Flow<ClimbSession?> = flow {
|
||||||
|
emit(repository.getSessionById(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
||||||
|
repository.getSessionsByGym(gymId)
|
||||||
|
|
||||||
|
// Active session management
|
||||||
|
fun startSession(context: Context, gymId: String, notes: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val existingActive = repository.getActiveSession()
|
||||||
|
if (existingActive != null) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
error = "There's already an active session. Please end it first."
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val newSession = ClimbSession.create(gymId = gymId, notes = notes)
|
||||||
|
repository.insertSession(newSession)
|
||||||
|
|
||||||
|
// Start the tracking service
|
||||||
|
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
|
||||||
|
context.startForegroundService(serviceIntent)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
message = "Session started successfully!"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endSession(context: Context, sessionId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val session = repository.getSessionById(sessionId)
|
||||||
|
if (session != null && session.status == SessionStatus.ACTIVE) {
|
||||||
|
val completedSession = with(ClimbSession) { session.complete() }
|
||||||
|
repository.updateSession(completedSession)
|
||||||
|
|
||||||
|
// Stop the tracking service, passing the session id so service can finalize if needed
|
||||||
|
val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
|
||||||
|
context.startService(serviceIntent)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
message = "Session completed!"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt operations
|
||||||
|
fun addAttempt(attempt: Attempt) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.insertAttempt(attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteAttempt(attempt: Attempt) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteAttempt(attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateAttempt(attempt: Attempt) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updateAttempt(attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||||
|
repository.getAttemptsBySession(sessionId)
|
||||||
|
|
||||||
|
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
|
||||||
|
repository.getAttemptsByProblem(problemId)
|
||||||
|
|
||||||
|
fun exportDataToUri(context: Context, uri: android.net.Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
repository.exportAllDataToUri(context, uri)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
message = "Data exported successfully"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Export failed: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
repository.exportAllDataToZipUri(context, uri)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
message = "Data with images exported successfully"
|
||||||
|
)
|
||||||
|
} 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)
|
||||||
|
|
||||||
|
// Check if it's a ZIP file or JSON file
|
||||||
|
if (file.name.lowercase().endsWith(".zip")) {
|
||||||
|
repository.importDataFromZip(file)
|
||||||
|
} else {
|
||||||
|
repository.importDataFromJson(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setError(message: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = 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
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.atridad.openclimb.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||||
|
|
||||||
|
class ClimbViewModelFactory(
|
||||||
|
private val repository: ClimbRepository
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
|
||||||
|
return ClimbViewModel(repository) as T
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
|
}
|
||||||
|
}
|
||||||
241
app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
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 java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
|
||||||
|
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 URI to app's private storage with compression
|
||||||
|
* @param context Android context
|
||||||
|
* @param imageUri URI of the image to save
|
||||||
|
* @return The relative file path if successful, null otherwise
|
||||||
|
*/
|
||||||
|
fun saveImageFromUri(context: Context, imageUri: Uri): String? {
|
||||||
|
return try {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||||
|
inputStream?.use { input ->
|
||||||
|
// Decode with options to get EXIF data
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
}
|
||||||
|
input.reset()
|
||||||
|
BitmapFactory.decodeStream(input, null, options)
|
||||||
|
|
||||||
|
// Reset stream and decode with proper orientation
|
||||||
|
input.reset()
|
||||||
|
val originalBitmap = BitmapFactory.decodeStream(input)
|
||||||
|
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||||
|
val compressedBitmap = compressImage(orientedBitmap)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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 {
|
||||||
|
return File(context.filesDir, 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,445 @@
|
|||||||
|
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 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
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.core.graphics.toColorInt
|
||||||
|
|
||||||
|
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 = 1080
|
||||||
|
val height = 1350
|
||||||
|
|
||||||
|
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 - 100f)
|
||||||
|
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
|
||||||
|
|
||||||
|
// Left column stats
|
||||||
|
var leftY = statsStartY
|
||||||
|
drawStatItem(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint)
|
||||||
|
leftY += 140f
|
||||||
|
drawStatItem(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint)
|
||||||
|
leftY += 140f
|
||||||
|
drawStatItem(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint)
|
||||||
|
|
||||||
|
// Right column stats
|
||||||
|
var rightY = statsStartY
|
||||||
|
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint)
|
||||||
|
rightY += 140f
|
||||||
|
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint)
|
||||||
|
rightY += 140f
|
||||||
|
|
||||||
|
stats.topGrade?.let { grade ->
|
||||||
|
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success rate arc
|
||||||
|
if (stats.totalAttempts > 0) {
|
||||||
|
val successRate = (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100
|
||||||
|
drawSuccessRateArc(canvas, width / 2f, height - 280f, successRate, statLabelPaint, statValuePaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawSuccessRateArc(
|
||||||
|
canvas: Canvas,
|
||||||
|
centerX: Float,
|
||||||
|
centerY: Float,
|
||||||
|
successRate: Float,
|
||||||
|
labelPaint: Paint,
|
||||||
|
valuePaint: Paint
|
||||||
|
) {
|
||||||
|
val radius = 80f
|
||||||
|
val strokeWidth = 16f
|
||||||
|
|
||||||
|
// Background arc
|
||||||
|
val bgPaint = Paint().apply {
|
||||||
|
color = "#40FFFFFF".toColorInt()
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
this.strokeWidth = strokeWidth
|
||||||
|
isAntiAlias = true
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success arc
|
||||||
|
val successPaint = Paint().apply {
|
||||||
|
color = "#4CAF50".toColorInt()
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
this.strokeWidth = strokeWidth
|
||||||
|
isAntiAlias = true
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
val rect = RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius)
|
||||||
|
|
||||||
|
// Draw background arc (full circle)
|
||||||
|
canvas.drawArc(rect, -90f, 360f, false, bgPaint)
|
||||||
|
|
||||||
|
// Draw success arc
|
||||||
|
val sweepAngle = (successRate / 100f) * 360f
|
||||||
|
canvas.drawArc(rect, -90f, sweepAngle, false, successPaint)
|
||||||
|
|
||||||
|
// Draw percentage text
|
||||||
|
val percentText = "${successRate.roundToInt()}%"
|
||||||
|
canvas.drawText(percentText, centerX, centerY + 10f, valuePaint)
|
||||||
|
canvas.drawText("Success Rate", centerX, centerY + 60f, labelPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,199 @@
|
|||||||
|
package com.atridad.openclimb.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
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
|
||||||
|
|
||||||
|
object ZipExportImportUtils {
|
||||||
|
|
||||||
|
private const val DATA_JSON_FILENAME = "data.json"
|
||||||
|
private const val IMAGES_DIR_NAME = "images"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: com.atridad.openclimb.data.repository.ClimbDataExport,
|
||||||
|
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")
|
||||||
|
|
||||||
|
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
|
||||||
|
// Add JSON data file
|
||||||
|
val json = Json { prettyPrint = true }
|
||||||
|
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
|
||||||
|
|
||||||
|
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
|
||||||
|
zipOut.putNextEntry(jsonEntry)
|
||||||
|
zipOut.write(jsonString.toByteArray())
|
||||||
|
zipOut.closeEntry()
|
||||||
|
|
||||||
|
// Add images
|
||||||
|
referencedImagePaths.forEach { imagePath ->
|
||||||
|
try {
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
if (imageFile.exists()) {
|
||||||
|
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
|
||||||
|
zipOut.putNextEntry(imageEntry)
|
||||||
|
|
||||||
|
FileInputStream(imageFile).use { imageInput ->
|
||||||
|
imageInput.copyTo(zipOut)
|
||||||
|
}
|
||||||
|
zipOut.closeEntry()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error but continue with other images
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return zipFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: com.atridad.openclimb.data.repository.ClimbDataExport,
|
||||||
|
referencedImagePaths: Set<String>
|
||||||
|
) {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
ZipOutputStream(outputStream).use { zipOut ->
|
||||||
|
// Add JSON data file
|
||||||
|
val json = Json { prettyPrint = true }
|
||||||
|
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
|
||||||
|
|
||||||
|
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
|
||||||
|
zipOut.putNextEntry(jsonEntry)
|
||||||
|
zipOut.write(jsonString.toByteArray())
|
||||||
|
zipOut.closeEntry()
|
||||||
|
|
||||||
|
// Add images
|
||||||
|
referencedImagePaths.forEach { imagePath ->
|
||||||
|
try {
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
if (imageFile.exists()) {
|
||||||
|
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
|
||||||
|
zipOut.putNextEntry(imageEntry)
|
||||||
|
|
||||||
|
FileInputStream(imageFile).use { imageInput ->
|
||||||
|
imageInput.copyTo(zipOut)
|
||||||
|
}
|
||||||
|
zipOut.closeEntry()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error but continue with other images
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: throw IOException("Could not open output stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>()
|
||||||
|
|
||||||
|
ZipInputStream(FileInputStream(zipFile)).use { zipIn ->
|
||||||
|
var entry = zipIn.nextEntry
|
||||||
|
|
||||||
|
while (entry != null) {
|
||||||
|
when {
|
||||||
|
entry.name == DATA_JSON_FILENAME -> {
|
||||||
|
// Read JSON data
|
||||||
|
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
|
||||||
|
// Extract image file
|
||||||
|
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
|
||||||
|
|
||||||
|
// Create temporary file to hold the extracted image
|
||||||
|
val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir)
|
||||||
|
|
||||||
|
FileOutputStream(tempFile).use { output ->
|
||||||
|
zipIn.copyTo(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the image to permanent storage
|
||||||
|
val newPath = ImageUtils.importImageFile(context, tempFile)
|
||||||
|
if (newPath != null) {
|
||||||
|
importedImagePaths[originalFilename] = newPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zipIn.closeEntry()
|
||||||
|
entry = zipIn.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonContent.isEmpty()) {
|
||||||
|
throw IOException("No data.json file found in the ZIP archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportResult(jsonContent, importedImagePaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<com.atridad.openclimb.data.model.Problem>,
|
||||||
|
imagePathMapping: Map<String, String>
|
||||||
|
): List<com.atridad.openclimb.data.model.Problem> {
|
||||||
|
return problems.map { problem ->
|
||||||
|
val updatedImagePaths = problem.imagePaths.mapNotNull { oldPath ->
|
||||||
|
// Extract filename from the old path
|
||||||
|
val filename = oldPath.substringAfterLast("/")
|
||||||
|
imagePathMapping[filename]
|
||||||
|
}
|
||||||
|
problem.copy(imagePaths = updatedImagePaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
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
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
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>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">OpenClimb</string>
|
||||||
|
</resources>
|
||||||
5
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
5
app/src/main/res/xml/file_provider_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="share_images" path="shares/" />
|
||||||
|
<external-files-path name="external_files" path="." />
|
||||||
|
</paths>
|
||||||
17
app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package com.atridad.openclimb
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
build.gradle.kts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
23
gradle.properties
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
64
gradle/libs.versions.toml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.9.1"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.15.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.3.0"
|
||||||
|
espressoCore = "3.7.0"
|
||||||
|
lifecycleRuntimeKtx = "2.9.2"
|
||||||
|
activityCompose = "1.10.1"
|
||||||
|
composeBom = "2024.09.00"
|
||||||
|
room = "2.6.1"
|
||||||
|
navigation = "2.8.4"
|
||||||
|
viewmodel = "2.9.2"
|
||||||
|
kotlinxSerialization = "1.7.1"
|
||||||
|
kotlinxCoroutines = "1.9.0"
|
||||||
|
coil = "2.7.0"
|
||||||
|
ksp = "2.0.21-1.0.25"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
|
||||||
|
# Room Database
|
||||||
|
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
||||||
|
|
||||||
|
# ViewModel
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewmodel" }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||||
|
|
||||||
|
# Coroutines
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
|
||||||
|
# Image Loading
|
||||||
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
|
|
||||||
|
# Charts - MPAndroidChart for now, will be replaced with Vico when stable
|
||||||
|
mpandroidchart = { group = "com.github.PhilJay", name = "MPAndroidChart", version = "v3.1.0" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#Fri Aug 15 11:23:25 MDT 2025
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
185
gradlew
vendored
Executable file
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2015 the original author or authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=`expr $i + 1`
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
0) set -- ;;
|
||||||
|
1) set -- "$args0" ;;
|
||||||
|
2) set -- "$args0" "$args1" ;;
|
||||||
|
3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=`save "$@"`
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
89
gradlew.bat
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
10
local.properties
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## This file is automatically generated by Android Studio.
|
||||||
|
# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
|
||||||
|
#
|
||||||
|
# This file should *NOT* be checked into Version Control Systems,
|
||||||
|
# as it contains information specific to your local configuration.
|
||||||
|
#
|
||||||
|
# Location of the SDK. This is only used by Gradle.
|
||||||
|
# For customization when using a Version Control System, please read the
|
||||||
|
# header note.
|
||||||
|
sdk.dir=/Users/atridad/Library/Android/sdk
|
||||||
24
settings.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "OpenClimb"
|
||||||
|
include(":app")
|
||||||