This commit is contained in:
2025-08-15 13:58:53 -06:00
parent 7c1296c255
commit 8ac86f7b5c
98 changed files with 9221 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

View File

@@ -0,0 +1,2 @@
#Fri Aug 15 12:27:13 MDT 2025
gradle.version=8.11.1

Binary file not shown.

View File

@@ -0,0 +1,2 @@
#Fri Aug 15 12:29:02 MDT 2025
java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home

BIN
.gradle/file-system.probe Normal file

Binary file not shown.

View File

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

6
.idea/AndroidProjectSystem.xml generated Normal file
View 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
View 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
View 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>

18
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View 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
View 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
View 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>

10
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View 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
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,87 @@
kotlin version: 2.0.21
error message: java.lang.IllegalStateException: Storage for [/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.set(PersistentStorage.kt:96)
at org.jetbrains.kotlin.incremental.LookupStorage.addFileIfNeeded(LookupStorage.kt:165)
at org.jetbrains.kotlin.incremental.LookupStorage.addAll$lambda$4(LookupStorage.kt:117)
at org.jetbrains.kotlin.utils.CollectionsKt.keysToMap(collections.kt:117)
at org.jetbrains.kotlin.incremental.LookupStorage.addAll(LookupStorage.kt:117)
at org.jetbrains.kotlin.incremental.BuildUtilKt.update(buildUtil.kt:134)
at com.google.devtools.ksp.LookupStorageWrapperImpl.update(IncrementalContext.kt:231)
at com.google.devtools.ksp.common.IncrementalContextBase.updateLookupCache(IncrementalContextBase.kt:133)
at com.google.devtools.ksp.common.IncrementalContextBase.updateCaches(IncrementalContextBase.kt:365)
at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:471)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:362)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Suppressed: java.lang.Exception: Storage[/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageIfExists(LazyStorage.kt:53)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.get(LazyStorage.kt:76)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.get(PersistentStorage.kt:92)
at org.jetbrains.kotlin.incremental.LookupStorage.get(LookupStorage.kt:99)
at com.google.devtools.ksp.LookupStorageWrapperImpl.get(IncrementalContext.kt:224)
at com.google.devtools.ksp.common.IncrementalContextBase.calcDirtyFiles(IncrementalContextBase.kt:234)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:196)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:189)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.handleException(KotlinSymbolProcessingExtension.kt:414)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:189)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
... 23 more

View File

@@ -0,0 +1,87 @@
kotlin version: 2.0.21
error message: java.lang.IllegalStateException: Storage for [/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.set(PersistentStorage.kt:96)
at org.jetbrains.kotlin.incremental.LookupStorage.addFileIfNeeded(LookupStorage.kt:165)
at org.jetbrains.kotlin.incremental.LookupStorage.addAll$lambda$4(LookupStorage.kt:117)
at org.jetbrains.kotlin.utils.CollectionsKt.keysToMap(collections.kt:117)
at org.jetbrains.kotlin.incremental.LookupStorage.addAll(LookupStorage.kt:117)
at org.jetbrains.kotlin.incremental.BuildUtilKt.update(buildUtil.kt:134)
at com.google.devtools.ksp.LookupStorageWrapperImpl.update(IncrementalContext.kt:231)
at com.google.devtools.ksp.common.IncrementalContextBase.updateLookupCache(IncrementalContextBase.kt:133)
at com.google.devtools.ksp.common.IncrementalContextBase.updateCaches(IncrementalContextBase.kt:365)
at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:471)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:362)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Suppressed: java.lang.Exception: Storage[/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageIfExists(LazyStorage.kt:53)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.get(LazyStorage.kt:76)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.get(PersistentStorage.kt:92)
at org.jetbrains.kotlin.incremental.LookupStorage.get(LookupStorage.kt:99)
at com.google.devtools.ksp.LookupStorageWrapperImpl.get(IncrementalContext.kt:224)
at com.google.devtools.ksp.common.IncrementalContextBase.calcDirtyFiles(IncrementalContextBase.kt:234)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:196)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:189)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.handleException(KotlinSymbolProcessingExtension.kt:414)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:189)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
... 23 more

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "disabled"
}

View File

@@ -1,2 +1,18 @@
# 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="">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
View File

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

88
app/build.gradle.kts Normal file
View 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 = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
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
View File

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

View File

@@ -0,0 +1,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)
}
}

View 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>

View 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()
}
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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?
}

View File

@@ -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?>
}

View File

@@ -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>>
}

View File

@@ -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>>
}

View File

@@ -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 AttemptResult {
SUCCESS, // Completed the problem/route
FALL, // Fell but made progress
NO_PROGRESS, // Couldn't make meaningful progress
FLASH, // Completed on first try
REDPOINT, // Completed after previous attempts
ONSIGHT // Completed on first try without prior knowledge
}
@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
)
}
}
}

View File

@@ -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, // ISO date string
val startTime: String? = null, // When session was started
val endTime: String? = null, // When session was completed
val duration: Long? = null, // Duration in minutes (calculated when completed)
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 (e: Exception) {
null
}
} else null
return this.copy(
endTime = endTime,
duration = durationMinutes,
status = SessionStatus.COMPLETED,
updatedAt = LocalDateTime.now().toString()
)
}
}
}

View File

@@ -0,0 +1,9 @@
package com.atridad.openclimb.data.model
import kotlinx.serialization.Serializable
@Serializable
enum class ClimbType {
ROPE,
BOULDER
}

View File

@@ -0,0 +1,26 @@
package com.atridad.openclimb.data.model
import kotlinx.serialization.Serializable
@Serializable
enum class DifficultySystem {
// Rope climbing systems
YDS, // Yosemite Decimal System (5.1 - 5.15d)
FRENCH, // French system (3 - 9c+)
UIAA, // UIAA system (I - XII+)
BRITISH, // British system (Mod - E11)
// Bouldering systems
V_SCALE, // V-Scale (VB - V17)
FONT, // Fontainebleau (3 - 9A+)
// Custom system for gyms that use their own colors/naming
CUSTOM
}
@Serializable
data class DifficultyGrade(
val system: DifficultySystem,
val grade: String,
val numericValue: Int // For comparison and analytics
)

View 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>, // What systems this gym uses
val customDifficultyGrades: List<String> = emptyList(), // For gyms using colors/custom names
val notes: String? = null,
val createdAt: String, // ISO string format for serialization
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
)
}
}
}

View File

@@ -0,0 +1,75 @@
package com.atridad.openclimb.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import 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, // Route setter name
val tags: List<String> = emptyList(), // e.g., "overhang", "slab", "crimpy"
val location: String? = null, // Wall section, area in gym
val imagePaths: List<String> = emptyList(), // Local file paths to photos
val isActive: Boolean = true, // Whether the problem is still up
val dateSet: String? = null, // When the problem was set
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
)
}
}
}

View File

@@ -0,0 +1,50 @@
package com.atridad.openclimb.data.model
import kotlinx.serialization.Serializable
import java.time.LocalDateTime
@Serializable
data class ProblemProgress(
val problemId: String,
val totalAttempts: Int,
val successfulAttempts: Int,
val firstAttemptDate: String,
val lastAttemptDate: String,
val bestResult: AttemptResult,
val averageAttempts: Double,
val successRate: Double,
val personalBest: String? = null, // Highest hold or completion details
val notes: String? = null
)
@Serializable
data class SessionSummary(
val sessionId: String,
val date: String,
val totalAttempts: Int,
val successfulAttempts: Int,
val uniqueProblems: Int,
val avgDifficulty: Double,
val maxDifficulty: DifficultyGrade,
val climbTypes: List<ClimbType>,
val duration: Long?, // in minutes
val notes: String? = null
)
@Serializable
data class ClimbingStats(
val totalSessions: Int,
val totalAttempts: Int,
val totalSuccesses: Int,
val overallSuccessRate: Double,
val uniqueProblemsAttempted: Int,
val uniqueProblemsCompleted: Int,
val averageSessionDuration: Double, // in minutes
val favoriteGym: String?,
val mostAttemptedDifficulty: DifficultyGrade?,
val currentStreak: Int, // consecutive sessions
val longestStreak: Int,
val firstClimbDate: String?,
val lastClimbDate: String?,
val improvementTrend: String? = null // "improving", "stable", "declining"
)

View File

@@ -0,0 +1,175 @@
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 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(
private val 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)
fun getActiveProblems(): Flow<List<Problem>> = problemDao.getActiveProblems()
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)
fun getRecentSessions(limit: Int = 10): Flow<List<ClimbSession>> = sessionDao.getRecentSessions(limit)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
fun getSessionsByStatus(status: SessionStatus): Flow<List<ClimbSession>> = sessionDao.getSessionsByStatus(status)
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()
suspend fun getAttemptById(id: String): Attempt? = attemptDao.getAttemptById(id)
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 functionality
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 (replace if exists due to primary key constraint)
importData.gyms.forEach { gym ->
try {
gymDao.insertGym(gym)
} catch (e: Exception) {
// If insertion fails due to primary key conflict, update instead
gymDao.updateGym(gym)
}
}
// Import problems
importData.problems.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>
)

View File

@@ -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"
)
)

View 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()
}

View File

@@ -0,0 +1,189 @@
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.Build
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.format.DateTimeFormatter
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): Intent {
return Intent(context, SessionTrackingService::class.java).apply {
action = ACTION_STOP_SESSION
}
}
}
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 -> {
stopSessionTracking()
}
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) {
notificationJob?.cancel()
notificationJob = serviceScope.launch {
while (isActive) {
updateNotification(sessionId)
delay(1000)
}
}
}
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 minutes = ChronoUnit.MINUTES.between(start, now)
val hours = minutes / 60
val remainingMinutes = minutes % 60
when {
hours > 0 -> "${hours}h ${remainingMinutes}m"
remainingMinutes > 0 -> "${remainingMinutes}m"
else -> "< 1m"
}
} catch (e: Exception) {
"Active"
}
} ?: "Active"
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("OpenClimb Session Active")
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts")
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setOngoing(true)
.setContentIntent(createOpenAppIntent())
.addAction(
R.drawable.ic_launcher_foreground,
"Open Session",
createOpenAppIntent()
)
.addAction(
R.drawable.ic_launcher_foreground,
"End Session",
createStopIntent()
)
.build()
startForeground(NOTIFICATION_ID, notification)
} catch (e: 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 createStopIntent(): PendingIntent {
val intent = createStopIntent(this)
return PendingIntent.getService(
this,
1,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Session Tracking",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows active climbing session information"
setShowBadge(false)
}
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
override fun onDestroy() {
super.onDestroy()
notificationJob?.cancel()
serviceScope.cancel()
}
}

View File

@@ -0,0 +1,274 @@
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 currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = currentBackStackEntry?.destination?.route
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))
},
onNavigateToAddSession = { gymId ->
navController.navigate(Screen.AddEditSession(gymId = gymId))
}
)
}
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))
},
onNavigateToAddProblem = { gymId ->
navController.navigate(Screen.AddEditProblem(gymId = gymId))
}
)
}
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))
},
onNavigateToAddGym = {
navController.navigate(Screen.AddEditGym())
}
)
}
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>()
SessionDetailScreen(
sessionId = args.sessionId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { sessionId ->
navController.navigate(Screen.AddEditSession(sessionId = sessionId))
}
)
}
composable<Screen.ProblemDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
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>()
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>()
AddEditGymScreen(
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
composable<Screen.AddEditProblem> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
AddEditProblemScreen(
problemId = args.problemId,
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
composable<Screen.AddEditSession> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditSession>()
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) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(Screen.Sessions) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
}
}
data class FabConfig(
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String,
val onClick: () -> Unit
)

View File

@@ -0,0 +1,188 @@
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.material.icons.rounded.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.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
@Composable
fun ActiveSessionBanner(
activeSession: ClimbSession?,
gym: Gym?,
onSessionClick: () -> Unit,
onEndSession: () -> Unit
) {
if (activeSession != null) {
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)
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"
)
}
}
}
}
}
@Composable
fun StartSessionButton(
gyms: List<Gym>,
onStartSession: (String) -> Unit
) {
var showGymSelection by remember { mutableStateOf(false) }
if (gyms.isEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No gyms available",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Add a gym first to start a session",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
Button(
onClick = { showGymSelection = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Start Session")
}
}
if (showGymSelection) {
AlertDialog(
onDismissRequest = { showGymSelection = false },
title = { Text("Select Gym") },
text = {
Column {
gyms.forEach { gym ->
TextButton(
onClick = {
onStartSession(gym.id)
showGymSelection = false
},
modifier = Modifier.fillMaxWidth()
) {
Text(
text = gym.name,
modifier = Modifier.fillMaxWidth()
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showGymSelection = false }) {
Text("Cancel")
}
}
)
}
}
private fun calculateDuration(startTimeString: String): String {
return try {
val startTime = LocalDateTime.parse(startTimeString)
val now = LocalDateTime.now()
val minutes = ChronoUnit.MINUTES.between(startTime, now)
val hours = minutes / 60
val remainingMinutes = minutes % 60
when {
hours > 0 -> "${hours}h ${remainingMinutes}m"
remainingMinutes > 0 -> "${remainingMinutes}m"
else -> "< 1m"
}
} catch (e: Exception) {
"Active"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
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 {
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)
}
item {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Progress Charts",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Detailed charts and analytics coming soon!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "📊",
style = MaterialTheme.typography.displaySmall
)
}
}
}
}
}
@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
)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,127 @@
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.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
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,
onNavigateToAddGym: () -> Unit
) {
val gyms by viewModel.gyms.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
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.name.lowercase().replaceFirstChar { it.uppercase() })
},
modifier = Modifier.padding(end = 4.dp)
)
}
}
if (gym.difficultySystems.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.name }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
gym.notes?.let { notes ->
if (notes.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = notes,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
}
}
}
}

View File

@@ -0,0 +1,146 @@
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.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.Problem
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProblemsScreen(
viewModel: ClimbViewModel,
onNavigateToProblemDetail: (String) -> Unit,
onNavigateToAddProblem: (String?) -> Unit
) {
val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = "Problems & Routes",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
if (problems.isEmpty()) {
EmptyStateMessage(
title = if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet",
message = if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!",
onActionClick = { },
actionText = ""
)
} else {
LazyColumn {
items(problems) { problem ->
ProblemCard(
problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
onClick = { onNavigateToProblemDetail(problem.id) }
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProblemCard(
problem: Problem,
gymName: String,
onClick: () -> Unit
) {
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.name.lowercase().replaceFirstChar { it.uppercase() },
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)
)
}
}
}
if (!problem.isActive) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Inactive",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}

View File

@@ -0,0 +1,203 @@
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.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.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,
onNavigateToAddSession: (String?) -> Unit
) {
val context = LocalContext.current
val sessions by viewModel.sessions.collectAsState()
val gyms by viewModel.gyms.collectAsState()
val activeSession by viewModel.activeSession.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(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
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))
}
}
}
}
}
@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 (e: Exception) {
dateString
}
}

View File

@@ -0,0 +1,313 @@
package com.atridad.openclimb.ui.screens
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import android.os.Environment
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
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
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
uri?.let {
try {
val inputStream = context.contentResolver.openInputStream(uri)
val tempFile = File(context.cacheDir, "temp_import.json")
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
val exportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let {
try {
val defaultFileName = "openclimb_export_${
java.time.LocalDateTime.now()
.toString()
.replace(":", "-")
.replace(".", "-")
}.json"
// Use the selected URI for export
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 {
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") },
supportingContent = { Text("Export all your climbing data to JSON") },
leadingContent = { Icon(Icons.Default.Share, contentDescription = null) },
trailingContent = {
TextButton(
onClick = {
val defaultFileName = "openclimb_export_${
java.time.LocalDateTime.now()
.toString()
.replace(":", "-")
.replace(".", "-")
}.json"
exportLauncher.launch(defaultFileName)
},
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Export")
}
}
}
)
}
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 JSON file") },
leadingContent = { Icon(Icons.Default.Add, contentDescription = null) },
trailingContent = {
TextButton(
onClick = {
importLauncher.launch("application/json")
},
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 = { Text("Version") },
supportingContent = { Text(appVersion ?: "Unknown") },
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
)
}
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("About") },
supportingContent = { Text("OpenClimb - Track your climbing progress") },
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
)
}
}
}
}

View File

@@ -0,0 +1,75 @@
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)
// Legacy colors for backward compatibility
val Purple80 = ClimbOrange80
val PurpleGrey80 = ClimbGrey80
val Pink80 = ClimbBlue80
val Purple40 = ClimbOrange40
val PurpleGrey40 = ClimbGrey40
val Pink40 = ClimbBlue40

View File

@@ -0,0 +1,123 @@
package com.atridad.openclimb.ui.theme
import android.app.Activity
import android.os.Build
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.graphics.toArgb
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 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
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
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

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

View File

@@ -0,0 +1,336 @@
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.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) {
viewModelScope.launch {
repository.deleteProblem(problem)
}
}
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
val serviceIntent = SessionTrackingService.createStopIntent(context)
context.startService(serviceIntent)
_uiState.value = _uiState.value.copy(
message = "Session completed!"
)
}
}
}
fun pauseSession(sessionId: String) {
viewModelScope.launch {
val session = repository.getSessionById(sessionId)
if (session != null && session.status == SessionStatus.ACTIVE) {
val pausedSession = session.copy(
status = SessionStatus.PAUSED,
updatedAt = java.time.LocalDateTime.now().toString()
)
repository.updateSession(pausedSession)
}
}
}
fun resumeSession(sessionId: String) {
viewModelScope.launch {
val session = repository.getSessionById(sessionId)
if (session != null && session.status == SessionStatus.PAUSED) {
val resumedSession = session.copy(
status = SessionStatus.ACTIVE,
updatedAt = java.time.LocalDateTime.now().toString()
)
repository.updateSession(resumedSession)
}
}
}
// Attempt operations
fun addAttempt(attempt: Attempt) {
viewModelScope.launch {
repository.insertAttempt(attempt)
}
}
fun updateAttempt(attempt: Attempt) {
viewModelScope.launch {
repository.updateAttempt(attempt)
}
}
fun deleteAttempt(attempt: Attempt) {
viewModelScope.launch {
repository.deleteAttempt(attempt)
}
}
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
repository.getAttemptsByProblem(problemId)
// Analytics operations
// fun getProblemProgress(problemId: String): Flow<ProblemProgress?> =
// repository.getProblemProgress(problemId)
// fun getSessionSummary(sessionId: String): Flow<SessionSummary?> =
// repository.getSessionSummary(sessionId)
// Export operations
fun exportData(context: Context, directory: File? = null) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
val exportFile = repository.exportAllDataToJson(directory)
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Data exported to: ${exportFile.absolutePath}"
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "Export failed: ${e.message}"
)
}
}
}
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 importData(file: File) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
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)
}
// Search operations
fun searchGyms(query: String): Flow<List<Gym>> = repository.searchGyms(query)
fun searchProblems(query: String): Flow<List<Problem>> = repository.searchProblems(query)
// Share operations
suspend fun generateSessionShareCard(
context: Context,
sessionId: String
): File? = withContext(Dispatchers.IO) {
try {
val session = repository.getSessionById(sessionId) ?: return@withContext null
val attempts = repository.getAttemptsBySession(sessionId).first()
val problems = repository.getAllProblems().first().filter { problem ->
attempts.any { it.problemId == problem.id }
}
val gym = repository.getGymById(session.gymId) ?: return@withContext null
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
SessionShareUtils.generateShareCard(context, session, gym, stats)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = "Failed to generate share card: ${e.message}")
null
}
}
fun shareSessionCard(context: Context, imageFile: File) {
SessionShareUtils.shareSessionCard(context, imageFile)
}
}
data class ClimbUiState(
val isLoading: Boolean = false,
val message: String? = null,
val error: String? = null
)

View File

@@ -0,0 +1,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")
}
}

View File

@@ -0,0 +1,336 @@
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
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?
)
fun calculateSessionStats(
session: ClimbSession,
attempts: List<Attempt>,
problems: List<Problem>
): SessionStats {
val successfulResults = listOf(
AttemptResult.SUCCESS,
AttemptResult.FLASH,
AttemptResult.REDPOINT,
AttemptResult.ONSIGHT
)
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 }
val averageGrade = if (attemptedProblems.isNotEmpty()) {
// This is a simplified average - in reality you'd need proper grade conversion
val gradeValues = attemptedProblems.mapNotNull { problem ->
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
}
if (gradeValues.isNotEmpty()) {
"V${gradeValues.average().roundToInt()}"
} else null
} else null
val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
val topResult = attempts.maxByOrNull {
when (it.result) {
AttemptResult.ONSIGHT -> 5
AttemptResult.FLASH -> 4
AttemptResult.REDPOINT -> 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
)
}
private fun calculateDuration(startTime: String?, endTime: String?): String {
return try {
if (startTime != null && endTime != null) {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val start = LocalDateTime.parse(startTime, formatter)
val end = LocalDateTime.parse(endTime, formatter)
val duration = java.time.Duration.between(start, end)
val hours = duration.toHours()
val minutes = duration.toMinutes() % 60
when {
hours > 0 -> "${hours}h ${minutes}m"
minutes > 0 -> "${minutes}m"
else -> "< 1m"
}
} else {
"Unknown"
}
} catch (e: Exception) {
"Unknown"
}
}
fun generateShareCard(
context: Context,
session: ClimbSession,
gym: Gym,
stats: SessionStats
): File? {
return try {
val width = 1080
val height = 1350
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val gradientDrawable = GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf(
Color.parseColor("#667eea"),
Color.parseColor("#764ba2")
)
)
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 = Color.parseColor("#E8E8E8")
textSize = 48f
typeface = Typeface.DEFAULT
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val statLabelPaint = Paint().apply {
color = Color.parseColor("#B8B8B8")
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 = Color.parseColor("#40FFFFFF")
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.averageGrade?.let { grade ->
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Avg 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 = Color.parseColor("#80FFFFFF")
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 = Color.parseColor("#40FFFFFF")
style = Paint.Style.STROKE
this.strokeWidth = strokeWidth
isAntiAlias = true
strokeCap = Paint.Cap.ROUND
}
// Success arc
val successPaint = Paint().apply {
color = Color.parseColor("#4CAF50")
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 (e: 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()
}
}
}

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

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

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">OpenClimb</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
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
View File

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

File diff suppressed because one or more lines are too long

23
gradle.properties Normal file
View File

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

64
gradle/libs.versions.toml Normal file
View 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

Binary file not shown.

View 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
View 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
View 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
View 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
View 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")