Compare commits

...

31 Commits

Author SHA1 Message Date
036becb5be 1.6.0 for Android and 1.1.0 for iOS - Finalizing Export and Import
formats :)
2025-09-28 02:37:03 -06:00
dcc3f9cc9d Fixed versionCode for android 2025-09-27 20:00:30 -06:00
2a48908dd2 New builds for iOS 1.0.3 and Android 1.5.1 2025-09-27 18:47:23 -06:00
5d1748765f New builds for iOS 1.0.3 and Android 1.5.1 2025-09-27 18:47:09 -06:00
298ba6149b 1.03 for iOS and 1.5.0 for Android 2025-09-27 02:13:51 -06:00
416b68e96a [Android] 1.5.0 2025-09-24 17:15:53 -06:00
f68963afbc oops 2025-09-23 22:20:11 -06:00
f1bc61d202 1.0.2 - Widget and Photos fixes 2025-09-23 21:57:45 -06:00
57b16c89ad 1.0.1 (6) 2025-09-20 15:08:59 -06:00
44b9b7bb9e Release README 2025-09-20 14:33:16 -06:00
7839d52001 ??? 2025-09-20 14:32:12 -06:00
fff8123978 1.0.1 (5) 2025-09-20 14:32:04 -06:00
6172074509 ??? 2025-09-20 12:03:46 -06:00
0235b5d506 iOS Release - 1.0.1 2025-09-20 12:03:37 -06:00
7c18b56674 ??? 2025-09-16 09:57:25 -06:00
cccdc2dd66 Build 3 - Remove debug settings from dev 2025-09-16 09:57:04 -06:00
62703cf2eb ??? 2025-09-16 00:36:43 -06:00
2c0ae23417 Remove iPad compat 2025-09-16 00:36:36 -06:00
87dcd08189 ??? 2025-09-15 23:34:42 -06:00
f3dabbd3aa Fixed swipe actions and more widgets 2025-09-15 23:34:33 -06:00
e4c6440758 Fixed Widget stats 2025-09-15 23:27:21 -06:00
b478f05260 ??? 2025-09-15 21:01:18 -06:00
afd954785a Proper 1.0 release for iOS. Pending App Store submission. 2025-09-15 21:01:02 -06:00
d95c45abbb Update README.md 2025-09-15 05:11:20 +00:00
9df0b29ada Merge pull request 'monorepo' (#4) from monorepo into main
Reviewed-on: atridad/OpenClimb#4
2025-09-15 05:09:46 +00:00
ff9f0d6cc6 1.0.0 for iOS is ready to ship 2025-09-14 23:07:32 -06:00
61384623bd Import/export fixes, icon, and graphing 2025-09-13 00:42:15 -06:00
7da1893748 1.5.0 Initial run as iOS in a monorepo 2025-09-12 22:35:14 -06:00
f106244e57 oooops 2025-09-09 12:59:59 -06:00
76a9120184 oops 2025-09-09 12:58:26 -06:00
abeed46c90 1.4.2 - Dropped minSDK down to support Android 12 2025-09-09 12:57:02 -06:00
172 changed files with 17980 additions and 5600 deletions

3
.idea/.gitignore generated vendored
View File

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

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

View File

@@ -1,860 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceStreaming">
<option name="deviceSelectionList">
<list>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="Sony" />
<option name="codename" value="A402SO" />
<option name="id" value="A402SO" />
<option name="labId" value="google" />
<option name="manufacturer" value="Sony" />
<option name="name" value="Xperia 10" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2520" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="27" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="F01L" />
<option name="id" value="F01L" />
<option name="labId" value="google" />
<option name="manufacturer" value="FUJITSU" />
<option name="name" value="F-01L" />
<option name="screenDensity" value="360" />
<option name="screenX" value="720" />
<option name="screenY" value="1280" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="OnePlus" />
<option name="codename" value="OP535DL1" />
<option name="id" value="OP535DL1" />
<option name="labId" value="google" />
<option name="manufacturer" value="OnePlus" />
<option name="name" value="CPH2409" />
<option name="screenDensity" value="401" />
<option name="screenX" value="1080" />
<option name="screenY" value="2412" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="OnePlus" />
<option name="codename" value="OP5552L1" />
<option name="id" value="OP5552L1" />
<option name="labId" value="google" />
<option name="manufacturer" value="OnePlus" />
<option name="name" value="CPH2415" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2412" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<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="36" />
<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>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-09-07T04:49:14.182787Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

19
.idea/gradle.xml generated
View File

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

View File

@@ -1,50 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>

6
.idea/kotlinc.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.21" />
</component>
</project>

10
.idea/migrations.xml generated
View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

4
.idea/misc.xml generated
View File

@@ -1,4 +0,0 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" />
</project>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/vcs.xml generated
View File

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

View File

@@ -1,18 +1,27 @@
# OpenClimb
This is a FOSS Android app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support.
This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS.
## Versions
- Android:1.4.2
- iOS: 1.0.1
## Download
You have two options:
For Android do one of the following:
1. Download the latest APK from the Releases page
2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
For iOS:
Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
## Requirements
- Android 15+
- Android 12+ or iOS 17+
## Contribution
As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out.
As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out.

View File

@@ -14,10 +14,10 @@ android {
defaultConfig {
applicationId = "com.atridad.openclimb"
minSdk = 34
minSdk = 31
targetSdk = 36
versionCode = 22
versionName = "1.4.1"
versionCode = 27
versionName = "1.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -26,8 +26,8 @@ android {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
@@ -35,60 +35,49 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
buildFeatures {
compose = true
}
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
buildFeatures { compose = true }
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
dependencies {
// Core Android libraries
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
// Compose BOM and UI
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
// Room Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// Navigation
implementation(libs.androidx.navigation.compose)
// ViewModel
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Serialization
implementation(libs.kotlinx.serialization.json)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// Image Loading
implementation(libs.coil.compose)
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
@@ -103,4 +92,4 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
}

View File

@@ -7,62 +7,58 @@ 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 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")
@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 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)
@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 || '%'
@Query(
"""
SELECT * FROM problems
WHERE (name LIKE '%' || :searchQuery || '%'
OR description LIKE '%' || :searchQuery || '%'
OR location LIKE '%' || :searchQuery || '%'
OR setter LIKE '%' || :searchQuery || '%')
OR location LIKE '%' || :searchQuery || '%')
ORDER BY updatedAt DESC
""")
"""
)
fun searchProblems(searchQuery: String): Flow<List<Problem>>
@Query("SELECT COUNT(*) FROM problems")
suspend fun getProblemsCount(): Int
@Query("DELETE FROM problems")
suspend fun deleteAllProblems()
@Query("SELECT COUNT(*) FROM problems") suspend fun getProblemsCount(): Int
@Query("DELETE FROM problems") suspend fun deleteAllProblems()
}

View File

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

View File

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

View File

@@ -0,0 +1,289 @@
package com.atridad.openclimb.data.repository
import android.content.Context
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym
import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.utils.ZipExportImportUtils
import java.io.File
import java.time.LocalDateTime
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
private val gymDao = database.gymDao()
private val problemDao = database.problemDao()
private val sessionDao = database.climbSessionDao()
private val attemptDao = database.attemptDao()
private val 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)
// Problem operations
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem)
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
// Session operations
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
suspend fun getLastUsedGym(): Gym? {
val recentSessions = sessionDao.getRecentSessions(1).first()
return if (recentSessions.isNotEmpty()) {
getGymById(recentSessions.first().gymId)
} else {
null
}
}
// Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
try {
// Collect all data
val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first()
// Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
// Create backup data using platform-neutral format
val backupData =
ClimbDataBackup(
exportedAt = LocalDateTime.now().toString(),
version = "2.0",
formatVersion = "2.0",
gyms = allGyms.map { BackupGym.fromGym(it) },
problems = allProblems.map { BackupProblem.fromProblem(it) },
sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
)
// Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths =
referencedImagePaths
.filter { imagePath ->
try {
val imageFile =
com.atridad.openclimb.utils.ImageUtils.getImageFile(
context,
imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (_: Exception) {
false
}
}
.toSet()
ZipExportImportUtils.createExportZipToUri(
context = context,
uri = uri,
exportData = backupData,
referencedImagePaths = validImagePaths
)
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
}
}
suspend fun importDataFromZip(file: File) {
try {
// Validate the ZIP file
if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist")
}
// Extract and validate the ZIP contents
val importResult = ZipExportImportUtils.extractImportZip(context, file)
// Validate JSON content
if (importResult.jsonContent.isBlank()) {
throw Exception("Invalid ZIP file: no data.json found or empty content")
}
// Parse and validate the data structure
val importData =
try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
} catch (e: Exception) {
throw Exception("Invalid data format: ${e.message}")
}
// Validate data integrity
validateImportData(importData)
// Clear existing data to avoid conflicts
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
// Import gyms first (problems depend on gyms)
importData.gyms.forEach { backupGym ->
try {
gymDao.insertGym(backupGym.toGym())
} catch (e: Exception) {
throw Exception("Failed to import gym '${backupGym.name}': ${e.message}")
}
}
// Import problems with updated image paths
val updatedBackupProblems =
ZipExportImportUtils.updateProblemImagePaths(
importData.problems,
importResult.importedImagePaths
)
// Import problems (depends on gyms)
updatedBackupProblems.forEach { backupProblem ->
try {
problemDao.insertProblem(backupProblem.toProblem())
} catch (e: Exception) {
throw Exception(
"Failed to import problem '${backupProblem.name}': ${e.message}"
)
}
}
// Import sessions
importData.sessions.forEach { backupSession ->
try {
sessionDao.insertSession(backupSession.toClimbSession())
} catch (e: Exception) {
throw Exception("Failed to import session '${backupSession.id}': ${e.message}")
}
}
// Import attempts last (depends on problems and sessions)
importData.attempts.forEach { backupAttempt ->
try {
attemptDao.insertAttempt(backupAttempt.toAttempt())
} catch (e: Exception) {
throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}")
}
}
} catch (e: Exception) {
throw Exception("Import failed: ${e.message}")
}
}
private fun validateDataIntegrity(
gyms: List<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
) {
// Validate that all problems reference valid gyms
val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
)
}
// Validate that all sessions reference valid gyms
val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
)
}
// Validate that all attempts reference valid problems and sessions
val problemIds = problems.map { it.id }.toSet()
val sessionIds = sessions.map { it.id }.toSet()
val invalidAttempts =
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
if (invalidAttempts.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
)
}
}
private fun validateImportData(importData: ClimbDataBackup) {
if (importData.gyms.isEmpty()) {
throw Exception("Import data is invalid: no gyms found")
}
if (importData.version.isBlank()) {
throw Exception("Import data is invalid: no version information")
}
// Check for reasonable data sizes to prevent malicious imports
if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 ||
importData.sessions.size > 10000 ||
importData.attempts.size > 100000
) {
throw Exception("Import data is too large: possible corruption or malicious file")
}
}
suspend fun resetAllData() {
try {
// Clear all data from database
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
// Clear all images from storage
clearAllImages()
} catch (e: Exception) {
throw Exception("Reset failed: ${e.message}")
}
}
private fun clearAllImages() {
try {
// Get the images directory
val imagesDir = File(context.filesDir, "images")
if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0
imagesDir.deleteRecursively()
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files")
}
} catch (e: Exception) {
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}")
}
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -324,12 +324,17 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
_uiState.value =
_uiState.value.copy(
isLoading = true,
message = "Creating ZIP file with images..."
)
repository.exportAllDataToZipUri(context, uri)
_uiState.value =
_uiState.value.copy(
isLoading = false,
message = "Data with images exported successfully"
message =
"Export complete! Your climbing data and images have been saved."
)
} catch (e: Exception) {
_uiState.value =

View File

@@ -0,0 +1,546 @@
package com.atridad.openclimb.utils
import android.content.Context
import android.content.Intent
import android.graphics.*
import android.graphics.drawable.GradientDrawable
import androidx.core.content.FileProvider
import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt
import com.atridad.openclimb.data.model.*
import java.io.File
import java.io.FileOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
object SessionShareUtils {
data class SessionStats(
val totalAttempts: Int,
val successfulAttempts: Int,
val problems: List<Problem>,
val uniqueProblemsAttempted: Int,
val uniqueProblemsCompleted: Int,
val averageGrade: String?,
val sessionDuration: String,
val topResult: AttemptResult?,
val topGrade: String?
)
fun calculateSessionStats(
session: ClimbSession,
attempts: List<Attempt>,
problems: List<Problem>
): SessionStats {
val successfulResults = listOf(AttemptResult.SUCCESS, AttemptResult.FLASH)
val successfulAttempts = attempts.filter { it.result in successfulResults }
val uniqueProblems = attempts.map { it.problemId }.distinct()
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in uniqueProblems }
// Calculate separate averages for different climbing types and difficulty systems
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder")
val ropeAverage = calculateAverageGrade(ropeProblems, "Rope")
// Combine averages for display
val averageGrade =
when {
boulderAverage != null && ropeAverage != null ->
"$boulderAverage / $ropeAverage"
boulderAverage != null -> boulderAverage
ropeAverage != null -> ropeAverage
else -> null
}
// Determine highest achieved grade (only from completed problems: SUCCESS or FLASH)
val completedProblems = problems.filter { it.id in uniqueCompletedProblems }
val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER }
val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE }
val topBoulder = highestGradeForProblems(completedBoulder)
val topRope = highestGradeForProblems(completedRope)
val topGrade =
when {
topBoulder != null && topRope != null -> "$topBoulder / $topRope"
topBoulder != null -> topBoulder
topRope != null -> topRope
else -> null
}
val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
val topResult =
attempts
.maxByOrNull {
when (it.result) {
AttemptResult.FLASH -> 3
AttemptResult.SUCCESS -> 2
AttemptResult.FALL -> 1
else -> 0
}
}
?.result
return SessionStats(
totalAttempts = attempts.size,
successfulAttempts = successfulAttempts.size,
problems = attemptedProblems,
uniqueProblemsAttempted = uniqueProblems.size,
uniqueProblemsCompleted = uniqueCompletedProblems.size,
averageGrade = averageGrade,
sessionDuration = duration,
topResult = topResult,
topGrade = topGrade
)
}
/**
* Calculate average grade for a specific set of problems, respecting their difficulty systems
*/
private fun calculateAverageGrade(problems: List<Problem>, climbingType: String): String? {
if (problems.isEmpty()) return null
// Group problems by difficulty system
val problemsBySystem = problems.groupBy { it.difficulty.system }
val averages = mutableListOf<String>()
problemsBySystem.forEach { (system, systemProblems) ->
when (system) {
DifficultySystem.V_SCALE -> {
val gradeValues =
systemProblems.mapNotNull { problem ->
when {
problem.difficulty.grade == "VB" -> 0
else -> problem.difficulty.grade.removePrefix("V").toIntOrNull()
}
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt()
averages.add(if (avg == 0) "VB" else "V$avg")
}
}
DifficultySystem.FONT -> {
val gradeValues =
systemProblems.mapNotNull { problem ->
// Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" ->
// 7)
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt()
averages.add("$avg")
}
}
DifficultySystem.YDS -> {
val gradeValues =
systemProblems.mapNotNull { problem ->
// Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10)
val grade = problem.difficulty.grade
if (grade.startsWith("5.")) {
grade.substring(2).toDoubleOrNull()
} else null
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average()
averages.add("5.${String.format("%.1f", avg)}")
}
}
DifficultySystem.CUSTOM -> {
// For custom systems, try to extract numeric values
val gradeValues =
systemProblems.mapNotNull { problem ->
problem.difficulty
.grade
.filter { it.isDigit() || it == '.' || it == '-' }
.toDoubleOrNull()
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average()
averages.add(String.format("%.1f", avg))
}
}
}
}
return if (averages.isNotEmpty()) {
if (averages.size == 1) {
averages.first()
} else {
averages.joinToString(" / ")
}
} else null
}
fun generateShareCard(
context: Context,
session: ClimbSession,
gym: Gym,
stats: SessionStats
): File? {
return try {
val width = 1242 // 3:4 aspect at higher resolution for better fit
val height = 1656
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val gradientDrawable =
GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf("#667eea".toColorInt(), "#764ba2".toColorInt())
)
gradientDrawable.setBounds(0, 0, width, height)
gradientDrawable.draw(canvas)
// Setup paint objects
val titlePaint =
Paint().apply {
color = Color.WHITE
textSize = 72f
typeface = Typeface.DEFAULT_BOLD
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val subtitlePaint =
Paint().apply {
color = "#E8E8E8".toColorInt()
textSize = 48f
typeface = Typeface.DEFAULT
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val statLabelPaint =
Paint().apply {
color = "#B8B8B8".toColorInt()
textSize = 36f
typeface = Typeface.DEFAULT
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val statValuePaint =
Paint().apply {
color = Color.WHITE
textSize = 64f
typeface = Typeface.DEFAULT_BOLD
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
val cardPaint =
Paint().apply {
color = "#40FFFFFF".toColorInt()
isAntiAlias = true
}
// Draw main card background
val cardRect = RectF(60f, 200f, width - 60f, height - 120f)
canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
// Draw content
var yPosition = 300f
// Title
canvas.drawText("Climbing Session", width / 2f, yPosition, titlePaint)
yPosition += 80f
// Gym and date
canvas.drawText(gym.name, width / 2f, yPosition, subtitlePaint)
yPosition += 60f
val dateText = formatSessionDate(session.date)
canvas.drawText(dateText, width / 2f, yPosition, subtitlePaint)
yPosition += 120f
// Stats grid
val statsStartY = yPosition
val columnWidth = width / 2f
val columnMaxTextWidth = columnWidth - 120f
// Left column stats
var leftY = statsStartY
drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Attempts",
stats.totalAttempts.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
leftY += 120f
drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Problems",
stats.uniqueProblemsAttempted.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
leftY += 120f
drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Duration",
stats.sessionDuration,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
// Right column stats
var rightY = statsStartY
drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rightY,
"Completed",
stats.uniqueProblemsCompleted.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
rightY += 120f
var rightYAfter = rightY
stats.topGrade?.let { grade ->
drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rightY,
"Top Grade",
grade,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
rightYAfter += 120f
}
// Grade range(s)
val boulderRange =
gradeRangeForProblems(
stats.problems.filter { it.climbType == ClimbType.BOULDER }
)
val ropeRange =
gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE })
val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f
if (boulderRange != null && ropeRange != null) {
// Two evenly spaced items
drawStatItemFitting(
canvas,
columnWidth / 2f,
rangesY,
"Boulder Range",
boulderRange,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rangesY,
"Rope Range",
ropeRange,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
} else if (boulderRange != null || ropeRange != null) {
// Single centered item
val singleRange = boulderRange ?: ropeRange ?: ""
drawStatItemFitting(
canvas,
width / 2f,
rangesY,
"Grade Range",
singleRange,
statLabelPaint,
statValuePaint,
width - 200f
)
}
// App branding
val brandingPaint =
Paint().apply {
color = "#80FFFFFF".toColorInt()
textSize = 32f
typeface = Typeface.DEFAULT
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
canvas.drawText("OpenClimb", width / 2f, height - 40f, brandingPaint)
// Save to file
val shareDir = File(context.cacheDir, "shares")
if (!shareDir.exists()) {
shareDir.mkdirs()
}
val filename = "session_${session.id}_${System.currentTimeMillis()}.png"
val file = File(shareDir, filename)
val outputStream = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
outputStream.flush()
outputStream.close()
bitmap.recycle()
file
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun drawStatItem(
canvas: Canvas,
x: Float,
y: Float,
label: String,
value: String,
labelPaint: Paint,
valuePaint: Paint
) {
canvas.drawText(value, x, y, valuePaint)
canvas.drawText(label, x, y + 50f, labelPaint)
}
/**
* Draws a stat item while fitting the value text to a max width by reducing text size if
* needed.
*/
private fun drawStatItemFitting(
canvas: Canvas,
x: Float,
y: Float,
label: String,
value: String,
labelPaint: Paint,
valuePaint: Paint,
maxTextWidth: Float
) {
val tempPaint = Paint(valuePaint)
var textSize = tempPaint.textSize
var textWidth = tempPaint.measureText(value)
while (textWidth > maxTextWidth && textSize > 36f) {
textSize -= 2f
tempPaint.textSize = textSize
textWidth = tempPaint.measureText(value)
}
canvas.drawText(value, x, y, tempPaint)
canvas.drawText(label, x, y + 50f, labelPaint)
}
/**
* Returns a range string like "X - Y" for the given problems, based on their difficulty grades.
*/
private fun gradeRangeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
val grades = problems.map { it.difficulty }
val sorted = grades.sortedWith { a, b -> a.compareTo(b) }
return "${sorted.first().grade} - ${sorted.last().grade}"
}
private fun formatSessionDate(dateString: String): String {
return try {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val date = LocalDateTime.parse(dateString, formatter)
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
date.format(displayFormatter)
} catch (_: Exception) {
dateString.take(10)
}
}
fun shareSessionCard(context: Context, imageFile: File) {
try {
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
imageFile
)
val shareIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(
Intent.EXTRA_TEXT,
"Check out my climbing session! 🧗‍♀️ #OpenClimb"
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(shareIntent, "Share Session")
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* Returns the highest grade string among the given problems, respecting their difficulty
* system.
*/
private fun highestGradeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
return problems
.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }
?.difficulty
?.grade
}
/** Produces a comparable numeric rank for grades across supported systems. */
private fun gradeRank(system: DifficultySystem, grade: String): Double {
return when (system) {
DifficultySystem.V_SCALE -> {
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
}
DifficultySystem.FONT -> {
val list = DifficultySystem.FONT.getAvailableGrades()
val idx = list.indexOf(grade.uppercase())
if (idx >= 0) idx.toDouble()
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
}
DifficultySystem.YDS -> {
// Parse 5.X with optional letter a-d
val s = grade.lowercase()
if (!s.startsWith("5.")) return -1.0
val tail = s.removePrefix("5.")
val numberPart = tail.takeWhile { it.isDigit() || it == '.' }
val letterPart = tail.drop(numberPart.length).firstOrNull()
val base = numberPart.toDoubleOrNull() ?: return -1.0
val letterWeight =
when (letterPart) {
'a' -> 0.0
'b' -> 0.1
'c' -> 0.2
'd' -> 0.3
else -> 0.0
}
base + letterWeight
}
DifficultySystem.CUSTOM -> {
grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() ?: -1.0
}
}
}
}

View File

@@ -1,7 +1,8 @@
package com.atridad.openclimb.utils
import android.content.Context
import kotlinx.serialization.json.Json
import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
@@ -10,13 +11,15 @@ import java.time.LocalDateTime
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object ZipExportImportUtils {
private const val DATA_JSON_FILENAME = "data.json"
private const val IMAGES_DIR_NAME = "images"
private const val METADATA_FILENAME = "metadata.txt"
/**
* Creates a ZIP file containing the JSON data and all referenced images
* @param context Android context
@@ -26,19 +29,26 @@ object ZipExportImportUtils {
* @return The created ZIP file
*/
fun createExportZip(
context: Context,
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
referencedImagePaths: Set<String>,
directory: File? = null
context: Context,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>,
directory: File? = null
): File {
val exportDir = directory ?: File(context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS), "OpenClimb")
val exportDir =
directory
?: File(
context.getExternalFilesDir(
android.os.Environment.DIRECTORY_DOCUMENTS
),
"OpenClimb"
)
if (!exportDir.exists()) {
exportDir.mkdirs()
}
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
val zipFile = File(exportDir, "openclimb_export_$timestamp.zip")
try {
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
// Add metadata file first
@@ -47,19 +57,19 @@ object ZipExportImportUtils {
zipOut.putNextEntry(metadataEntry)
zipOut.write(metadata.toByteArray())
zipOut.closeEntry()
// Add JSON data file
val json = Json {
prettyPrint = true
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
zipOut.putNextEntry(jsonEntry)
zipOut.write(jsonString.toByteArray())
zipOut.closeEntry()
// Add images with validation
var successfulImages = 0
referencedImagePaths.forEach { imagePath ->
@@ -68,31 +78,39 @@ object ZipExportImportUtils {
if (imageFile.exists() && imageFile.length() > 0) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry)
FileInputStream(imageFile).use { imageInput ->
imageInput.copyTo(zipOut)
}
zipOut.closeEntry()
successfulImages++
} else {
android.util.Log.w("ZipExportImportUtils", "Image file not found or empty: $imagePath")
android.util.Log.w(
"ZipExportImportUtils",
"Image file not found or empty: $imagePath"
)
}
} catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}")
android.util.Log.e(
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}"
)
}
}
// Log export summary
android.util.Log.i("ZipExportImportUtils", "Export completed: ${successfulImages}/${referencedImagePaths.size} images included")
android.util.Log.i(
"ZipExportImportUtils",
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
)
}
// Validate the created ZIP file
if (!zipFile.exists() || zipFile.length() == 0L) {
throw IOException("Failed to create ZIP file: file is empty or doesn't exist")
}
return zipFile
} catch (e: Exception) {
// Clean up failed export
if (zipFile.exists()) {
@@ -101,7 +119,7 @@ object ZipExportImportUtils {
throw IOException("Failed to create export ZIP: ${e.message}")
}
}
/**
* Creates a ZIP file and writes it to a provided URI
* @param context Android context
@@ -110,10 +128,10 @@ object ZipExportImportUtils {
* @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: Context,
uri: android.net.Uri,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
@@ -124,19 +142,19 @@ object ZipExportImportUtils {
zipOut.putNextEntry(metadataEntry)
zipOut.write(metadata.toByteArray())
zipOut.closeEntry()
// Add JSON data file
val json = Json {
prettyPrint = true
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
zipOut.putNextEntry(jsonEntry)
zipOut.write(jsonString.toByteArray())
zipOut.closeEntry()
// Add images with validation
var successfulImages = 0
referencedImagePaths.forEach { imagePath ->
@@ -145,7 +163,7 @@ object ZipExportImportUtils {
if (imageFile.exists() && imageFile.length() > 0) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry)
FileInputStream(imageFile).use { imageInput ->
imageInput.copyTo(zipOut)
}
@@ -153,22 +171,28 @@ object ZipExportImportUtils {
successfulImages++
}
} catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}")
android.util.Log.e(
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}"
)
}
}
android.util.Log.i("ZipExportImportUtils", "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included")
android.util.Log.i(
"ZipExportImportUtils",
"Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included"
)
}
} ?: throw IOException("Could not open output stream")
}
?: throw IOException("Could not open output stream")
} catch (e: Exception) {
throw IOException("Failed to create export ZIP to URI: ${e.message}")
}
}
private fun createMetadata(
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
referencedImagePaths: Set<String>
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
): String {
return buildString {
appendLine("OpenClimb Export Metadata")
@@ -183,15 +207,13 @@ object ZipExportImportUtils {
appendLine("Format: ZIP with embedded JSON data and images")
}
}
/**
* Data class to hold extraction results
*/
/** Data class to hold extraction results */
data class ImportResult(
val jsonContent: String,
val importedImagePaths: Map<String, String> // original filename -> new relative path
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
@@ -200,106 +222,125 @@ object ZipExportImportUtils {
*/
fun extractImportZip(context: Context, zipFile: File): ImportResult {
var jsonContent = ""
var metadataContent = ""
val importedImagePaths = mutableMapOf<String, String>()
var foundRequiredFiles = mutableSetOf<String>()
try {
ZipInputStream(FileInputStream(zipFile)).use { zipIn ->
var entry = zipIn.nextEntry
while (entry != null) {
when {
entry.name == METADATA_FILENAME -> {
// Read metadata for validation
metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("metadata")
android.util.Log.i("ZipExportImportUtils", "Found metadata: ${metadataContent.lines().take(3).joinToString()}")
android.util.Log.i(
"ZipExportImportUtils",
"Found metadata: ${metadataContent.lines().take(3).joinToString()}"
)
}
entry.name == DATA_JSON_FILENAME -> {
// Read JSON data
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("data")
}
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
// Extract image file
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
try {
// Create temporary file to hold the extracted image
val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir)
FileOutputStream(tempFile).use { output ->
zipIn.copyTo(output)
}
val tempFile =
File.createTempFile(
"import_image_",
"_$originalFilename",
context.cacheDir
)
FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) }
// Validate the extracted image
if (tempFile.exists() && tempFile.length() > 0) {
// Import the image to permanent storage
val newPath = ImageUtils.importImageFile(context, tempFile)
if (newPath != null) {
importedImagePaths[originalFilename] = newPath
android.util.Log.d("ZipExportImportUtils", "Successfully imported image: $originalFilename -> $newPath")
android.util.Log.d(
"ZipExportImportUtils",
"Successfully imported image: $originalFilename -> $newPath"
)
} else {
android.util.Log.w("ZipExportImportUtils", "Failed to import image: $originalFilename")
android.util.Log.w(
"ZipExportImportUtils",
"Failed to import image: $originalFilename"
)
}
} else {
android.util.Log.w("ZipExportImportUtils", "Extracted image is empty: $originalFilename")
android.util.Log.w(
"ZipExportImportUtils",
"Extracted image is empty: $originalFilename"
)
}
// Clean up temp file
tempFile.delete()
} catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to process image $originalFilename: ${e.message}")
android.util.Log.e(
"ZipExportImportUtils",
"Failed to process image $originalFilename: ${e.message}"
)
}
}
else -> {
android.util.Log.d("ZipExportImportUtils", "Skipping ZIP entry: ${entry.name}")
android.util.Log.d(
"ZipExportImportUtils",
"Skipping ZIP entry: ${entry.name}"
)
}
}
zipIn.closeEntry()
entry = zipIn.nextEntry
}
}
// Validate that we found the required files
if (!foundRequiredFiles.contains("data")) {
throw IOException("Invalid ZIP file: data.json not found")
}
if (jsonContent.isBlank()) {
throw IOException("Invalid ZIP file: data.json is empty")
}
android.util.Log.i("ZipExportImportUtils", "Import extraction completed: ${importedImagePaths.size} images processed")
android.util.Log.i(
"ZipExportImportUtils",
"Import extraction completed: ${importedImagePaths.size} images processed"
)
return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) {
throw IOException("Failed to extract import ZIP: ${e.message}")
}
}
/**
* Updates image paths in a problem list after import
* This function maps the old image paths to the new ones after import
* 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> {
problems: List<BackupProblem>,
imagePathMapping: Map<String, String>
): List<BackupProblem> {
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)
val updatedImagePaths =
(problem.imagePaths ?: emptyList()).mapNotNull { oldPath ->
// Extract filename from the old path
val filename = oldPath.substringAfterLast("/")
imagePathMapping[filename]
}
problem.withUpdatedImagePaths(updatedImagePaths)
}
}
}

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 982 B

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -1,6 +1,6 @@
[versions]
agp = "8.12.2"
kotlin = "2.2.10"
agp = "8.12.3"
kotlin = "2.2.20"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
@@ -9,12 +9,12 @@ androidxTestCore = "1.7.0"
androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.3"
activityCompose = "1.10.1"
composeBom = "2025.08.01"
room = "2.7.2"
navigation = "2.9.3"
viewmodel = "2.9.3"
lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2025.09.01"
room = "2.8.1"
navigation = "2.9.5"
viewmodel = "2.9.4"
kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2"
coil = "2.7.0"
@@ -39,6 +39,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Room Database
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
@@ -59,7 +60,7 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Testing
mockk = { group = "io.mockk", name = "mockk", version = "1.13.8" }
mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" }
# Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
@@ -72,4 +73,3 @@ 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" }

View File

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