Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f68963afbc
|
|||
|
f1bc61d202
|
|||
|
57b16c89ad
|
|||
|
44b9b7bb9e
|
|||
|
7839d52001
|
|||
|
fff8123978
|
|||
|
6172074509
|
|||
|
0235b5d506
|
|||
|
7c18b56674
|
|||
|
cccdc2dd66
|
|||
|
62703cf2eb
|
|||
|
2c0ae23417
|
|||
|
87dcd08189
|
|||
|
f3dabbd3aa
|
|||
|
e4c6440758
|
|||
|
b478f05260
|
|||
|
afd954785a
|
|||
| d95c45abbb | |||
| 9df0b29ada | |||
|
ff9f0d6cc6
|
|||
|
61384623bd
|
|||
|
7da1893748
|
|||
|
f106244e57
|
|||
|
76a9120184
|
|||
|
abeed46c90
|
|||
|
7770997fd4
|
|||
|
f45ff8963d
|
|||
|
5988cbf1fb
|
|||
| 13654cde70 | |||
| 9064dbe2ef | |||
|
0537da79e4
|
|||
|
4804049274
|
|||
|
8db6ed0e82
|
3
.gitignore
vendored
@@ -8,6 +8,7 @@ local.properties
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
@@ -32,4 +33,4 @@ render.experimental.xml
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
*.hprof
|
||||
|
||||
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
6
.idea/AndroidProjectSystem.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
848
.idea/caches/deviceStreaming.xml
generated
@@ -1,848 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceStreaming">
|
||||
<option name="deviceSelectionList">
|
||||
<list>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="Sony" />
|
||||
<option name="codename" value="A402SO" />
|
||||
<option name="id" value="A402SO" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Sony" />
|
||||
<option name="name" value="Xperia 10" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2520" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="27" />
|
||||
<option name="brand" value="DOCOMO" />
|
||||
<option name="codename" value="F01L" />
|
||||
<option name="id" value="F01L" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="FUJITSU" />
|
||||
<option name="name" value="F-01L" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1280" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="OnePlus" />
|
||||
<option name="codename" value="OP535DL1" />
|
||||
<option name="id" value="OP535DL1" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="OnePlus" />
|
||||
<option name="name" value="CPH2409" />
|
||||
<option name="screenDensity" value="401" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2412" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="OnePlus" />
|
||||
<option name="codename" value="OP5552L1" />
|
||||
<option name="id" value="OP5552L1" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="OnePlus" />
|
||||
<option name="name" value="CPH2415" />
|
||||
<option name="screenDensity" value="480" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2412" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="OPPO" />
|
||||
<option name="codename" value="OP573DL1" />
|
||||
<option name="id" value="OP573DL1" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="OPPO" />
|
||||
<option name="name" value="CPH2557" />
|
||||
<option name="screenDensity" value="480" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="28" />
|
||||
<option name="brand" value="DOCOMO" />
|
||||
<option name="codename" value="SH-01L" />
|
||||
<option name="id" value="SH-01L" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="SHARP" />
|
||||
<option name="name" value="AQUOS sense2 SH-01L" />
|
||||
<option name="screenDensity" value="480" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2160" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="a14m" />
|
||||
<option name="id" value="a14m" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-A145R" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2408" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="a15" />
|
||||
<option name="id" value="a15" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="A15" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="a15x" />
|
||||
<option name="id" value="a15x" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="A15 5G" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="a16x" />
|
||||
<option name="id" value="a16x" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="A16 5G" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="a35x" />
|
||||
<option name="id" value="a35x" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="A35" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="akita" />
|
||||
<option name="id" value="akita" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="akita" />
|
||||
<option name="id" value="akita" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="arcfox" />
|
||||
<option name="id" value="arcfox" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="razr plus 2024" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="1272" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="austin" />
|
||||
<option name="id" value="austin" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="moto g 5G (2022)" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1600" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="b0q" />
|
||||
<option name="id" value="b0q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S22 Ultra" />
|
||||
<option name="screenDensity" value="600" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="3088" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="b6q" />
|
||||
<option name="id" value="b6q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Flip 6" />
|
||||
<option name="screenDensity" value="340" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2640" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="32" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="bluejay" />
|
||||
<option name="id" value="bluejay" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 6a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="caiman" />
|
||||
<option name="id" value="caiman" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="960" />
|
||||
<option name="screenY" value="2142" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="caiman" />
|
||||
<option name="id" value="caiman" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="960" />
|
||||
<option name="screenY" value="2142" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="comet" />
|
||||
<option name="default" value="true" />
|
||||
<option name="id" value="comet" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro Fold" />
|
||||
<option name="screenDensity" value="390" />
|
||||
<option name="screenX" value="2076" />
|
||||
<option name="screenY" value="2152" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="comet" />
|
||||
<option name="default" value="true" />
|
||||
<option name="id" value="comet" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro Fold" />
|
||||
<option name="screenDensity" value="390" />
|
||||
<option name="screenX" value="2076" />
|
||||
<option name="screenY" value="2152" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="29" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="crownqlteue" />
|
||||
<option name="id" value="crownqlteue" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Note9" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="2220" />
|
||||
<option name="screenY" value="1080" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="dm2q" />
|
||||
<option name="id" value="dm2q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="S23 Plus" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="dm3q" />
|
||||
<option name="id" value="dm3q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S23 Ultra" />
|
||||
<option name="screenDensity" value="600" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="3088" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="dubai" />
|
||||
<option name="id" value="dubai" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="edge 30" />
|
||||
<option name="screenDensity" value="405" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="e1q" />
|
||||
<option name="default" value="true" />
|
||||
<option name="id" value="e1q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S24" />
|
||||
<option name="screenDensity" value="480" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="e3q" />
|
||||
<option name="id" value="e3q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S24 Ultra" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="3120" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="eos" />
|
||||
<option name="id" value="eos" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Eos" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="384" />
|
||||
<option name="screenY" value="384" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="eqe" />
|
||||
<option name="id" value="eqe" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="edge 50 pro" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1220" />
|
||||
<option name="screenY" value="2712" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="felix" />
|
||||
<option name="id" value="felix" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Fold" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="2208" />
|
||||
<option name="screenY" value="1840" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="felix" />
|
||||
<option name="id" value="felix" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Fold" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="2208" />
|
||||
<option name="screenY" value="1840" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="felix_camera" />
|
||||
<option name="id" value="felix_camera" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Fold (Camera-enabled)" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="2208" />
|
||||
<option name="screenY" value="1840" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="fogona" />
|
||||
<option name="id" value="fogona" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="moto g play - 2024" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1600" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="fogos" />
|
||||
<option name="id" value="fogos" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="moto g34 5G" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1600" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="g0q" />
|
||||
<option name="id" value="g0q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-S906U1" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gta9pwifi" />
|
||||
<option name="id" value="gta9pwifi" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-X210" />
|
||||
<option name="screenDensity" value="240" />
|
||||
<option name="screenX" value="1200" />
|
||||
<option name="screenY" value="1920" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts7lwifi" />
|
||||
<option name="id" value="gts7lwifi" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-T870" />
|
||||
<option name="screenDensity" value="340" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts7xllite" />
|
||||
<option name="id" value="gts7xllite" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-T738U" />
|
||||
<option name="screenDensity" value="340" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts8uwifi" />
|
||||
<option name="formFactor" value="Tablet" />
|
||||
<option name="id" value="gts8uwifi" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Tab S8 Ultra" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="1848" />
|
||||
<option name="screenY" value="2960" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts8wifi" />
|
||||
<option name="formFactor" value="Tablet" />
|
||||
<option name="id" value="gts8wifi" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Tab S8" />
|
||||
<option name="screenDensity" value="274" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts9fe" />
|
||||
<option name="id" value="gts9fe" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Tab S9 FE 5G" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="2304" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts9wifi" />
|
||||
<option name="id" value="gts9wifi" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-X710" />
|
||||
<option name="screenDensity" value="340" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="husky" />
|
||||
<option name="id" value="husky" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8 Pro" />
|
||||
<option name="screenDensity" value="390" />
|
||||
<option name="screenX" value="1008" />
|
||||
<option name="screenY" value="2244" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="30" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="java" />
|
||||
<option name="id" value="java" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="G20" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1600" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="komodo" />
|
||||
<option name="id" value="komodo" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro XL" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="1008" />
|
||||
<option name="screenY" value="2244" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="komodo" />
|
||||
<option name="id" value="komodo" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro XL" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="1008" />
|
||||
<option name="screenY" value="2244" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="lion" />
|
||||
<option name="id" value="lion" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="moto g04" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1612" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="lynx" />
|
||||
<option name="id" value="lynx" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 7a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="lyriq" />
|
||||
<option name="id" value="lyriq" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="edge 40" />
|
||||
<option name="screenDensity" value="400" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="manaus" />
|
||||
<option name="id" value="manaus" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="edge 40 neo" />
|
||||
<option name="screenDensity" value="400" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="maui" />
|
||||
<option name="id" value="maui" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="moto g play - 2023" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1600" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="o1q" />
|
||||
<option name="id" value="o1q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S21" />
|
||||
<option name="screenDensity" value="421" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="31" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="oriole" />
|
||||
<option name="id" value="oriole" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 6" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="pa3q" />
|
||||
<option name="id" value="pa3q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S25 Ultra" />
|
||||
<option name="screenDensity" value="600" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="3120" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="panther" />
|
||||
<option name="id" value="panther" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 7" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="q5q" />
|
||||
<option name="id" value="q5q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Z Fold5" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1812" />
|
||||
<option name="screenY" value="2176" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="q6q" />
|
||||
<option name="id" value="q6q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Z Fold6" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1856" />
|
||||
<option name="screenY" value="2160" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="30" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="r11" />
|
||||
<option name="formFactor" value="Wear OS" />
|
||||
<option name="id" value="r11" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Watch" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="384" />
|
||||
<option name="screenY" value="384" />
|
||||
<option name="type" value="WEAR_OS" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="r11q" />
|
||||
<option name="id" value="r11q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-S711U" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="30" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="redfin" />
|
||||
<option name="id" value="redfin" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 5" />
|
||||
<option name="screenDensity" value="440" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="shiba" />
|
||||
<option name="id" value="shiba" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="t2q" />
|
||||
<option name="id" value="t2q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S21 Plus" />
|
||||
<option name="screenDensity" value="394" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tangorpro" />
|
||||
<option name="formFactor" value="Tablet" />
|
||||
<option name="id" value="tangorpro" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Tablet" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tegu" />
|
||||
<option name="id" value="tegu" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2424" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tokay" />
|
||||
<option name="default" value="true" />
|
||||
<option name="id" value="tokay" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2424" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tokay" />
|
||||
<option name="default" value="true" />
|
||||
<option name="id" value="tokay" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2424" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="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>
|
||||
18
.idea/deploymentTargetSelector.xml
generated
@@ -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-08-27T22:55:36.064836Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Pixel_9a.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
19
.idea/gradle.xml
generated
@@ -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>
|
||||
50
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,50 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/kotlinc.xml
generated
@@ -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
@@ -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
@@ -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>
|
||||
17
.idea/runConfigurations.xml
generated
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
21
README.md
@@ -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 Released page
|
||||
2. Use <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D">Obtainium</a>
|
||||
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.
|
||||
|
||||
0
app/.gitignore → android/app/.gitignore
vendored
@@ -14,10 +14,10 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.atridad.openclimb"
|
||||
minSdk = 34
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 18
|
||||
versionName = "1.2.0"
|
||||
versionCode = 23
|
||||
versionName = "1.4.2"
|
||||
|
||||
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,48 @@ 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)
|
||||
|
||||
|
||||
// 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 +91,5 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
|
||||
<!-- Hardware features -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
|
||||
<!-- Permissions for notifications and foreground service -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -36,8 +36,10 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
|
||||
</activity>
|
||||
|
||||
|
||||
<!-- FileProvider for sharing images -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
@@ -48,7 +50,7 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
|
||||
|
||||
<!-- Session tracking service -->
|
||||
<service
|
||||
android:name=".service.SessionTrackingService"
|
||||
@@ -60,6 +62,18 @@
|
||||
android:name="android.app.foreground_service_type"
|
||||
android:value="specialUse" />
|
||||
</service>
|
||||
|
||||
<!-- Widget Provider -->
|
||||
<receiver
|
||||
android:name=".widget.ClimbStatsWidgetProvider"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_climb_stats_info" />
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.atridad.openclimb
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.atridad.openclimb.ui.OpenClimbApp
|
||||
import com.atridad.openclimb.ui.theme.OpenClimbTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var shortcutAction by mutableStateOf<String?>(null)
|
||||
private var lastUsedGymId by mutableStateOf<String?>(null)
|
||||
|
||||
fun clearShortcutAction() {
|
||||
shortcutAction = null
|
||||
lastUsedGymId = null
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setTheme(R.style.Theme_OpenClimb)
|
||||
enableEdgeToEdge()
|
||||
|
||||
shortcutAction = intent?.action
|
||||
lastUsedGymId = intent?.getStringExtra("LAST_USED_GYM_ID")
|
||||
|
||||
setContent {
|
||||
OpenClimbTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
OpenClimbApp(
|
||||
shortcutAction = shortcutAction,
|
||||
lastUsedGymId = lastUsedGymId,
|
||||
onShortcutActionProcessed = { clearShortcutAction() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
|
||||
shortcutAction = intent.action
|
||||
lastUsedGymId = intent.getStringExtra("LAST_USED_GYM_ID")
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,26 @@
|
||||
package com.atridad.openclimb.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.utils.ZipExportImportUtils
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class ClimbRepository(
|
||||
database: OpenClimbDatabase,
|
||||
private val context: Context
|
||||
) {
|
||||
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
|
||||
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)
|
||||
@@ -34,7 +28,7 @@ class ClimbRepository(
|
||||
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
|
||||
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
|
||||
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
|
||||
|
||||
|
||||
// Problem operations
|
||||
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
|
||||
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
|
||||
@@ -43,27 +37,36 @@ class ClimbRepository(
|
||||
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
|
||||
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
|
||||
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
|
||||
|
||||
|
||||
// Session operations
|
||||
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
|
||||
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
|
||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = sessionDao.getSessionsByGym(gymId)
|
||||
fun 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)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
// ZIP Export with images - Single format for reliability
|
||||
suspend fun exportAllDataToZip(directory: File? = null): File {
|
||||
try {
|
||||
@@ -72,47 +75,58 @@ class ClimbRepository(
|
||||
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)
|
||||
|
||||
val exportData = ClimbDataExport(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
version = "1.0",
|
||||
gyms = allGyms,
|
||||
problems = allProblems,
|
||||
sessions = allSessions,
|
||||
attempts = allAttempts
|
||||
)
|
||||
|
||||
|
||||
val exportData =
|
||||
ClimbDataExport(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
version = "1.0",
|
||||
gyms = allGyms,
|
||||
problems = allProblems,
|
||||
sessions = allSessions,
|
||||
attempts = allAttempts
|
||||
)
|
||||
|
||||
// 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 (e: Exception) {
|
||||
false
|
||||
}
|
||||
}.toSet()
|
||||
|
||||
val validImagePaths =
|
||||
referencedImagePaths
|
||||
.filter { imagePath ->
|
||||
try {
|
||||
val imageFile =
|
||||
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||
context,
|
||||
imagePath
|
||||
)
|
||||
imageFile.exists() && imageFile.length() > 0
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
.toSet()
|
||||
|
||||
// Log any missing images for debugging
|
||||
val missingImages = referencedImagePaths - validImagePaths
|
||||
if (missingImages.isNotEmpty()) {
|
||||
android.util.Log.w("ClimbRepository", "Some referenced images are missing: $missingImages")
|
||||
android.util.Log.w(
|
||||
"ClimbRepository",
|
||||
"Some referenced images are missing: $missingImages"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return ZipExportImportUtils.createExportZip(
|
||||
context = context,
|
||||
exportData = exportData,
|
||||
referencedImagePaths = validImagePaths,
|
||||
directory = directory
|
||||
context = context,
|
||||
exportData = exportData,
|
||||
referencedImagePaths = validImagePaths,
|
||||
directory = directory
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Export failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||
try {
|
||||
// Collect all data with proper error handling
|
||||
@@ -120,72 +134,81 @@ class ClimbRepository(
|
||||
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)
|
||||
|
||||
val exportData = ClimbDataExport(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
version = "1.0",
|
||||
gyms = allGyms,
|
||||
problems = allProblems,
|
||||
sessions = allSessions,
|
||||
attempts = allAttempts
|
||||
)
|
||||
|
||||
|
||||
val exportData =
|
||||
ClimbDataExport(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
version = "1.0",
|
||||
gyms = allGyms,
|
||||
problems = allProblems,
|
||||
sessions = allSessions,
|
||||
attempts = allAttempts
|
||||
)
|
||||
|
||||
// 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 (e: Exception) {
|
||||
false
|
||||
}
|
||||
}.toSet()
|
||||
|
||||
val validImagePaths =
|
||||
referencedImagePaths
|
||||
.filter { imagePath ->
|
||||
try {
|
||||
val imageFile =
|
||||
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||
context,
|
||||
imagePath
|
||||
)
|
||||
imageFile.exists() && imageFile.length() > 0
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
.toSet()
|
||||
|
||||
ZipExportImportUtils.createExportZipToUri(
|
||||
context = context,
|
||||
uri = uri,
|
||||
exportData = exportData,
|
||||
referencedImagePaths = validImagePaths
|
||||
context = context,
|
||||
uri = uri,
|
||||
exportData = exportData,
|
||||
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<ClimbDataExport>(importResult.jsonContent)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Invalid data format: ${e.message}")
|
||||
}
|
||||
|
||||
val importData =
|
||||
try {
|
||||
json.decodeFromString<ClimbDataExport>(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 { gym ->
|
||||
try {
|
||||
@@ -194,13 +217,14 @@ class ClimbRepository(
|
||||
throw Exception("Failed to import gym ${gym.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Import problems with updated image paths
|
||||
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths(
|
||||
importData.problems,
|
||||
importResult.importedImagePaths
|
||||
)
|
||||
|
||||
val updatedProblems =
|
||||
ZipExportImportUtils.updateProblemImagePaths(
|
||||
importData.problems,
|
||||
importResult.importedImagePaths
|
||||
)
|
||||
|
||||
updatedProblems.forEach { problem ->
|
||||
try {
|
||||
problemDao.insertProblem(problem)
|
||||
@@ -208,7 +232,7 @@ class ClimbRepository(
|
||||
throw Exception("Failed to import problem ${problem.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Import sessions
|
||||
importData.sessions.forEach { session ->
|
||||
try {
|
||||
@@ -217,7 +241,7 @@ class ClimbRepository(
|
||||
throw Exception("Failed to import session: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Import attempts last (depends on problems and sessions)
|
||||
importData.attempts.forEach { attempt ->
|
||||
try {
|
||||
@@ -226,61 +250,66 @@ class ClimbRepository(
|
||||
throw Exception("Failed to import attempt: ${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>
|
||||
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")
|
||||
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")
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
throw Exception(
|
||||
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun validateImportData(importData: ClimbDataExport) {
|
||||
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) {
|
||||
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
|
||||
@@ -288,15 +317,14 @@ class ClimbRepository(
|
||||
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
|
||||
@@ -314,10 +342,10 @@ class ClimbRepository(
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class ClimbDataExport(
|
||||
val exportedAt: String,
|
||||
val version: String = "1.0",
|
||||
val gyms: List<Gym>,
|
||||
val problems: List<Problem>,
|
||||
val sessions: List<ClimbSession>,
|
||||
val attempts: List<Attempt>
|
||||
)
|
||||
val exportedAt: String,
|
||||
val version: String = "1.0",
|
||||
val gyms: List<Gym>,
|
||||
val problems: List<Problem>,
|
||||
val sessions: List<ClimbSession>,
|
||||
val attempts: List<Attempt>
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.atridad.openclimb.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
data class BottomNavigationItem(val screen: Screen, val icon: ImageVector, val label: String)
|
||||
|
||||
val bottomNavigationItems =
|
||||
listOf(
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Sessions,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
label = "Sessions"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Problems,
|
||||
icon = Icons.Default.Star,
|
||||
label = "Problems"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Analytics,
|
||||
icon = Icons.Default.Info,
|
||||
label = "Analytics"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Gyms,
|
||||
icon = Icons.Default.LocationOn,
|
||||
label = "Gyms"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Settings,
|
||||
icon = Icons.Default.Settings,
|
||||
label = "Settings"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,415 @@
|
||||
package com.atridad.openclimb.ui
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.toRoute
|
||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||
import com.atridad.openclimb.navigation.Screen
|
||||
import com.atridad.openclimb.navigation.bottomNavigationItems
|
||||
import com.atridad.openclimb.ui.components.NotificationPermissionDialog
|
||||
import com.atridad.openclimb.ui.screens.*
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
|
||||
import com.atridad.openclimb.utils.AppShortcutManager
|
||||
import com.atridad.openclimb.utils.NotificationPermissionUtils
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OpenClimbApp(
|
||||
shortcutAction: String? = null,
|
||||
lastUsedGymId: String? = null,
|
||||
onShortcutActionProcessed: () -> Unit = {}
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
|
||||
var lastUsedGym by remember { mutableStateOf<com.atridad.openclimb.data.model.Gym?>(null) }
|
||||
|
||||
val database = remember { OpenClimbDatabase.getDatabase(context) }
|
||||
val repository = remember { ClimbRepository(database, context) }
|
||||
val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository))
|
||||
|
||||
// Notification permission state
|
||||
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
|
||||
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
|
||||
|
||||
// Permission launcher
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted: Boolean ->
|
||||
if (!isGranted) {
|
||||
showNotificationPermissionDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!hasCheckedNotificationPermission) {
|
||||
hasCheckedNotificationPermission = true
|
||||
|
||||
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
|
||||
!NotificationPermissionUtils.isNotificationPermissionGranted(context)
|
||||
) {
|
||||
showNotificationPermissionDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) }
|
||||
|
||||
val activeSession by viewModel.activeSession.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
|
||||
// Update last used gym when gyms change
|
||||
LaunchedEffect(gyms) {
|
||||
if (gyms.isNotEmpty() && lastUsedGym == null) {
|
||||
lastUsedGym = viewModel.getLastUsedGym()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(activeSession, gyms, lastUsedGym) {
|
||||
AppShortcutManager.updateShortcuts(
|
||||
context = context,
|
||||
hasActiveSession = activeSession != null,
|
||||
hasGyms = gyms.isNotEmpty(),
|
||||
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(shortcutAction) {
|
||||
when (shortcutAction) {
|
||||
AppShortcutManager.ACTION_START_SESSION -> {
|
||||
navController.navigate(Screen.Sessions) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
AppShortcutManager.ACTION_END_SESSION -> {
|
||||
navController.navigate(Screen.Sessions) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
|
||||
activeSession?.let { session -> viewModel.endSession(context, session.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process shortcut actions after data is loaded
|
||||
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
|
||||
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
|
||||
android.util.Log.d(
|
||||
"OpenClimbApp",
|
||||
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
|
||||
)
|
||||
|
||||
if (activeSession == null) {
|
||||
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
|
||||
!NotificationPermissionUtils.isNotificationPermissionGranted(
|
||||
context
|
||||
)
|
||||
) {
|
||||
android.util.Log.d("OpenClimbApp", "Showing notification permission dialog")
|
||||
showNotificationPermissionDialog = true
|
||||
} else {
|
||||
if (gyms.size == 1) {
|
||||
android.util.Log.d(
|
||||
"OpenClimbApp",
|
||||
"Starting session with single gym: ${gyms.first().name}"
|
||||
)
|
||||
viewModel.startSession(context, gyms.first().id)
|
||||
} else {
|
||||
// Try to get the last used gym from the intent or fallback to state
|
||||
val targetGym =
|
||||
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
|
||||
?: lastUsedGym
|
||||
|
||||
if (targetGym != null) {
|
||||
android.util.Log.d(
|
||||
"OpenClimbApp",
|
||||
"Starting session with target gym: ${targetGym.name}"
|
||||
)
|
||||
viewModel.startSession(context, targetGym.id)
|
||||
} else {
|
||||
android.util.Log.d(
|
||||
"OpenClimbApp",
|
||||
"No target gym found, navigating to selection"
|
||||
)
|
||||
navController.navigate(Screen.AddEditSession())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
android.util.Log.d(
|
||||
"OpenClimbApp",
|
||||
"Active session already exists: ${activeSession?.id}"
|
||||
)
|
||||
}
|
||||
|
||||
// Clear the shortcut action after processing to prevent repeated execution
|
||||
onShortcutActionProcessed()
|
||||
}
|
||||
}
|
||||
|
||||
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = { OpenClimbBottomNavigation(navController = navController) },
|
||||
floatingActionButton = {
|
||||
fabConfig?.let { config ->
|
||||
FloatingActionButton(
|
||||
onClick = config.onClick,
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Icon(
|
||||
imageVector = config.icon,
|
||||
contentDescription = config.contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Sessions,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable<Screen.Sessions> {
|
||||
LaunchedEffect(gyms, activeSession) {
|
||||
fabConfig =
|
||||
if (gyms.isNotEmpty() && activeSession == null) {
|
||||
FabConfig(
|
||||
icon = Icons.Default.PlayArrow,
|
||||
contentDescription = "Start Session",
|
||||
onClick = {
|
||||
if (NotificationPermissionUtils
|
||||
.shouldRequestNotificationPermission() &&
|
||||
!NotificationPermissionUtils
|
||||
.isNotificationPermissionGranted(
|
||||
context
|
||||
)
|
||||
) {
|
||||
showNotificationPermissionDialog = true
|
||||
} else {
|
||||
if (gyms.size == 1) {
|
||||
viewModel.startSession(context, gyms.first().id)
|
||||
} else {
|
||||
// Always show gym selection for FAB when
|
||||
// multiple gyms
|
||||
navController.navigate(Screen.AddEditSession())
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
SessionsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToSessionDetail = { sessionId ->
|
||||
navController.navigate(Screen.SessionDetail(sessionId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.Problems> {
|
||||
LaunchedEffect(gyms) {
|
||||
fabConfig =
|
||||
if (gyms.isNotEmpty()) {
|
||||
FabConfig(
|
||||
icon = Icons.Default.Add,
|
||||
contentDescription = "Add Problem",
|
||||
onClick = {
|
||||
navController.navigate(Screen.AddEditProblem())
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
ProblemsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.Analytics> {
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AnalyticsScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composable<Screen.Gyms> {
|
||||
LaunchedEffect(Unit) {
|
||||
fabConfig =
|
||||
FabConfig(
|
||||
icon = Icons.Default.Add,
|
||||
contentDescription = "Add Gym",
|
||||
onClick = { navController.navigate(Screen.AddEditGym()) }
|
||||
)
|
||||
}
|
||||
GymsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToGymDetail = { gymId ->
|
||||
navController.navigate(Screen.GymDetail(gymId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.Settings> {
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
SettingsScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
composable<Screen.SessionDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.SessionDetail>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
SessionDetailScreen(
|
||||
sessionId = args.sessionId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.ProblemDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
ProblemDetailScreen(
|
||||
problemId = args.problemId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { problemId ->
|
||||
navController.navigate(Screen.AddEditProblem(problemId = problemId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.GymDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.GymDetail>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
GymDetailScreen(
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { gymId ->
|
||||
navController.navigate(Screen.AddEditGym(gymId = gymId))
|
||||
},
|
||||
onNavigateToSessionDetail = { sessionId ->
|
||||
navController.navigate(Screen.SessionDetail(sessionId))
|
||||
},
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.AddEditGym> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditGym>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AddEditGymScreen(
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.AddEditProblem> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AddEditProblemScreen(
|
||||
problemId = args.problemId,
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.AddEditSession> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditSession>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AddEditSessionScreen(
|
||||
sessionId = args.sessionId,
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Notification permission dialog
|
||||
if (showNotificationPermissionDialog) {
|
||||
NotificationPermissionDialog(
|
||||
onDismiss = { showNotificationPermissionDialog = false },
|
||||
onRequestPermission = {
|
||||
permissionLauncher.launch(
|
||||
NotificationPermissionUtils.getNotificationPermissionString()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpenClimbBottomNavigation(navController: NavHostController) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
NavigationBar {
|
||||
bottomNavigationItems.forEach { item ->
|
||||
val isSelected =
|
||||
when (item.screen) {
|
||||
is Screen.Sessions -> currentRoute?.contains("Session") == true
|
||||
is Screen.Problems -> currentRoute?.contains("Problem") == true
|
||||
is Screen.Gyms -> currentRoute?.contains("Gym") == true
|
||||
is Screen.Analytics -> currentRoute?.contains("Analytics") == true
|
||||
is Screen.Settings -> currentRoute?.contains("Settings") == true
|
||||
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
|
||||
}
|
||||
|
||||
NavigationBarItem(
|
||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||
label = { Text(item.label) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
navController.navigate(item.screen) {
|
||||
// Clear the entire back stack and go to the selected tab's root screen
|
||||
popUpTo(0) { inclusive = true }
|
||||
// Avoid multiple copies of the same destination when
|
||||
// reselecting the same item
|
||||
launchSingleTop = true
|
||||
// Don't restore state - always start fresh when switching tabs
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FabConfig(
|
||||
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
val contentDescription: String,
|
||||
val onClick: () -> Unit
|
||||
)
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -31,12 +32,12 @@ data class ChartDataPoint(
|
||||
* Configuration for chart styling
|
||||
*/
|
||||
data class ChartStyle(
|
||||
val lineColor: Color = Color(0xFF6366F1),
|
||||
val fillColor: Color = Color(0x336366F1),
|
||||
val lineColor: Color,
|
||||
val fillColor: Color,
|
||||
val lineWidth: Float = 3f,
|
||||
val gridColor: Color = Color(0xFFE5E7EB),
|
||||
val textColor: Color = Color(0xFF374151),
|
||||
val backgroundColor: Color = Color.White
|
||||
val gridColor: Color,
|
||||
val textColor: Color,
|
||||
val backgroundColor: Color
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -46,7 +47,13 @@ data class ChartStyle(
|
||||
fun LineChart(
|
||||
data: List<ChartDataPoint>,
|
||||
modifier: Modifier = Modifier,
|
||||
style: ChartStyle = ChartStyle(),
|
||||
style: ChartStyle = ChartStyle(
|
||||
lineColor = MaterialTheme.colorScheme.primary,
|
||||
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
backgroundColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
showGrid: Boolean = true,
|
||||
xAxisFormatter: (Float) -> String = { it.toString() },
|
||||
yAxisFormatter: (Float) -> String = { it.toString() }
|
||||
@@ -136,18 +143,27 @@ fun LineChart(
|
||||
)
|
||||
}
|
||||
|
||||
// Draw data points
|
||||
// Draw data points - more pronounced
|
||||
screenPoints.forEach { point ->
|
||||
// Draw outer circle (larger)
|
||||
drawCircle(
|
||||
color = style.lineColor,
|
||||
radius = style.lineWidth * 1.5f,
|
||||
radius = 8f,
|
||||
center = point
|
||||
)
|
||||
// Draw inner circle (white center)
|
||||
drawCircle(
|
||||
color = style.backgroundColor,
|
||||
radius = style.lineWidth * 0.8f,
|
||||
radius = 5f,
|
||||
center = point
|
||||
)
|
||||
// Draw border for better visibility
|
||||
drawCircle(
|
||||
color = style.lineColor,
|
||||
radius = 8f,
|
||||
center = point,
|
||||
style = Stroke(width = 2f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,18 +206,7 @@ private fun DrawScope.drawGrid(
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
|
||||
// Draw label
|
||||
val text = xAxisFormatter(sessionNum.toFloat())
|
||||
val textSize = textMeasurer.measure(text, textStyle)
|
||||
drawText(
|
||||
textMeasurer = textMeasurer,
|
||||
text = text,
|
||||
style = textStyle,
|
||||
topLeft = Offset(
|
||||
x - textSize.size.width / 2f,
|
||||
padding + chartHeight + 8.dp.toPx()
|
||||
)
|
||||
)
|
||||
// X-axis labels removed per user request
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,11 +543,18 @@ fun AddEditProblemScreen(
|
||||
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
|
||||
OutlinedTextField(
|
||||
value = difficultyGrade,
|
||||
onValueChange = { difficultyGrade = it },
|
||||
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 custom grade") }
|
||||
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) }
|
||||
@@ -132,14 +132,12 @@ fun ProgressChartCard(
|
||||
progressData: List<ProgressDataPoint>,
|
||||
problems: List<com.atridad.openclimb.data.model.Problem>,
|
||||
) {
|
||||
// Find all grading systems that have been used
|
||||
val usedSystems = remember(problems) {
|
||||
problems.map { it.difficulty.system }.distinct().filter { system ->
|
||||
problems.any { it.difficulty.system == system }
|
||||
}
|
||||
// Find all grading systems that have been used in the progress data
|
||||
val usedSystems = remember(progressData) {
|
||||
progressData.map { it.difficultySystem }.distinct()
|
||||
}
|
||||
|
||||
var selectedSystem by remember {
|
||||
var selectedSystem by remember(usedSystems) {
|
||||
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
|
||||
}
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -456,7 +454,10 @@ fun gradeToNumeric(system: DifficultySystem, grade: String): Int {
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
DifficultySystem.CUSTOM -> 0
|
||||
DifficultySystem.CUSTOM -> {
|
||||
// Custom grades are numeric strings, so parse them directly
|
||||
grade.toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.atridad.openclimb.ui.screens
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -25,6 +26,7 @@ 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 androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -35,7 +37,6 @@ import com.atridad.openclimb.ui.theme.CustomIcons
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -291,8 +292,8 @@ fun SessionDetailScreen(
|
||||
|
||||
// Show stop icon for active sessions, delete icon for completed sessions
|
||||
if (session?.status == SessionStatus.ACTIVE) {
|
||||
IconButton(onClick = {
|
||||
session?.let { s ->
|
||||
IconButton(onClick = {
|
||||
session.let { s ->
|
||||
viewModel.endSession(context, s.id)
|
||||
onNavigateBack()
|
||||
}
|
||||
@@ -916,11 +917,6 @@ fun GymDetailScreen(
|
||||
problems.any { problem -> problem.id == attempt.problemId }
|
||||
}
|
||||
|
||||
val successfulAttempts =
|
||||
gymAttempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
|
||||
|
||||
|
||||
|
||||
val uniqueProblemsClimbed = gymAttempts.map { it.problemId }.toSet().size
|
||||
val totalSessions = sessions.size
|
||||
val activeSessions = sessions.count { it.status == SessionStatus.ACTIVE }
|
||||
@@ -1556,76 +1552,6 @@ private fun formatDate(dateString: String): String {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average grade for a specific set of problems, respecting their difficulty systems
|
||||
*/
|
||||
private fun calculateAverageGrade(problems: List<Problem>): 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
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EnhancedAddAttemptDialog(
|
||||
@@ -1835,8 +1761,12 @@ fun EnhancedAddAttemptDialog(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
TextButton(onClick = { showCreateProblem = false }) {
|
||||
Text("← Back", color = MaterialTheme.colorScheme.primary)
|
||||
IconButton(onClick = { showCreateProblem = false }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1930,9 +1860,14 @@ fun EnhancedAddAttemptDialog(
|
||||
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
|
||||
OutlinedTextField(
|
||||
value = newProblemGrade,
|
||||
onValueChange = { newProblemGrade = it },
|
||||
onValueChange = { newValue ->
|
||||
// Only allow integers for custom scales
|
||||
if (newValue.isEmpty() || newValue.all { it.isDigit() }) {
|
||||
newProblemGrade = newValue
|
||||
}
|
||||
},
|
||||
label = { Text("Grade *") },
|
||||
placeholder = { Text("Enter custom grade") },
|
||||
placeholder = { Text("Enter numeric grade (e.g. 5, 10, 15)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
colors =
|
||||
@@ -1943,6 +1878,7 @@ fun EnhancedAddAttemptDialog(
|
||||
MaterialTheme.colorScheme.outline
|
||||
),
|
||||
isError = newProblemGrade.isBlank(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
supportingText =
|
||||
if (newProblemGrade.isBlank()) {
|
||||
{
|
||||
@@ -1951,7 +1887,14 @@ fun EnhancedAddAttemptDialog(
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
} else null
|
||||
} else {
|
||||
{
|
||||
Text(
|
||||
"Custom grades must be whole numbers",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -0,0 +1,436 @@
|
||||
package com.atridad.openclimb.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||
import com.atridad.openclimb.service.SessionTrackingService
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
import com.atridad.openclimb.utils.SessionShareUtils
|
||||
import com.atridad.openclimb.widget.ClimbStatsWidgetProvider
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
|
||||
|
||||
// UI State flows
|
||||
private val _uiState = MutableStateFlow(ClimbUiState())
|
||||
val uiState: StateFlow<ClimbUiState> = _uiState.asStateFlow()
|
||||
|
||||
// Data flows
|
||||
val gyms =
|
||||
repository
|
||||
.getAllGyms()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val problems =
|
||||
repository
|
||||
.getAllProblems()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val sessions =
|
||||
repository
|
||||
.getAllSessions()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val activeSession =
|
||||
repository
|
||||
.getActiveSessionFlow()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
val attempts =
|
||||
repository
|
||||
.getAllAttempts()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
// Gym operations
|
||||
fun addGym(gym: Gym) {
|
||||
viewModelScope.launch { repository.insertGym(gym) }
|
||||
}
|
||||
|
||||
fun addGym(gym: Gym, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.insertGym(gym)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateGym(gym: Gym) {
|
||||
viewModelScope.launch { repository.updateGym(gym) }
|
||||
}
|
||||
|
||||
fun updateGym(gym: Gym, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.updateGym(gym)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteGym(gym: Gym) {
|
||||
viewModelScope.launch { repository.deleteGym(gym) }
|
||||
}
|
||||
|
||||
fun deleteGym(gym: Gym, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteGym(gym)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
|
||||
|
||||
// Problem operations
|
||||
fun addProblem(problem: Problem) {
|
||||
viewModelScope.launch { repository.insertProblem(problem) }
|
||||
}
|
||||
|
||||
fun addProblem(problem: Problem, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.insertProblem(problem)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProblem(problem: Problem) {
|
||||
viewModelScope.launch { repository.updateProblem(problem) }
|
||||
}
|
||||
|
||||
fun updateProblem(problem: Problem, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.updateProblem(problem)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteProblem(problem: Problem, context: Context) {
|
||||
viewModelScope.launch {
|
||||
// Delete associated images
|
||||
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
|
||||
|
||||
repository.deleteProblem(problem)
|
||||
|
||||
cleanupOrphanedImages(context)
|
||||
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupOrphanedImages(context: Context) {
|
||||
val allProblems = repository.getAllProblems().first()
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
|
||||
}
|
||||
|
||||
fun getProblemById(id: String): Flow<Problem?> = flow { emit(repository.getProblemById(id)) }
|
||||
|
||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
|
||||
|
||||
// Session operations
|
||||
fun addSession(session: ClimbSession) {
|
||||
viewModelScope.launch { repository.insertSession(session) }
|
||||
}
|
||||
|
||||
fun addSession(session: ClimbSession, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.insertSession(session)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSession(session: ClimbSession) {
|
||||
viewModelScope.launch { repository.updateSession(session) }
|
||||
}
|
||||
|
||||
fun updateSession(session: ClimbSession, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.updateSession(session)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSession(session: ClimbSession) {
|
||||
viewModelScope.launch { repository.deleteSession(session) }
|
||||
}
|
||||
|
||||
fun deleteSession(session: ClimbSession, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteSession(session)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSessionById(id: String): Flow<ClimbSession?> = flow {
|
||||
emit(repository.getSessionById(id))
|
||||
}
|
||||
|
||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
||||
repository.getSessionsByGym(gymId)
|
||||
|
||||
// Get last used gym for shortcut functionality
|
||||
suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym()
|
||||
|
||||
// Active session management
|
||||
fun startSession(context: Context, gymId: String, notes: String? = null) {
|
||||
viewModelScope.launch {
|
||||
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
|
||||
|
||||
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
|
||||
.isNotificationPermissionGranted(context)
|
||||
) {
|
||||
android.util.Log.d("ClimbViewModel", "Notification permission not granted")
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
error =
|
||||
"Notification permission is required to track your climbing session. Please enable notifications in settings."
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val existingActive = repository.getActiveSession()
|
||||
if (existingActive != null) {
|
||||
android.util.Log.d(
|
||||
"ClimbViewModel",
|
||||
"Active session already exists: ${existingActive.id}"
|
||||
)
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
error = "There's already an active session. Please end it first."
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
android.util.Log.d("ClimbViewModel", "Creating new session")
|
||||
val newSession = ClimbSession.create(gymId = gymId, notes = notes)
|
||||
repository.insertSession(newSession)
|
||||
|
||||
android.util.Log.d(
|
||||
"ClimbViewModel",
|
||||
"Starting tracking service for session: ${newSession.id}"
|
||||
)
|
||||
// Start the tracking service
|
||||
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
|
||||
context.startForegroundService(serviceIntent)
|
||||
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
|
||||
android.util.Log.d("ClimbViewModel", "Session started successfully")
|
||||
_uiState.value = _uiState.value.copy(message = "Session started successfully!")
|
||||
}
|
||||
}
|
||||
|
||||
fun endSession(context: Context, sessionId: String) {
|
||||
viewModelScope.launch {
|
||||
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
|
||||
.isNotificationPermissionGranted(context)
|
||||
) {
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
error =
|
||||
"Notification permission is required to manage your climbing session. Please enable notifications in settings."
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val session = repository.getSessionById(sessionId)
|
||||
if (session != null && session.status == SessionStatus.ACTIVE) {
|
||||
val completedSession = with(ClimbSession) { session.complete() }
|
||||
repository.updateSession(completedSession)
|
||||
|
||||
val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
|
||||
context.startService(serviceIntent)
|
||||
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
|
||||
_uiState.value = _uiState.value.copy(message = "Session completed!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureSessionTrackingServiceRunning(context: Context) {
|
||||
viewModelScope.launch {
|
||||
val activeSession = repository.getActiveSession()
|
||||
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
|
||||
val serviceIntent =
|
||||
SessionTrackingService.createStartIntent(context, activeSession.id)
|
||||
context.startForegroundService(serviceIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt operations
|
||||
fun addAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch { repository.insertAttempt(attempt) }
|
||||
}
|
||||
|
||||
fun addAttempt(attempt: Attempt, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.insertAttempt(attempt)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch { repository.deleteAttempt(attempt) }
|
||||
}
|
||||
|
||||
fun deleteAttempt(attempt: Attempt, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteAttempt(attempt)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch { repository.updateAttempt(attempt) }
|
||||
}
|
||||
|
||||
fun updateAttempt(attempt: Attempt, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.updateAttempt(attempt)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||
repository.getAttemptsBySession(sessionId)
|
||||
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
|
||||
repository.getAttemptsByProblem(problemId)
|
||||
|
||||
fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
repository.exportAllDataToZipUri(context, uri)
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "Data with images exported successfully"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Export failed: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun importData(file: File) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
|
||||
if (!file.name.lowercase().endsWith(".zip")) {
|
||||
throw Exception(
|
||||
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
|
||||
)
|
||||
}
|
||||
|
||||
repository.importDataFromZip(file)
|
||||
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "Data imported successfully from ${file.name}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Import failed: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI state operations
|
||||
fun clearMessage() {
|
||||
_uiState.value = _uiState.value.copy(message = null)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
fun setError(message: String) {
|
||||
_uiState.value = _uiState.value.copy(error = message)
|
||||
}
|
||||
|
||||
fun resetAllData() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
|
||||
repository.resetAllData()
|
||||
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "All data has been reset successfully"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value =
|
||||
_uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Share operations
|
||||
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val session = repository.getSessionById(sessionId) ?: return@withContext null
|
||||
val attempts = repository.getAttemptsBySession(sessionId).first()
|
||||
val problems =
|
||||
repository.getAllProblems().first().filter { problem ->
|
||||
attempts.any { it.problemId == problem.id }
|
||||
}
|
||||
val gym = repository.getGymById(session.gymId) ?: return@withContext null
|
||||
|
||||
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
|
||||
SessionShareUtils.generateShareCard(context, session, gym, stats)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
error = "Failed to generate share card: ${e.message}"
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun shareSessionCard(context: Context, imageFile: File) {
|
||||
SessionShareUtils.shareSessionCard(context, imageFile)
|
||||
}
|
||||
}
|
||||
|
||||
data class ClimbUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.atridad.openclimb.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.content.pm.ShortcutManager
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.atridad.openclimb.MainActivity
|
||||
import com.atridad.openclimb.R
|
||||
|
||||
object AppShortcutManager {
|
||||
|
||||
const val SHORTCUT_START_SESSION = "start_session"
|
||||
const val SHORTCUT_END_SESSION = "end_session"
|
||||
|
||||
const val ACTION_START_SESSION = "com.atridad.openclimb.action.START_SESSION"
|
||||
const val ACTION_END_SESSION = "com.atridad.openclimb.action.END_SESSION"
|
||||
|
||||
/** Updates the app shortcuts based on current session state */
|
||||
fun updateShortcuts(
|
||||
context: Context,
|
||||
hasActiveSession: Boolean,
|
||||
hasGyms: Boolean,
|
||||
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
|
||||
|
||||
val shortcuts = mutableListOf<ShortcutInfo>()
|
||||
|
||||
if (hasActiveSession) {
|
||||
// Show "End Session" shortcut when there's an active session
|
||||
shortcuts.add(createEndSessionShortcut(context))
|
||||
} else if (hasGyms) {
|
||||
// Show "Start Session" shortcut when no active session but gyms exist
|
||||
shortcuts.add(createStartSessionShortcut(context, lastUsedGym))
|
||||
}
|
||||
|
||||
shortcutManager.dynamicShortcuts = shortcuts
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
||||
private fun createStartSessionShortcut(
|
||||
context: Context,
|
||||
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
|
||||
): ShortcutInfo {
|
||||
val startIntent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
action = ACTION_START_SESSION
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
lastUsedGym?.let { gym -> putExtra("LAST_USED_GYM_ID", gym.id) }
|
||||
}
|
||||
|
||||
val shortLabel =
|
||||
if (lastUsedGym != null) {
|
||||
"Start at ${lastUsedGym.name}"
|
||||
} else {
|
||||
context.getString(R.string.shortcut_start_session_short)
|
||||
}
|
||||
|
||||
val longLabel =
|
||||
if (lastUsedGym != null) {
|
||||
"Start a new climbing session at ${lastUsedGym.name}"
|
||||
} else {
|
||||
context.getString(R.string.shortcut_start_session_long)
|
||||
}
|
||||
|
||||
return ShortcutInfo.Builder(context, SHORTCUT_START_SESSION)
|
||||
.setShortLabel(shortLabel)
|
||||
.setLongLabel(longLabel)
|
||||
.setIcon(Icon.createWithResource(context, R.drawable.ic_play_arrow_24))
|
||||
.setIntent(startIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
||||
private fun createEndSessionShortcut(context: Context): ShortcutInfo {
|
||||
val endIntent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
action = ACTION_END_SESSION
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
|
||||
return ShortcutInfo.Builder(context, SHORTCUT_END_SESSION)
|
||||
.setShortLabel(context.getString(R.string.shortcut_end_session_short))
|
||||
.setLongLabel(context.getString(R.string.shortcut_end_session_long))
|
||||
.setIcon(Icon.createWithResource(context, R.drawable.ic_stop_24))
|
||||
.setIntent(endIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Removes all dynamic shortcuts */
|
||||
fun clearShortcuts(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
|
||||
shortcutManager.removeAllDynamicShortcuts()
|
||||
}
|
||||
}
|
||||
|
||||
/** Disables a specific shortcut and shows a disabled message */
|
||||
fun disableShortcut(context: Context, shortcutId: String, disabledMessage: String) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
|
||||
shortcutManager.disableShortcuts(listOf(shortcutId), disabledMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.atridad.openclimb.widget
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.RemoteViews
|
||||
import com.atridad.openclimb.MainActivity
|
||||
import com.atridad.openclimb.R
|
||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ClimbStatsWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
private val job = SupervisorJob()
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO + job)
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
for (appWidgetId in appWidgetIds) {
|
||||
updateAppWidget(context, appWidgetManager, appWidgetId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private fun updateAppWidget(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetId: Int
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val database = OpenClimbDatabase.getDatabase(context)
|
||||
val repository = ClimbRepository(database, context)
|
||||
|
||||
// Fetch stats data
|
||||
val sessions = repository.getAllSessions().first()
|
||||
val problems = repository.getAllProblems().first()
|
||||
val attempts = repository.getAllAttempts().first()
|
||||
val gyms = repository.getAllGyms().first()
|
||||
val activeSession = repository.getActiveSession()
|
||||
|
||||
// Calculate stats
|
||||
val completedSessions = sessions.filter { it.endTime != null }
|
||||
|
||||
// Count problems that have been completed (have at least one successful attempt)
|
||||
val completedProblems =
|
||||
problems
|
||||
.filter { problem ->
|
||||
attempts.any { attempt ->
|
||||
attempt.problemId == problem.id &&
|
||||
(attempt.result ==
|
||||
com.atridad.openclimb.data.model
|
||||
.AttemptResult.SUCCESS ||
|
||||
attempt.result ==
|
||||
com.atridad.openclimb.data.model
|
||||
.AttemptResult.FLASH)
|
||||
}
|
||||
}
|
||||
.size
|
||||
|
||||
val favoriteGym =
|
||||
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
|
||||
(gymId, _) ->
|
||||
gyms.find { it.id == gymId }?.name
|
||||
}
|
||||
?: "No sessions yet"
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
|
||||
|
||||
views.setTextViewText(
|
||||
R.id.widget_total_sessions,
|
||||
completedSessions.size.toString()
|
||||
)
|
||||
views.setTextViewText(
|
||||
R.id.widget_problems_completed,
|
||||
completedProblems.toString()
|
||||
)
|
||||
views.setTextViewText(R.id.widget_total_problems, problems.size.toString())
|
||||
views.setTextViewText(R.id.widget_favorite_gym, favoriteGym)
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
val pendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
|
||||
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
launch(Dispatchers.Main) {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
|
||||
views.setTextViewText(R.id.widget_total_sessions, "0")
|
||||
views.setTextViewText(R.id.widget_problems_completed, "0")
|
||||
views.setTextViewText(R.id.widget_total_problems, "0")
|
||||
views.setTextViewText(R.id.widget_favorite_gym, "No data")
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
val pendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
|
||||
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun updateAllWidgets(context: Context) {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, ClimbStatsWidgetProvider::class.java)
|
||||
val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
|
||||
|
||||
val intent =
|
||||
Intent(context, ClimbStatsWidgetProvider::class.java).apply {
|
||||
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
||||
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#81C784"
|
||||
android:pathData="M8,5v14l11,-7z"/>
|
||||
</vector>
|
||||
9
android/app/src/main/res/drawable-night/ic_stop_24.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#EF5350"
|
||||
android:pathData="M6,6h12v12H6z"/>
|
||||
</vector>
|
||||
9
android/app/src/main/res/drawable/ic_play_arrow_24.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#4CAF50"
|
||||
android:pathData="M8,5v14l11,-7z"/>
|
||||
</vector>
|
||||
9
android/app/src/main/res/drawable/ic_stop_24.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#F44336"
|
||||
android:pathData="M6,6h12v12H6z"/>
|
||||
</vector>
|
||||
15
android/app/src/main/res/drawable/widget_background.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<solid android:color="@color/widget_background" />
|
||||
|
||||
<corners android:radius="24dp" />
|
||||
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/widget_outline" />
|
||||
|
||||
|
||||
|
||||
</shape>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<solid android:color="@color/widget_surface" />
|
||||
|
||||
<corners android:radius="16dp" />
|
||||
|
||||
<stroke
|
||||
android:width="0.5dp"
|
||||
android:color="@color/widget_outline" />
|
||||
|
||||
|
||||
|
||||
</shape>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<solid android:color="@color/widget_accent" />
|
||||
|
||||
<corners android:radius="12dp" />
|
||||
|
||||
<stroke
|
||||
android:width="0.5dp"
|
||||
android:color="@color/widget_text_primary" />
|
||||
|
||||
<padding
|
||||
android:left="6dp"
|
||||
android:top="3dp"
|
||||
android:right="6dp"
|
||||
android:bottom="3dp" />
|
||||
|
||||
</shape>
|
||||
195
android/app/src/main/res/layout/widget_climb_stats.xml
Normal file
@@ -0,0 +1,195 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/widget_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_background"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<!-- Header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:src="@drawable/ic_mountains"
|
||||
android:tint="@color/widget_primary"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="OpenClimb"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_text_primary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Climbing Stats"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_text_secondary" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<!-- Top Row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<!-- Sessions Card -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/widget_stat_card_background"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_total_sessions"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_primary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sessions"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Problems Card -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/widget_stat_card_background"
|
||||
android:layout_marginStart="4dp"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_problems_completed"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_primary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Completed"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Bottom Row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<!-- Success Rate Card -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/widget_stat_card_background"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_total_problems"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_secondary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Problems"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Favorite Gym Card -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/widget_stat_card_background"
|
||||
android:layout_marginStart="4dp"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_favorite_gym"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="No gyms"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/widget_accent"
|
||||
android:gravity="center"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Favorite"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.6 KiB |
15
android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Splash background (dark) -->
|
||||
<color name="splash_background">#FF121212</color>
|
||||
|
||||
<!-- Widget colors (dark theme) -->
|
||||
<color name="widget_background">#FF1E1E1E</color>
|
||||
<color name="widget_surface">#FF2D2D2D</color>
|
||||
<color name="widget_outline">#FF404040</color>
|
||||
<color name="widget_primary">#FF90CAF9</color>
|
||||
<color name="widget_secondary">#FFA5D6A7</color>
|
||||
<color name="widget_accent">#FFFF8A65</color>
|
||||
<color name="widget_text_primary">#FFFFFFFF</color>
|
||||
<color name="widget_text_secondary">#FFBDBDBD</color>
|
||||
</resources>
|
||||
23
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
|
||||
<!-- Splash background (light) -->
|
||||
<color name="splash_background">#FFFFFFFF</color>
|
||||
|
||||
<!-- Widget colors (light theme) -->
|
||||
<color name="widget_background">#FFFFFFFF</color>
|
||||
<color name="widget_surface">#FFF8F9FA</color>
|
||||
<color name="widget_outline">#FFE0E0E0</color>
|
||||
<color name="widget_primary">#FF1976D2</color>
|
||||
<color name="widget_secondary">#FF388E3C</color>
|
||||
<color name="widget_accent">#FFFF5722</color>
|
||||
<color name="widget_text_primary">#FF212121</color>
|
||||
<color name="widget_text_secondary">#FF757575</color>
|
||||
</resources>
|
||||
16
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<resources>
|
||||
<string name="app_name">OpenClimb</string>
|
||||
<string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string>
|
||||
|
||||
<!-- App Shortcuts -->
|
||||
<string name="shortcut_start_session_short">Start Session</string>
|
||||
<string name="shortcut_start_session_long">Start a new climbing session</string>
|
||||
<string name="shortcut_start_session_disabled">No gyms available to start session</string>
|
||||
|
||||
<string name="shortcut_end_session_short">End Session</string>
|
||||
<string name="shortcut_end_session_long">End current climbing session</string>
|
||||
<string name="shortcut_end_session_disabled">No active session to end</string>
|
||||
|
||||
<!-- Widget -->
|
||||
<string name="widget_description">View your climbing stats at a glance</string>
|
||||
</resources>
|
||||
17
android/app/src/main/res/xml/widget_climb_stats_info.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_description"
|
||||
android:initialKeyguardLayout="@layout/widget_climb_stats"
|
||||
android:initialLayout="@layout/widget_climb_stats"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="180dp"
|
||||
android:previewImage="@drawable/ic_mountains"
|
||||
android:previewLayout="@layout/widget_climb_stats"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:maxResizeWidth="320dp"
|
||||
android:maxResizeHeight="240dp" />
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "8.12.1"
|
||||
agp = "8.12.2"
|
||||
kotlin = "2.2.10"
|
||||
coreKtx = "1.17.0"
|
||||
junit = "4.13.2"
|
||||