Merge pull request '0.1.0 - Initial release of the app' (#1) from 0.1.0 into main
Reviewed-on: atridad/OpenClimb#1
BIN
.gradle/8.11.1/checksums/checksums.lock
Normal file
BIN
.gradle/8.11.1/executionHistory/executionHistory.bin
Normal file
BIN
.gradle/8.11.1/executionHistory/executionHistory.lock
Normal file
BIN
.gradle/8.11.1/fileChanges/last-build.bin
Normal file
BIN
.gradle/8.11.1/fileHashes/fileHashes.bin
Normal file
BIN
.gradle/8.11.1/fileHashes/fileHashes.lock
Normal file
BIN
.gradle/8.11.1/fileHashes/resourceHashesCache.bin
Normal file
0
.gradle/8.11.1/gc.properties
Normal file
BIN
.gradle/buildOutputCleanup/buildOutputCleanup.lock
Normal file
2
.gradle/buildOutputCleanup/cache.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#Fri Aug 15 12:27:13 MDT 2025
|
||||||
|
gradle.version=8.11.1
|
||||||
BIN
.gradle/buildOutputCleanup/outputFiles.bin
Normal file
2
.gradle/config.properties
Normal 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
0
.gradle/vcs-1/gc.properties
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
835
.idea/caches/deviceStreaming.xml
generated
Normal file
@@ -0,0 +1,835 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceStreaming">
|
||||||
|
<option name="deviceSelectionList">
|
||||||
|
<list>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="Sony" />
|
||||||
|
<option name="codename" value="A402SO" />
|
||||||
|
<option name="id" value="A402SO" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Sony" />
|
||||||
|
<option name="name" value="Xperia 10" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2520" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="27" />
|
||||||
|
<option name="brand" value="DOCOMO" />
|
||||||
|
<option name="codename" value="F01L" />
|
||||||
|
<option name="id" value="F01L" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="FUJITSU" />
|
||||||
|
<option name="name" value="F-01L" />
|
||||||
|
<option name="screenDensity" value="360" />
|
||||||
|
<option name="screenX" value="720" />
|
||||||
|
<option name="screenY" value="1280" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="OnePlus" />
|
||||||
|
<option name="codename" value="OP535DL1" />
|
||||||
|
<option name="id" value="OP535DL1" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="OnePlus" />
|
||||||
|
<option name="name" value="CPH2409" />
|
||||||
|
<option name="screenDensity" value="401" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2412" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="OnePlus" />
|
||||||
|
<option name="codename" value="OP5552L1" />
|
||||||
|
<option name="id" value="OP5552L1" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="OnePlus" />
|
||||||
|
<option name="name" value="CPH2415" />
|
||||||
|
<option name="screenDensity" value="480" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2412" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="OPPO" />
|
||||||
|
<option name="codename" value="OP573DL1" />
|
||||||
|
<option name="id" value="OP573DL1" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="OPPO" />
|
||||||
|
<option name="name" value="CPH2557" />
|
||||||
|
<option name="screenDensity" value="480" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="28" />
|
||||||
|
<option name="brand" value="DOCOMO" />
|
||||||
|
<option name="codename" value="SH-01L" />
|
||||||
|
<option name="id" value="SH-01L" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="SHARP" />
|
||||||
|
<option name="name" value="AQUOS sense2 SH-01L" />
|
||||||
|
<option name="screenDensity" value="480" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2160" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="a14m" />
|
||||||
|
<option name="id" value="a14m" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="SM-A145R" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2408" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="a15" />
|
||||||
|
<option name="id" value="a15" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="A15" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2340" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="a15x" />
|
||||||
|
<option name="id" value="a15x" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="A15 5G" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2340" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="a16x" />
|
||||||
|
<option name="id" value="a16x" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="A16 5G" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2340" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="a35x" />
|
||||||
|
<option name="id" value="a35x" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="A35" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2340" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="akita" />
|
||||||
|
<option name="id" value="akita" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 8a" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="35" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="akita" />
|
||||||
|
<option name="id" value="akita" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 8a" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="arcfox" />
|
||||||
|
<option name="id" value="arcfox" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="razr plus 2024" />
|
||||||
|
<option name="screenDensity" value="360" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="1272" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="austin" />
|
||||||
|
<option name="id" value="austin" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="moto g 5G (2022)" />
|
||||||
|
<option name="screenDensity" value="280" />
|
||||||
|
<option name="screenX" value="720" />
|
||||||
|
<option name="screenY" value="1600" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="b0q" />
|
||||||
|
<option name="id" value="b0q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy S22 Ultra" />
|
||||||
|
<option name="screenDensity" value="600" />
|
||||||
|
<option name="screenX" value="1440" />
|
||||||
|
<option name="screenY" value="3088" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="b6q" />
|
||||||
|
<option name="id" value="b6q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Flip 6" />
|
||||||
|
<option name="screenDensity" value="340" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2640" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="32" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="bluejay" />
|
||||||
|
<option name="id" value="bluejay" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 6a" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="caiman" />
|
||||||
|
<option name="id" value="caiman" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9 Pro" />
|
||||||
|
<option name="screenDensity" value="360" />
|
||||||
|
<option name="screenX" value="960" />
|
||||||
|
<option name="screenY" value="2142" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="35" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="caiman" />
|
||||||
|
<option name="id" value="caiman" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9 Pro" />
|
||||||
|
<option name="screenDensity" value="360" />
|
||||||
|
<option name="screenX" value="960" />
|
||||||
|
<option name="screenY" value="2142" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="comet" />
|
||||||
|
<option name="default" value="true" />
|
||||||
|
<option name="id" value="comet" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9 Pro Fold" />
|
||||||
|
<option name="screenDensity" value="390" />
|
||||||
|
<option name="screenX" value="2076" />
|
||||||
|
<option name="screenY" value="2152" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="35" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="comet" />
|
||||||
|
<option name="default" value="true" />
|
||||||
|
<option name="id" value="comet" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9 Pro Fold" />
|
||||||
|
<option name="screenDensity" value="390" />
|
||||||
|
<option name="screenX" value="2076" />
|
||||||
|
<option name="screenY" value="2152" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="29" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="crownqlteue" />
|
||||||
|
<option name="id" value="crownqlteue" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy Note9" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="2220" />
|
||||||
|
<option name="screenY" value="1080" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="dm2q" />
|
||||||
|
<option name="id" value="dm2q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="S23 Plus" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2340" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="dm3q" />
|
||||||
|
<option name="id" value="dm3q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy S23 Ultra" />
|
||||||
|
<option name="screenDensity" value="600" />
|
||||||
|
<option name="screenX" value="1440" />
|
||||||
|
<option name="screenY" value="3088" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="dubai" />
|
||||||
|
<option name="id" value="dubai" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="edge 30" />
|
||||||
|
<option name="screenDensity" value="405" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="e1q" />
|
||||||
|
<option name="default" value="true" />
|
||||||
|
<option name="id" value="e1q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy S24" />
|
||||||
|
<option name="screenDensity" value="480" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2340" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="e3q" />
|
||||||
|
<option name="id" value="e3q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy S24 Ultra" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1440" />
|
||||||
|
<option name="screenY" value="3120" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="eos" />
|
||||||
|
<option name="id" value="eos" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Eos" />
|
||||||
|
<option name="screenDensity" value="320" />
|
||||||
|
<option name="screenX" value="384" />
|
||||||
|
<option name="screenY" value="384" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="35" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="eqe" />
|
||||||
|
<option name="id" value="eqe" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="edge 50 pro" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1220" />
|
||||||
|
<option name="screenY" value="2712" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="felix" />
|
||||||
|
<option name="id" value="felix" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel Fold" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="2208" />
|
||||||
|
<option name="screenY" value="1840" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="felix" />
|
||||||
|
<option name="id" value="felix" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel Fold" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="2208" />
|
||||||
|
<option name="screenY" value="1840" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="felix_camera" />
|
||||||
|
<option name="id" value="felix_camera" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel Fold (Camera-enabled)" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="2208" />
|
||||||
|
<option name="screenY" value="1840" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="fogona" />
|
||||||
|
<option name="id" value="fogona" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="moto g play - 2024" />
|
||||||
|
<option name="screenDensity" value="280" />
|
||||||
|
<option name="screenX" value="720" />
|
||||||
|
<option name="screenY" value="1600" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="fogos" />
|
||||||
|
<option name="id" value="fogos" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="moto g34 5G" />
|
||||||
|
<option name="screenDensity" value="280" />
|
||||||
|
<option name="screenX" value="720" />
|
||||||
|
<option name="screenY" value="1600" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="g0q" />
|
||||||
|
<option name="id" value="g0q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="SM-S906U1" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2340" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="gta9pwifi" />
|
||||||
|
<option name="id" value="gta9pwifi" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="SM-X210" />
|
||||||
|
<option name="screenDensity" value="240" />
|
||||||
|
<option name="screenX" value="1200" />
|
||||||
|
<option name="screenY" value="1920" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="gts7lwifi" />
|
||||||
|
<option name="id" value="gts7lwifi" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="SM-T870" />
|
||||||
|
<option name="screenDensity" value="340" />
|
||||||
|
<option name="screenX" value="1600" />
|
||||||
|
<option name="screenY" value="2560" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="gts7xllite" />
|
||||||
|
<option name="id" value="gts7xllite" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="SM-T738U" />
|
||||||
|
<option name="screenDensity" value="340" />
|
||||||
|
<option name="screenX" value="1600" />
|
||||||
|
<option name="screenY" value="2560" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="gts8uwifi" />
|
||||||
|
<option name="formFactor" value="Tablet" />
|
||||||
|
<option name="id" value="gts8uwifi" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy Tab S8 Ultra" />
|
||||||
|
<option name="screenDensity" value="320" />
|
||||||
|
<option name="screenX" value="1848" />
|
||||||
|
<option name="screenY" value="2960" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="gts8wifi" />
|
||||||
|
<option name="formFactor" value="Tablet" />
|
||||||
|
<option name="id" value="gts8wifi" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy Tab S8" />
|
||||||
|
<option name="screenDensity" value="274" />
|
||||||
|
<option name="screenX" value="1600" />
|
||||||
|
<option name="screenY" value="2560" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="gts9fe" />
|
||||||
|
<option name="id" value="gts9fe" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy Tab S9 FE 5G" />
|
||||||
|
<option name="screenDensity" value="280" />
|
||||||
|
<option name="screenX" value="1440" />
|
||||||
|
<option name="screenY" value="2304" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="gts9wifi" />
|
||||||
|
<option name="id" value="gts9wifi" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="SM-X710" />
|
||||||
|
<option name="screenDensity" value="340" />
|
||||||
|
<option name="screenX" value="1600" />
|
||||||
|
<option name="screenY" value="2560" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="husky" />
|
||||||
|
<option name="id" value="husky" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 8 Pro" />
|
||||||
|
<option name="screenDensity" value="390" />
|
||||||
|
<option name="screenX" value="1008" />
|
||||||
|
<option name="screenY" value="2244" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="30" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="java" />
|
||||||
|
<option name="id" value="java" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="G20" />
|
||||||
|
<option name="screenDensity" value="280" />
|
||||||
|
<option name="screenX" value="720" />
|
||||||
|
<option name="screenY" value="1600" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="komodo" />
|
||||||
|
<option name="id" value="komodo" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9 Pro XL" />
|
||||||
|
<option name="screenDensity" value="360" />
|
||||||
|
<option name="screenX" value="1008" />
|
||||||
|
<option name="screenY" value="2244" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="35" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="komodo" />
|
||||||
|
<option name="id" value="komodo" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9 Pro XL" />
|
||||||
|
<option name="screenDensity" value="360" />
|
||||||
|
<option name="screenX" value="1008" />
|
||||||
|
<option name="screenY" value="2244" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="lion" />
|
||||||
|
<option name="id" value="lion" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="moto g04" />
|
||||||
|
<option name="screenDensity" value="280" />
|
||||||
|
<option name="screenX" value="720" />
|
||||||
|
<option name="screenY" value="1612" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="lynx" />
|
||||||
|
<option name="id" value="lynx" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 7a" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="lyriq" />
|
||||||
|
<option name="id" value="lyriq" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="edge 40" />
|
||||||
|
<option name="screenDensity" value="400" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="manaus" />
|
||||||
|
<option name="id" value="manaus" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="edge 40 neo" />
|
||||||
|
<option name="screenDensity" value="400" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="maui" />
|
||||||
|
<option name="id" value="maui" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="moto g play - 2023" />
|
||||||
|
<option name="screenDensity" value="280" />
|
||||||
|
<option name="screenX" value="720" />
|
||||||
|
<option name="screenY" value="1600" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="o1q" />
|
||||||
|
<option name="id" value="o1q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy S21" />
|
||||||
|
<option name="screenDensity" value="421" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="31" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="oriole" />
|
||||||
|
<option name="id" value="oriole" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 6" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="35" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="pa3q" />
|
||||||
|
<option name="id" value="pa3q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy S25 Ultra" />
|
||||||
|
<option name="screenDensity" value="600" />
|
||||||
|
<option name="screenX" value="1440" />
|
||||||
|
<option name="screenY" value="3120" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="panther" />
|
||||||
|
<option name="id" value="panther" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 7" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="q5q" />
|
||||||
|
<option name="id" value="q5q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy Z Fold5" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1812" />
|
||||||
|
<option name="screenY" value="2176" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="q6q" />
|
||||||
|
<option name="id" value="q6q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy Z Fold6" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1856" />
|
||||||
|
<option name="screenY" value="2160" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="30" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="r11" />
|
||||||
|
<option name="formFactor" value="Wear OS" />
|
||||||
|
<option name="id" value="r11" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel Watch" />
|
||||||
|
<option name="screenDensity" value="320" />
|
||||||
|
<option name="screenX" value="384" />
|
||||||
|
<option name="screenY" value="384" />
|
||||||
|
<option name="type" value="WEAR_OS" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="r11q" />
|
||||||
|
<option name="id" value="r11q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="SM-S711U" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2340" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="30" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="redfin" />
|
||||||
|
<option name="id" value="redfin" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 5" />
|
||||||
|
<option name="screenDensity" value="440" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2340" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="shiba" />
|
||||||
|
<option name="id" value="shiba" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 8" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="t2q" />
|
||||||
|
<option name="id" value="t2q" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy S21 Plus" />
|
||||||
|
<option name="screenDensity" value="394" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="tangorpro" />
|
||||||
|
<option name="formFactor" value="Tablet" />
|
||||||
|
<option name="id" value="tangorpro" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel Tablet" />
|
||||||
|
<option name="screenDensity" value="320" />
|
||||||
|
<option name="screenX" value="1600" />
|
||||||
|
<option name="screenY" value="2560" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="35" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="tegu" />
|
||||||
|
<option name="id" value="tegu" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9a" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2424" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="tokay" />
|
||||||
|
<option name="default" value="true" />
|
||||||
|
<option name="id" value="tokay" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2424" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="35" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="tokay" />
|
||||||
|
<option name="default" value="true" />
|
||||||
|
<option name="id" value="tokay" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2424" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="xcover7" />
|
||||||
|
<option name="id" value="xcover7" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="SM-G556B" />
|
||||||
|
<option name="screenDensity" value="450" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2408" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/deploymentTargetSelector.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetSelector">
|
||||||
|
<selectionStates>
|
||||||
|
<SelectionState runConfigName="app">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
</SelectionState>
|
||||||
|
</selectionStates>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
18
.idea/gradle.xml
generated
Normal 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>
|
||||||
50
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
6
.idea/kotlinc.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="KotlinJpsPluginSettings">
|
||||||
|
<option name="version" value="2.0.21" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/migrations.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectMigrations">
|
||||||
|
<option name="MigrateToGradleLocalJavaHome">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/misc.xml
generated
Normal 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
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
87
.kotlin/errors/errors-1755282386436.log
Normal 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
|
||||||
|
|
||||||
|
|
||||||
87
.kotlin/errors/errors-1755282407075.log
Normal 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
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"java.configuration.updateBuildConfiguration": "disabled"
|
||||||
|
}
|
||||||
16
README.md
@@ -1,2 +1,18 @@
|
|||||||
# OpenClimb
|
# OpenClimb
|
||||||
|
|
||||||
|
This is a FOSS Android app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support.
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
You have two options:
|
||||||
|
|
||||||
|
1. Download the latest APK from the Released page
|
||||||
|
2. Use <a href="">Obtainium</a>
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Android 15+
|
||||||
|
|
||||||
|
## Contribution
|
||||||
|
|
||||||
|
As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out.
|
||||||
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
88
app/build.gradle.kts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.atridad.openclimb"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.atridad.openclimb"
|
||||||
|
minSdk = 31
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 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
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.atridad.openclimb
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("com.atridad.openclimb", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Permissions for file access and camera -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<!-- Permissions for notifications and foreground service -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.OpenClimb"
|
||||||
|
tools:targetApi="31">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.OpenClimb">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- FileProvider for sharing images -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_provider_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
<!-- Session tracking service -->
|
||||||
|
<service
|
||||||
|
android:name=".service.SessionTrackingService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.foreground_service_type"
|
||||||
|
android:value="specialUse" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
27
app/src/main/java/com/atridad/openclimb/MainActivity.kt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package com.atridad.openclimb
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.atridad.openclimb.ui.OpenClimbApp
|
||||||
|
import com.atridad.openclimb.ui.theme.OpenClimbTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
OpenClimbTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
OpenClimbApp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.atridad.openclimb.data.database
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import com.atridad.openclimb.data.model.*
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
class Converters {
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromClimbTypeList(value: List<ClimbType>): String {
|
||||||
|
return Json.encodeToString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toClimbTypeList(value: String): List<ClimbType> {
|
||||||
|
return Json.decodeFromString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromDifficultySystemList(value: List<DifficultySystem>): String {
|
||||||
|
return Json.encodeToString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toDifficultySystemList(value: String): List<DifficultySystem> {
|
||||||
|
return Json.decodeFromString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromStringList(value: List<String>): String {
|
||||||
|
return Json.encodeToString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toStringList(value: String): List<String> {
|
||||||
|
return Json.decodeFromString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromDifficultyGrade(value: DifficultyGrade): String {
|
||||||
|
return Json.encodeToString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toDifficultyGrade(value: String): DifficultyGrade {
|
||||||
|
return Json.decodeFromString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromClimbType(value: ClimbType): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toClimbType(value: String): ClimbType {
|
||||||
|
return ClimbType.valueOf(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromAttemptResult(value: AttemptResult): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toAttemptResult(value: String): AttemptResult {
|
||||||
|
return AttemptResult.valueOf(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromSessionStatus(value: SessionStatus): String {
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toSessionStatus(value: String): SessionStatus {
|
||||||
|
return SessionStatus.valueOf(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.atridad.openclimb.data.database
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
import android.content.Context
|
||||||
|
import com.atridad.openclimb.data.database.dao.*
|
||||||
|
import com.atridad.openclimb.data.model.*
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
Gym::class,
|
||||||
|
Problem::class,
|
||||||
|
ClimbSession::class,
|
||||||
|
Attempt::class
|
||||||
|
],
|
||||||
|
version = 5,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
abstract class OpenClimbDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun gymDao(): GymDao
|
||||||
|
abstract fun problemDao(): ProblemDao
|
||||||
|
abstract fun climbSessionDao(): ClimbSessionDao
|
||||||
|
abstract fun attemptDao(): AttemptDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: OpenClimbDatabase? = null
|
||||||
|
|
||||||
|
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
val cursor = database.query("PRAGMA table_info(climb_sessions)")
|
||||||
|
val existingColumns = mutableSetOf<String>()
|
||||||
|
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val columnName = cursor.getString(cursor.getColumnIndexOrThrow("name"))
|
||||||
|
existingColumns.add(columnName)
|
||||||
|
}
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
if (!existingColumns.contains("startTime")) {
|
||||||
|
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT")
|
||||||
|
}
|
||||||
|
if (!existingColumns.contains("endTime")) {
|
||||||
|
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT")
|
||||||
|
}
|
||||||
|
if (!existingColumns.contains("status")) {
|
||||||
|
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'")
|
||||||
|
}
|
||||||
|
|
||||||
|
database.execSQL("UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL")
|
||||||
|
database.execSQL("UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDatabase(context: Context): OpenClimbDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
OpenClimbDatabase::class.java,
|
||||||
|
"openclimb_database"
|
||||||
|
)
|
||||||
|
.addMigrations(MIGRATION_4_5, MIGRATION_5_6)
|
||||||
|
.enableMultiInstanceInvalidation()
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.atridad.openclimb.data.database.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.atridad.openclimb.data.model.Attempt
|
||||||
|
import com.atridad.openclimb.data.model.AttemptResult
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface AttemptDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts ORDER BY timestamp DESC")
|
||||||
|
fun getAllAttempts(): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE id = :id")
|
||||||
|
suspend fun getAttemptById(id: String): Attempt?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE sessionId = :sessionId ORDER BY timestamp ASC")
|
||||||
|
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE problemId = :problemId ORDER BY timestamp DESC")
|
||||||
|
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE sessionId = :sessionId AND problemId = :problemId ORDER BY timestamp ASC")
|
||||||
|
fun getAttemptsBySessionAndProblem(sessionId: String, problemId: String): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE result = :result ORDER BY timestamp DESC")
|
||||||
|
fun getAttemptsByResult(result: AttemptResult): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE timestamp BETWEEN :startDate AND :endDate ORDER BY timestamp DESC")
|
||||||
|
fun getAttemptsInDateRange(startDate: String, endDate: String): Flow<List<Attempt>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAttempt(attempt: Attempt)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAttempts(attempts: List<Attempt>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateAttempt(attempt: Attempt)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteAttempt(attempt: Attempt)
|
||||||
|
|
||||||
|
@Query("DELETE FROM attempts WHERE id = :id")
|
||||||
|
suspend fun deleteAttemptById(id: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM attempts WHERE sessionId = :sessionId")
|
||||||
|
suspend fun deleteAttemptsBySession(sessionId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM attempts WHERE problemId = :problemId")
|
||||||
|
suspend fun deleteAttemptsByProblem(problemId: String)
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM attempts")
|
||||||
|
suspend fun getAttemptsCount(): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId")
|
||||||
|
suspend fun getAttemptsCountBySession(sessionId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM attempts WHERE problemId = :problemId")
|
||||||
|
suspend fun getAttemptsCountByProblem(problemId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM attempts WHERE result = :result")
|
||||||
|
suspend fun getAttemptsCountByResult(result: AttemptResult): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM attempts WHERE problemId = :problemId AND result IN ('SUCCESS', 'FLASH', 'REDPOINT', 'ONSIGHT')")
|
||||||
|
suspend fun getSuccessfulAttemptsCountByProblem(problemId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE problemId = :problemId AND result IN ('SUCCESS', 'FLASH', 'REDPOINT', 'ONSIGHT') ORDER BY timestamp ASC LIMIT 1")
|
||||||
|
suspend fun getFirstSuccessfulAttempt(problemId: String): Attempt?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM attempts WHERE problemId = :problemId ORDER BY timestamp DESC LIMIT 1")
|
||||||
|
suspend fun getLatestAttemptForProblem(problemId: String): Attempt?
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.atridad.openclimb.data.database.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.atridad.openclimb.data.model.ClimbSession
|
||||||
|
import com.atridad.openclimb.data.model.SessionStatus
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ClimbSessionDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions ORDER BY date DESC")
|
||||||
|
fun getAllSessions(): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE id = :id")
|
||||||
|
suspend fun getSessionById(id: String): ClimbSession?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE gymId = :gymId ORDER BY date DESC")
|
||||||
|
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE date = :date ORDER BY createdAt DESC")
|
||||||
|
fun getSessionsByDate(date: String): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
|
||||||
|
fun getSessionsInDateRange(startDate: String, endDate: String): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions ORDER BY date DESC LIMIT :limit")
|
||||||
|
fun getRecentSessions(limit: Int = 10): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertSession(session: ClimbSession)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertSessions(sessions: List<ClimbSession>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateSession(session: ClimbSession)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteSession(session: ClimbSession)
|
||||||
|
|
||||||
|
@Query("DELETE FROM climb_sessions WHERE id = :id")
|
||||||
|
suspend fun deleteSessionById(id: String)
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM climb_sessions")
|
||||||
|
suspend fun getSessionsCount(): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM climb_sessions WHERE gymId = :gymId")
|
||||||
|
suspend fun getSessionsCountByGym(gymId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM climb_sessions WHERE date BETWEEN :startDate AND :endDate")
|
||||||
|
suspend fun getSessionsCountInDateRange(startDate: String, endDate: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT DISTINCT date FROM climb_sessions ORDER BY date DESC")
|
||||||
|
suspend fun getUniqueDates(): List<String>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE status = :status ORDER BY date DESC")
|
||||||
|
fun getSessionsByStatus(status: SessionStatus): Flow<List<ClimbSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
||||||
|
suspend fun getActiveSession(): ClimbSession?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
||||||
|
fun getActiveSessionFlow(): Flow<ClimbSession?>
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.atridad.openclimb.data.database.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.atridad.openclimb.data.model.ClimbType
|
||||||
|
import com.atridad.openclimb.data.model.Gym
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface GymDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM gyms ORDER BY name ASC")
|
||||||
|
fun getAllGyms(): Flow<List<Gym>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM gyms WHERE id = :id")
|
||||||
|
suspend fun getGymById(id: String): Gym?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM gyms WHERE :climbType IN (supportedClimbTypes)")
|
||||||
|
fun getGymsByClimbType(climbType: ClimbType): Flow<List<Gym>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertGym(gym: Gym)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertGyms(gyms: List<Gym>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateGym(gym: Gym)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteGym(gym: Gym)
|
||||||
|
|
||||||
|
@Query("DELETE FROM gyms WHERE id = :id")
|
||||||
|
suspend fun deleteGymById(id: String)
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM gyms")
|
||||||
|
suspend fun getGymsCount(): Int
|
||||||
|
|
||||||
|
@Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'")
|
||||||
|
fun searchGyms(searchQuery: String): Flow<List<Gym>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.atridad.openclimb.data.database.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.atridad.openclimb.data.model.ClimbType
|
||||||
|
import com.atridad.openclimb.data.model.Problem
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ProblemDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems ORDER BY updatedAt DESC")
|
||||||
|
fun getAllProblems(): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE id = :id")
|
||||||
|
suspend fun getProblemById(id: String): Problem?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC")
|
||||||
|
fun getProblemsByGym(gymId: String): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC")
|
||||||
|
fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC")
|
||||||
|
fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC")
|
||||||
|
fun getActiveProblems(): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC")
|
||||||
|
fun getActiveProblemsByGym(gymId: String): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertProblem(problem: Problem)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertProblems(problems: List<Problem>)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateProblem(problem: Problem)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteProblem(problem: Problem)
|
||||||
|
|
||||||
|
@Query("DELETE FROM problems WHERE id = :id")
|
||||||
|
suspend fun deleteProblemById(id: String)
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId")
|
||||||
|
suspend fun getProblemsCountByGym(gymId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM problems WHERE isActive = 1")
|
||||||
|
suspend fun getActiveProblemsCount(): Int
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM problems
|
||||||
|
WHERE (name LIKE '%' || :searchQuery || '%'
|
||||||
|
OR description LIKE '%' || :searchQuery || '%'
|
||||||
|
OR location LIKE '%' || :searchQuery || '%'
|
||||||
|
OR setter LIKE '%' || :searchQuery || '%')
|
||||||
|
ORDER BY updatedAt DESC
|
||||||
|
""")
|
||||||
|
fun searchProblems(searchQuery: String): Flow<List<Problem>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.atridad.openclimb.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ClimbType {
|
||||||
|
ROPE,
|
||||||
|
BOULDER
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
45
app/src/main/java/com/atridad/openclimb/data/model/Gym.kt
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package com.atridad.openclimb.data.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Entity(tableName = "gyms")
|
||||||
|
@Serializable
|
||||||
|
data class Gym(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val location: String? = null,
|
||||||
|
val supportedClimbTypes: List<ClimbType>,
|
||||||
|
val difficultySystems: List<DifficultySystem>, // 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package com.atridad.openclimb.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Environment
|
||||||
|
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||||
|
import com.atridad.openclimb.data.model.*
|
||||||
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
|
import com.atridad.openclimb.utils.ZipExportImportUtils
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class ClimbRepository(
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZIP Export functionality with images
|
||||||
|
suspend fun exportAllDataToZip(directory: File? = null): File {
|
||||||
|
val allGyms = gymDao.getAllGyms().first()
|
||||||
|
val allProblems = problemDao.getAllProblems().first()
|
||||||
|
val allSessions = sessionDao.getAllSessions().first()
|
||||||
|
val allAttempts = attemptDao.getAllAttempts().first()
|
||||||
|
|
||||||
|
val exportData = ClimbDataExport(
|
||||||
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
|
gyms = allGyms,
|
||||||
|
problems = allProblems,
|
||||||
|
sessions = allSessions,
|
||||||
|
attempts = allAttempts
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect all referenced image paths
|
||||||
|
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||||
|
|
||||||
|
return ZipExportImportUtils.createExportZip(
|
||||||
|
context = context,
|
||||||
|
exportData = exportData,
|
||||||
|
referencedImagePaths = referencedImagePaths,
|
||||||
|
directory = directory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||||
|
val gyms = gymDao.getAllGyms().first()
|
||||||
|
val problems = problemDao.getAllProblems().first()
|
||||||
|
val sessions = sessionDao.getAllSessions().first()
|
||||||
|
val attempts = attemptDao.getAllAttempts().first()
|
||||||
|
|
||||||
|
val exportData = ClimbDataExport(
|
||||||
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
|
gyms = gyms,
|
||||||
|
problems = problems,
|
||||||
|
sessions = sessions,
|
||||||
|
attempts = attempts
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect all referenced image paths
|
||||||
|
val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet()
|
||||||
|
|
||||||
|
ZipExportImportUtils.createExportZipToUri(
|
||||||
|
context = context,
|
||||||
|
uri = uri,
|
||||||
|
exportData = exportData,
|
||||||
|
referencedImagePaths = referencedImagePaths
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importDataFromZip(file: File) {
|
||||||
|
try {
|
||||||
|
val importResult = ZipExportImportUtils.extractImportZip(context, file)
|
||||||
|
val importData = json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
|
||||||
|
|
||||||
|
// Update problem image paths with the new imported paths
|
||||||
|
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths(
|
||||||
|
importData.problems,
|
||||||
|
importResult.importedImagePaths
|
||||||
|
)
|
||||||
|
|
||||||
|
// Import gyms (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 with updated image paths
|
||||||
|
updatedProblems.forEach { problem ->
|
||||||
|
try {
|
||||||
|
problemDao.insertProblem(problem)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
problemDao.updateProblem(problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import sessions
|
||||||
|
importData.sessions.forEach { session ->
|
||||||
|
try {
|
||||||
|
sessionDao.insertSession(session)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sessionDao.updateSession(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import attempts
|
||||||
|
importData.attempts.forEach { attempt ->
|
||||||
|
try {
|
||||||
|
attemptDao.insertAttempt(attempt)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
attemptDao.updateAttempt(attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Failed to import data: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
data class ClimbDataExport(
|
||||||
|
val exportedAt: String,
|
||||||
|
val gyms: List<Gym>,
|
||||||
|
val problems: List<Problem>,
|
||||||
|
val sessions: List<ClimbSession>,
|
||||||
|
val attempts: List<Attempt>
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.atridad.openclimb.navigation
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
|
data class BottomNavigationItem(
|
||||||
|
val screen: Screen,
|
||||||
|
val icon: ImageVector,
|
||||||
|
val label: String
|
||||||
|
)
|
||||||
|
|
||||||
|
val bottomNavigationItems = listOf(
|
||||||
|
BottomNavigationItem(
|
||||||
|
screen = Screen.Sessions,
|
||||||
|
icon = Icons.Default.PlayArrow,
|
||||||
|
label = "Sessions"
|
||||||
|
),
|
||||||
|
BottomNavigationItem(
|
||||||
|
screen = Screen.Problems,
|
||||||
|
icon = Icons.Default.Star,
|
||||||
|
label = "Problems"
|
||||||
|
),
|
||||||
|
BottomNavigationItem(
|
||||||
|
screen = Screen.Analytics,
|
||||||
|
icon = Icons.Default.Info,
|
||||||
|
label = "Analytics"
|
||||||
|
),
|
||||||
|
BottomNavigationItem(
|
||||||
|
screen = Screen.Gyms,
|
||||||
|
icon = Icons.Default.LocationOn,
|
||||||
|
label = "Gyms"
|
||||||
|
),
|
||||||
|
BottomNavigationItem(
|
||||||
|
screen = Screen.Settings,
|
||||||
|
icon = Icons.Default.Settings,
|
||||||
|
label = "Settings"
|
||||||
|
)
|
||||||
|
)
|
||||||
42
app/src/main/java/com/atridad/openclimb/navigation/Screen.kt
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package com.atridad.openclimb.navigation
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class Screen {
|
||||||
|
@Serializable
|
||||||
|
data object Sessions : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Problems : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Analytics : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Gyms : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Settings : Screen()
|
||||||
|
|
||||||
|
// Detail screens
|
||||||
|
@Serializable
|
||||||
|
data class SessionDetail(val sessionId: String) : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProblemDetail(val problemId: String) : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GymDetail(val gymId: String) : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AddEditGym(val gymId: String? = null) : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen()
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,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()
|
||||||
|
}
|
||||||
|
}
|
||||||
274
app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt
Normal 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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun FullscreenImageViewer(
|
||||||
|
imagePaths: List<String>,
|
||||||
|
initialIndex: Int = 0,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = initialIndex,
|
||||||
|
pageCount = { imagePaths.size }
|
||||||
|
)
|
||||||
|
val thumbnailListState = rememberLazyListState()
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Auto-scroll thumbnail list to center current image
|
||||||
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
|
thumbnailListState.animateScrollToItem(
|
||||||
|
index = pagerState.currentPage,
|
||||||
|
scrollOffset = -200 // Center the item
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
decorFitsSystemWindows = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
) {
|
||||||
|
// Main image pager
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) { page ->
|
||||||
|
ZoomableImage(
|
||||||
|
imagePath = imagePaths[page],
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(16.dp)
|
||||||
|
.background(
|
||||||
|
Color.Black.copy(alpha = 0.5f),
|
||||||
|
CircleShape
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image counter
|
||||||
|
if (imagePaths.size > 1) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail strip (if multiple images)
|
||||||
|
if (imagePaths.size > 1) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
state = thumbnailListState,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(imagePaths) { index, imagePath ->
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
val isSelected = index == pagerState.currentPage
|
||||||
|
|
||||||
|
AsyncImage(
|
||||||
|
model = imageFile,
|
||||||
|
contentDescription = "Thumbnail ${index + 1}",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(60.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable {
|
||||||
|
coroutineScope.launch {
|
||||||
|
pagerState.animateScrollToPage(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.then(
|
||||||
|
if (isSelected) {
|
||||||
|
Modifier.background(
|
||||||
|
Color.White.copy(alpha = 0.3f),
|
||||||
|
RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
} else Modifier
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ZoomableImage(
|
||||||
|
imagePath: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
|
||||||
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
|
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTransformGestures(
|
||||||
|
onGesture = { _, pan, zoom, _ ->
|
||||||
|
scale = (scale * zoom).coerceIn(0.5f, 5f)
|
||||||
|
|
||||||
|
val maxOffsetX = (size.width * (scale - 1)) / 2
|
||||||
|
val maxOffsetY = (size.height * (scale - 1)) / 2
|
||||||
|
|
||||||
|
offsetX = (offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
|
||||||
|
offsetY = (offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageFile,
|
||||||
|
contentDescription = "Full screen image",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer(
|
||||||
|
scaleX = scale,
|
||||||
|
scaleY = scale,
|
||||||
|
translationX = offsetX,
|
||||||
|
translationY = offsetY
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImageDisplay(
|
||||||
|
imagePaths: List<String>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
imageSize: Int = 120,
|
||||||
|
onImageClick: ((Int) -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
if (imagePaths.isNotEmpty()) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(imagePaths) { index, imagePath ->
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
|
||||||
|
AsyncImage(
|
||||||
|
model = imageFile,
|
||||||
|
contentDescription = "Problem photo",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(imageSize.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable(enabled = onImageClick != null) {
|
||||||
|
onImageClick?.invoke(index)
|
||||||
|
},
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImageDisplaySection(
|
||||||
|
imagePaths: List<String>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String = "Photos",
|
||||||
|
onImageClick: ((Int) -> Unit)? = null
|
||||||
|
) {
|
||||||
|
if (imagePaths.isNotEmpty()) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
ImageDisplay(
|
||||||
|
imagePaths = imagePaths,
|
||||||
|
imageSize = 120,
|
||||||
|
onImageClick = onImageClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImagePicker(
|
||||||
|
imageUris: List<String>,
|
||||||
|
onImagesChanged: (List<String>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
maxImages: Int = 5
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var tempImageUris by remember { mutableStateOf(imageUris) }
|
||||||
|
|
||||||
|
// Image picker launcher
|
||||||
|
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetMultipleContents()
|
||||||
|
) { uris ->
|
||||||
|
if (uris.isNotEmpty()) {
|
||||||
|
val currentCount = tempImageUris.size
|
||||||
|
val remainingSlots = maxImages - currentCount
|
||||||
|
val urisToProcess = uris.take(remainingSlots)
|
||||||
|
|
||||||
|
// Process each selected image
|
||||||
|
val newImagePaths = mutableListOf<String>()
|
||||||
|
urisToProcess.forEach { uri ->
|
||||||
|
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
||||||
|
if (imagePath != null) {
|
||||||
|
newImagePaths.add(imagePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newImagePaths.isNotEmpty()) {
|
||||||
|
val updatedUris = tempImageUris + newImagePaths
|
||||||
|
tempImageUris = updatedUris
|
||||||
|
onImagesChanged(updatedUris)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Photos (${tempImageUris.size}/$maxImages)",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tempImageUris.size < maxImages) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
imagePickerLauncher.launch("image/*")
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Add Photos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempImageUris.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(tempImageUris) { imagePath ->
|
||||||
|
ImageItem(
|
||||||
|
imagePath = imagePath,
|
||||||
|
onRemove = {
|
||||||
|
val updatedUris = tempImageUris.filter { it != imagePath }
|
||||||
|
tempImageUris = updatedUris
|
||||||
|
onImagesChanged(updatedUris)
|
||||||
|
|
||||||
|
// Delete the image file
|
||||||
|
ImageUtils.deleteImage(context, imagePath)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(100.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Add photos of this problem",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImageItem(
|
||||||
|
imagePath: String,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.size(80.dp)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageFile,
|
||||||
|
contentDescription = "Problem photo",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onRemove,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.size(24.dp)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Remove photo",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(2.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1055
app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1855
app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt
Normal file
127
app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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.components.FullscreenImageViewer
|
||||||
|
import com.atridad.openclimb.ui.components.ImageDisplay
|
||||||
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ProblemsScreen(
|
||||||
|
viewModel: ClimbViewModel,
|
||||||
|
onNavigateToProblemDetail: (String) -> Unit,
|
||||||
|
onNavigateToAddProblem: (String?) -> Unit
|
||||||
|
) {
|
||||||
|
val problems by viewModel.problems.collectAsState()
|
||||||
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
|
var showImageViewer by remember { mutableStateOf(false) }
|
||||||
|
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
|
var selectedImageIndex by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
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) },
|
||||||
|
onImageClick = { imagePaths, index ->
|
||||||
|
selectedImagePaths = imagePaths
|
||||||
|
selectedImageIndex = index
|
||||||
|
showImageViewer = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fullscreen Image Viewer
|
||||||
|
if (showImageViewer && selectedImagePaths.isNotEmpty()) {
|
||||||
|
FullscreenImageViewer(
|
||||||
|
imagePaths = selectedImagePaths,
|
||||||
|
initialIndex = selectedImageIndex,
|
||||||
|
onDismiss = { showImageViewer = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ProblemCard(
|
||||||
|
problem: Problem,
|
||||||
|
gymName: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onImageClick: ((List<String>, Int) -> Unit)? = null
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = problem.name ?: "Unnamed Problem",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = gymName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = problem.difficulty.grade,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = problem.climbType.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display images if any
|
||||||
|
if (problem.imagePaths.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
ImageDisplay(
|
||||||
|
imagePaths = problem.imagePaths.take(3), // Show max 3 images in list
|
||||||
|
imageSize = 60,
|
||||||
|
onImageClick = { index ->
|
||||||
|
onImageClick?.invoke(problem.imagePaths, index)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!problem.isActive) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Inactive",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
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 - accepts both ZIP and JSON files
|
||||||
|
val importLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent()
|
||||||
|
) { uri ->
|
||||||
|
uri?.let {
|
||||||
|
try {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
|
// Determine file extension from content resolver
|
||||||
|
val fileName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
||||||
|
cursor.getString(nameIndex)
|
||||||
|
} else null
|
||||||
|
} ?: "import_file"
|
||||||
|
|
||||||
|
val extension = fileName.substringAfterLast(".", "")
|
||||||
|
val tempFileName = if (extension.isNotEmpty()) "temp_import.$extension" else "temp_import"
|
||||||
|
val tempFile = File(context.cacheDir, tempFileName)
|
||||||
|
|
||||||
|
inputStream?.use { input ->
|
||||||
|
tempFile.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.importData(tempFile)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
viewModel.setError("Failed to read file: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File picker launcher for export - save location (ZIP format with images)
|
||||||
|
val exportZipLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/zip")
|
||||||
|
) { uri ->
|
||||||
|
uri?.let {
|
||||||
|
try {
|
||||||
|
viewModel.exportDataToZipUri(context, uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
viewModel.setError("Failed to save file: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON export launcher
|
||||||
|
val exportJsonLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/json")
|
||||||
|
) { uri ->
|
||||||
|
uri?.let {
|
||||||
|
try {
|
||||||
|
viewModel.exportDataToUri(context, uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
viewModel.setError("Failed to save file: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Settings",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Management Section
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Data Management",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Export Data
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Export Data with Images") },
|
||||||
|
supportingContent = { Text("Export all your climbing data and images to ZIP file (recommended)") },
|
||||||
|
leadingContent = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||||
|
trailingContent = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val defaultFileName = "openclimb_export_${
|
||||||
|
java.time.LocalDateTime.now()
|
||||||
|
.toString()
|
||||||
|
.replace(":", "-")
|
||||||
|
.replace(".", "-")
|
||||||
|
}.zip"
|
||||||
|
exportZipLauncher.launch(defaultFileName)
|
||||||
|
},
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Export ZIP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Export Data Only") },
|
||||||
|
supportingContent = { Text("Export climbing data to JSON without images") },
|
||||||
|
leadingContent = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||||
|
trailingContent = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val defaultFileName = "openclimb_export_${
|
||||||
|
java.time.LocalDateTime.now()
|
||||||
|
.toString()
|
||||||
|
.replace(":", "-")
|
||||||
|
.replace(".", "-")
|
||||||
|
}.json"
|
||||||
|
exportJsonLauncher.launch(defaultFileName)
|
||||||
|
},
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Export JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Import Data") },
|
||||||
|
supportingContent = { Text("Import climbing data from ZIP or JSON file") },
|
||||||
|
leadingContent = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||||
|
trailingContent = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
importLauncher.launch("*/*")
|
||||||
|
},
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Import")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// App Information Section
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "App Information",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/src/main/java/com/atridad/openclimb/ui/theme/Color.kt
Normal 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
|
||||||
123
app/src/main/java/com/atridad/openclimb/ui/theme/Theme.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
18
app/src/main/java/com/atridad/openclimb/ui/theme/Type.kt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package com.atridad.openclimb.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
// Set of Material typography styles to start with
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package com.atridad.openclimb.ui.viewmodel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.atridad.openclimb.data.model.*
|
||||||
|
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||||
|
import com.atridad.openclimb.service.SessionTrackingService
|
||||||
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
|
import com.atridad.openclimb.utils.SessionShareUtils
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ClimbViewModel(
|
||||||
|
private val repository: ClimbRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// UI State flows
|
||||||
|
private val _uiState = MutableStateFlow(ClimbUiState())
|
||||||
|
val uiState: StateFlow<ClimbUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
// Data flows
|
||||||
|
val gyms = repository.getAllGyms().stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
val problems = repository.getAllProblems().stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
val sessions = repository.getAllSessions().stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
val activeSession = repository.getActiveSessionFlow().stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(),
|
||||||
|
initialValue = null
|
||||||
|
)
|
||||||
|
|
||||||
|
val attempts = repository.getAllAttempts().stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Gym operations
|
||||||
|
fun addGym(gym: Gym) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.insertGym(gym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGym(gym: Gym) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updateGym(gym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteGym(gym: Gym) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteGym(gym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGymById(id: String): Flow<Gym?> = flow {
|
||||||
|
emit(repository.getGymById(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problem operations
|
||||||
|
fun addProblem(problem: Problem) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.insertProblem(problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProblem(problem: Problem) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updateProblem(problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteProblem(problem: Problem, context: Context) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Delete associated images
|
||||||
|
problem.imagePaths.forEach { imagePath ->
|
||||||
|
ImageUtils.deleteImage(context, imagePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.deleteProblem(problem)
|
||||||
|
|
||||||
|
// Clean up any remaining orphaned images
|
||||||
|
cleanupOrphanedImages(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun cleanupOrphanedImages(context: Context) {
|
||||||
|
val allProblems = repository.getAllProblems().first()
|
||||||
|
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||||
|
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getProblemById(id: String): Flow<Problem?> = flow {
|
||||||
|
emit(repository.getProblemById(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getProblemsByGym(gymId: String): Flow<List<Problem>> =
|
||||||
|
repository.getProblemsByGym(gymId)
|
||||||
|
|
||||||
|
// Session operations
|
||||||
|
fun addSession(session: ClimbSession) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.insertSession(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSession(session: ClimbSession) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updateSession(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSession(session: ClimbSession) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteSession(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSessionById(id: String): Flow<ClimbSession?> = flow {
|
||||||
|
emit(repository.getSessionById(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
||||||
|
repository.getSessionsByGym(gymId)
|
||||||
|
|
||||||
|
// Active session management
|
||||||
|
fun startSession(context: Context, gymId: String, notes: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val existingActive = repository.getActiveSession()
|
||||||
|
if (existingActive != null) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
error = "There's already an active session. Please end it first."
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val newSession = ClimbSession.create(gymId = gymId, notes = notes)
|
||||||
|
repository.insertSession(newSession)
|
||||||
|
|
||||||
|
// Start the tracking service
|
||||||
|
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
|
||||||
|
context.startForegroundService(serviceIntent)
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
message = "Session started successfully!"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endSession(context: Context, sessionId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val session = repository.getSessionById(sessionId)
|
||||||
|
if (session != null && session.status == SessionStatus.ACTIVE) {
|
||||||
|
val completedSession = with(ClimbSession) { session.complete() }
|
||||||
|
repository.updateSession(completedSession)
|
||||||
|
|
||||||
|
// Stop the tracking service
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZIP Export operations with images
|
||||||
|
fun exportDataToZip(context: Context, directory: File? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
val exportFile = repository.exportAllDataToZip(directory)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
message = "Data with images exported to: ${exportFile.absolutePath}"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Export failed: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
repository.exportAllDataToZipUri(context, uri)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
message = "Data with images exported successfully"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Export failed: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importData(file: File) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
|
// Check if it's a ZIP file or JSON file
|
||||||
|
if (file.name.lowercase().endsWith(".zip")) {
|
||||||
|
repository.importDataFromZip(file)
|
||||||
|
} else {
|
||||||
|
repository.importDataFromJson(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
message = "Data imported successfully from ${file.name}"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = "Import failed: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI state operations
|
||||||
|
fun clearMessage() {
|
||||||
|
_uiState.value = _uiState.value.copy(message = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setError(message: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
196
app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package com.atridad.openclimb.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
object ImageUtils {
|
||||||
|
|
||||||
|
private const val IMAGES_DIR = "problem_images"
|
||||||
|
private const val MAX_IMAGE_SIZE = 1024
|
||||||
|
private const val IMAGE_QUALITY = 85
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the images directory if it doesn't exist
|
||||||
|
*/
|
||||||
|
private fun getImagesDirectory(context: Context): File {
|
||||||
|
val imagesDir = File(context.filesDir, IMAGES_DIR)
|
||||||
|
if (!imagesDir.exists()) {
|
||||||
|
imagesDir.mkdirs()
|
||||||
|
}
|
||||||
|
return imagesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves an image from URI to app's private storage with compression
|
||||||
|
* @param context Android context
|
||||||
|
* @param imageUri URI of the image to save
|
||||||
|
* @return The relative file path if successful, null otherwise
|
||||||
|
*/
|
||||||
|
fun saveImageFromUri(context: Context, imageUri: Uri): String? {
|
||||||
|
return try {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||||
|
inputStream?.use { input ->
|
||||||
|
// Decode and compress the image
|
||||||
|
val originalBitmap = BitmapFactory.decodeStream(input)
|
||||||
|
val compressedBitmap = compressImage(originalBitmap)
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
val filename = "${UUID.randomUUID()}.jpg"
|
||||||
|
val imageFile = File(getImagesDirectory(context), filename)
|
||||||
|
|
||||||
|
// Save compressed image
|
||||||
|
FileOutputStream(imageFile).use { output ->
|
||||||
|
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up bitmaps
|
||||||
|
originalBitmap.recycle()
|
||||||
|
compressedBitmap.recycle()
|
||||||
|
|
||||||
|
// Return relative path
|
||||||
|
"$IMAGES_DIR/$filename"
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compresses and resizes an image bitmap
|
||||||
|
*/
|
||||||
|
private fun compressImage(original: Bitmap): Bitmap {
|
||||||
|
val width = original.width
|
||||||
|
val height = original.height
|
||||||
|
|
||||||
|
// Calculate the scaling factor
|
||||||
|
val scaleFactor = if (width > height) {
|
||||||
|
if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f
|
||||||
|
} else {
|
||||||
|
if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (scaleFactor < 1f) {
|
||||||
|
val newWidth = (width * scaleFactor).toInt()
|
||||||
|
val newHeight = (height * scaleFactor).toInt()
|
||||||
|
Bitmap.createScaledBitmap(original, newWidth, newHeight, true)
|
||||||
|
} else {
|
||||||
|
original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the full file path for an image
|
||||||
|
* @param context Android context
|
||||||
|
* @param relativePath The relative path returned by saveImageFromUri
|
||||||
|
* @return Full file path
|
||||||
|
*/
|
||||||
|
fun getImageFile(context: Context, relativePath: String): File {
|
||||||
|
return File(context.filesDir, relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an image file
|
||||||
|
* @param context Android context
|
||||||
|
* @param relativePath The relative path of the image to delete
|
||||||
|
* @return true if deleted successfully, false otherwise
|
||||||
|
*/
|
||||||
|
fun deleteImage(context: Context, relativePath: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val file = getImageFile(context, relativePath)
|
||||||
|
file.delete()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies an image file to export directory
|
||||||
|
* @param context Android context
|
||||||
|
* @param relativePath The relative path of the image
|
||||||
|
* @param exportDir The directory to copy to
|
||||||
|
* @return The filename in the export directory, null if failed
|
||||||
|
*/
|
||||||
|
fun copyImageForExport(context: Context, relativePath: String, exportDir: File): String? {
|
||||||
|
return try {
|
||||||
|
val sourceFile = getImageFile(context, relativePath)
|
||||||
|
if (!sourceFile.exists()) return null
|
||||||
|
|
||||||
|
val filename = sourceFile.name
|
||||||
|
val destFile = File(exportDir, filename)
|
||||||
|
|
||||||
|
sourceFile.copyTo(destFile, overwrite = true)
|
||||||
|
filename
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports an image file from the import directory
|
||||||
|
* @param context Android context
|
||||||
|
* @param sourceFile The source image file to import
|
||||||
|
* @return The relative path in app storage, null if failed
|
||||||
|
*/
|
||||||
|
fun importImageFile(context: Context, sourceFile: File): String? {
|
||||||
|
return try {
|
||||||
|
if (!sourceFile.exists()) return null
|
||||||
|
|
||||||
|
// Generate new filename to avoid conflicts
|
||||||
|
val extension = sourceFile.extension.ifEmpty { "jpg" }
|
||||||
|
val filename = "${UUID.randomUUID()}.$extension"
|
||||||
|
val destFile = File(getImagesDirectory(context), filename)
|
||||||
|
|
||||||
|
sourceFile.copyTo(destFile, overwrite = true)
|
||||||
|
"$IMAGES_DIR/$filename"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all image files in the images directory
|
||||||
|
* @param context Android context
|
||||||
|
* @return List of relative paths for all images
|
||||||
|
*/
|
||||||
|
fun getAllImages(context: Context): List<String> {
|
||||||
|
return try {
|
||||||
|
val imagesDir = getImagesDirectory(context)
|
||||||
|
imagesDir.listFiles()?.mapNotNull { file ->
|
||||||
|
if (file.isFile && (file.extension == "jpg" || file.extension == "jpeg" || file.extension == "png")) {
|
||||||
|
"$IMAGES_DIR/${file.name}"
|
||||||
|
} else null
|
||||||
|
} ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up orphaned images that are not referenced by any problems
|
||||||
|
* @param context Android context
|
||||||
|
* @param referencedPaths Set of image paths that are still being used
|
||||||
|
*/
|
||||||
|
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
|
||||||
|
try {
|
||||||
|
val allImages = getAllImages(context)
|
||||||
|
val orphanedImages = allImages.filter { it !in referencedPaths }
|
||||||
|
|
||||||
|
orphanedImages.forEach { path ->
|
||||||
|
deleteImage(context, path)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package com.atridad.openclimb.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
object ZipExportImportUtils {
|
||||||
|
|
||||||
|
private const val DATA_JSON_FILENAME = "data.json"
|
||||||
|
private const val IMAGES_DIR_NAME = "images"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a ZIP file containing the JSON data and all referenced images
|
||||||
|
* @param context Android context
|
||||||
|
* @param exportData The data to export (should be serializable)
|
||||||
|
* @param referencedImagePaths Set of image paths referenced in the data
|
||||||
|
* @param directory Optional directory to save to, uses default if null
|
||||||
|
* @return The created ZIP file
|
||||||
|
*/
|
||||||
|
fun createExportZip(
|
||||||
|
context: Context,
|
||||||
|
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
|
||||||
|
referencedImagePaths: Set<String>,
|
||||||
|
directory: File? = null
|
||||||
|
): File {
|
||||||
|
val exportDir = directory ?: File(context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS), "OpenClimb")
|
||||||
|
if (!exportDir.exists()) {
|
||||||
|
exportDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
|
||||||
|
val zipFile = File(exportDir, "openclimb_export_$timestamp.zip")
|
||||||
|
|
||||||
|
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
|
||||||
|
// Add JSON data file
|
||||||
|
val json = Json { prettyPrint = true }
|
||||||
|
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
|
||||||
|
|
||||||
|
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
|
||||||
|
zipOut.putNextEntry(jsonEntry)
|
||||||
|
zipOut.write(jsonString.toByteArray())
|
||||||
|
zipOut.closeEntry()
|
||||||
|
|
||||||
|
// Add images
|
||||||
|
referencedImagePaths.forEach { imagePath ->
|
||||||
|
try {
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
if (imageFile.exists()) {
|
||||||
|
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
|
||||||
|
zipOut.putNextEntry(imageEntry)
|
||||||
|
|
||||||
|
FileInputStream(imageFile).use { imageInput ->
|
||||||
|
imageInput.copyTo(zipOut)
|
||||||
|
}
|
||||||
|
zipOut.closeEntry()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error but continue with other images
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return zipFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a ZIP file and writes it to a provided URI
|
||||||
|
* @param context Android context
|
||||||
|
* @param uri The URI to write to
|
||||||
|
* @param exportData The data to export
|
||||||
|
* @param referencedImagePaths Set of image paths referenced in the data
|
||||||
|
*/
|
||||||
|
fun createExportZipToUri(
|
||||||
|
context: Context,
|
||||||
|
uri: android.net.Uri,
|
||||||
|
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
|
||||||
|
referencedImagePaths: Set<String>
|
||||||
|
) {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
ZipOutputStream(outputStream).use { zipOut ->
|
||||||
|
// Add JSON data file
|
||||||
|
val json = Json { prettyPrint = true }
|
||||||
|
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
|
||||||
|
|
||||||
|
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
|
||||||
|
zipOut.putNextEntry(jsonEntry)
|
||||||
|
zipOut.write(jsonString.toByteArray())
|
||||||
|
zipOut.closeEntry()
|
||||||
|
|
||||||
|
// Add images
|
||||||
|
referencedImagePaths.forEach { imagePath ->
|
||||||
|
try {
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
if (imageFile.exists()) {
|
||||||
|
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
|
||||||
|
zipOut.putNextEntry(imageEntry)
|
||||||
|
|
||||||
|
FileInputStream(imageFile).use { imageInput ->
|
||||||
|
imageInput.copyTo(zipOut)
|
||||||
|
}
|
||||||
|
zipOut.closeEntry()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error but continue with other images
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: throw IOException("Could not open output stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class to hold extraction results
|
||||||
|
*/
|
||||||
|
data class ImportResult(
|
||||||
|
val jsonContent: String,
|
||||||
|
val importedImagePaths: Map<String, String> // original filename -> new relative path
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a ZIP file and returns the JSON content and imported image paths
|
||||||
|
* @param context Android context
|
||||||
|
* @param zipFile The ZIP file to extract
|
||||||
|
* @return ImportResult containing the JSON and image path mappings
|
||||||
|
*/
|
||||||
|
fun extractImportZip(context: Context, zipFile: File): ImportResult {
|
||||||
|
var jsonContent = ""
|
||||||
|
val importedImagePaths = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
ZipInputStream(FileInputStream(zipFile)).use { zipIn ->
|
||||||
|
var entry = zipIn.nextEntry
|
||||||
|
|
||||||
|
while (entry != null) {
|
||||||
|
when {
|
||||||
|
entry.name == DATA_JSON_FILENAME -> {
|
||||||
|
// Read JSON data
|
||||||
|
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
|
||||||
|
// Extract image file
|
||||||
|
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
|
||||||
|
|
||||||
|
// Create temporary file to hold the extracted image
|
||||||
|
val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir)
|
||||||
|
|
||||||
|
FileOutputStream(tempFile).use { output ->
|
||||||
|
zipIn.copyTo(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the image to permanent storage
|
||||||
|
val newPath = ImageUtils.importImageFile(context, tempFile)
|
||||||
|
if (newPath != null) {
|
||||||
|
importedImagePaths[originalFilename] = newPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zipIn.closeEntry()
|
||||||
|
entry = zipIn.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonContent.isEmpty()) {
|
||||||
|
throw IOException("No data.json file found in the ZIP archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportResult(jsonContent, importedImagePaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to determine if a file is a ZIP file based on extension
|
||||||
|
*/
|
||||||
|
fun isZipFile(filename: String): Boolean {
|
||||||
|
return filename.lowercase().endsWith(".zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates image paths in a problem list after import
|
||||||
|
* This function maps the old image paths to the new ones after import
|
||||||
|
*/
|
||||||
|
fun updateProblemImagePaths(
|
||||||
|
problems: List<com.atridad.openclimb.data.model.Problem>,
|
||||||
|
imagePathMapping: Map<String, String>
|
||||||
|
): List<com.atridad.openclimb.data.model.Problem> {
|
||||||
|
return problems.map { problem ->
|
||||||
|
val updatedImagePaths = problem.imagePaths.mapNotNull { oldPath ->
|
||||||
|
// Extract filename from the old path
|
||||||
|
val filename = oldPath.substringAfterLast("/")
|
||||||
|
imagePathMapping[filename]
|
||||||
|
}
|
||||||
|
problem.copy(imagePaths = updatedImagePaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">OpenClimb</string>
|
||||||
|
</resources>
|
||||||
5
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
5
app/src/main/res/xml/file_provider_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="share_images" path="shares/" />
|
||||||
|
<external-files-path name="external_files" path="." />
|
||||||
|
</paths>
|
||||||
17
app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package com.atridad.openclimb
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
build.gradle.kts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
663
build/reports/problems/problems-report.html
Normal file
23
gradle.properties
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
64
gradle/libs.versions.toml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.9.1"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.15.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.3.0"
|
||||||
|
espressoCore = "3.7.0"
|
||||||
|
lifecycleRuntimeKtx = "2.9.2"
|
||||||
|
activityCompose = "1.10.1"
|
||||||
|
composeBom = "2024.09.00"
|
||||||
|
room = "2.6.1"
|
||||||
|
navigation = "2.8.4"
|
||||||
|
viewmodel = "2.9.2"
|
||||||
|
kotlinxSerialization = "1.7.1"
|
||||||
|
kotlinxCoroutines = "1.9.0"
|
||||||
|
coil = "2.7.0"
|
||||||
|
ksp = "2.0.21-1.0.25"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
|
||||||
|
# Room Database
|
||||||
|
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
||||||
|
|
||||||
|
# ViewModel
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewmodel" }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||||
|
|
||||||
|
# Coroutines
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
|
||||||
|
# Image Loading
|
||||||
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
|
|
||||||
|
# Charts - MPAndroidChart for now, will be replaced with Vico when stable
|
||||||
|
mpandroidchart = { group = "com.github.PhilJay", name = "MPAndroidChart", version = "v3.1.0" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#Fri Aug 15 11:23:25 MDT 2025
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
185
gradlew
vendored
Executable file
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2015 the original author or authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=`expr $i + 1`
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
0) set -- ;;
|
||||||
|
1) set -- "$args0" ;;
|
||||||
|
2) set -- "$args0" "$args1" ;;
|
||||||
|
3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=`save "$@"`
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||