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
|
1
.gitignore
vendored
@@ -8,6 +8,7 @@ local.properties
|
|||||||
|
|
||||||
# Log/OS Files
|
# Log/OS Files
|
||||||
*.log
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# Android Studio generated files and folders
|
# Android Studio generated files and folders
|
||||||
captures/
|
captures/
|
||||||
|
|||||||
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>
|
|
||||||
19
README.md
@@ -1,17 +1,26 @@
|
|||||||
# OpenClimb
|
# OpenClimb
|
||||||
|
|
||||||
This is a FOSS Android app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support.
|
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
|
## Download
|
||||||
|
|
||||||
You have two options:
|
For Android do one of the following:
|
||||||
|
|
||||||
1. Download the latest APK from the Released page
|
1. Download the latest APK from the Releases 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>
|
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
|
## Requirements
|
||||||
|
|
||||||
- Android 15+
|
- Android 12+ or iOS 17+
|
||||||
|
|
||||||
## Contribution
|
## Contribution
|
||||||
|
|
||||||
|
|||||||
0
app/.gitignore → android/app/.gitignore
vendored
@@ -14,10 +14,10 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.openclimb"
|
||||||
minSdk = 34
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 18
|
versionCode = 23
|
||||||
versionName = "1.2.0"
|
versionName = "1.4.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -36,22 +36,12 @@ android {
|
|||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
|
||||||
toolchain {
|
|
||||||
languageVersion.set(JavaLanguageVersion.of(17))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures { compose = true }
|
||||||
compose = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_17)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Core Android libraries
|
// Core Android libraries
|
||||||
@@ -87,8 +77,6 @@ dependencies {
|
|||||||
// Image Loading
|
// Image Loading
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.mockk)
|
testImplementation(libs.mockk)
|
||||||
@@ -104,3 +92,4 @@ dependencies {
|
|||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +36,8 @@
|
|||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- FileProvider for sharing images -->
|
<!-- FileProvider for sharing images -->
|
||||||
@@ -60,6 +62,18 @@
|
|||||||
android:name="android.app.foreground_service_type"
|
android:name="android.app.foreground_service_type"
|
||||||
android:value="specialUse" />
|
android:value="specialUse" />
|
||||||
</service>
|
</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>
|
</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,27 +1,21 @@
|
|||||||
package com.atridad.openclimb.data.repository
|
package com.atridad.openclimb.data.repository
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Environment
|
|
||||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.openclimb.data.model.*
|
||||||
import com.atridad.openclimb.utils.ZipExportImportUtils
|
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.io.File
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
class ClimbRepository(
|
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
|
||||||
database: OpenClimbDatabase,
|
|
||||||
private val context: Context
|
|
||||||
) {
|
|
||||||
private val gymDao = database.gymDao()
|
private val gymDao = database.gymDao()
|
||||||
private val problemDao = database.problemDao()
|
private val problemDao = database.problemDao()
|
||||||
private val sessionDao = database.climbSessionDao()
|
private val sessionDao = database.climbSessionDao()
|
||||||
private val attemptDao = database.attemptDao()
|
private val attemptDao = database.attemptDao()
|
||||||
|
|
||||||
|
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
prettyPrint = true
|
prettyPrint = true
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
@@ -47,23 +41,32 @@ class ClimbRepository(
|
|||||||
// Session operations
|
// Session operations
|
||||||
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
|
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
|
||||||
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
|
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()
|
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
|
||||||
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
|
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
|
||||||
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
|
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
|
||||||
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
|
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
|
||||||
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(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
|
// Attempt operations
|
||||||
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
|
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
|
||||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = attemptDao.getAttemptsBySession(sessionId)
|
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = attemptDao.getAttemptsByProblem(problemId)
|
attemptDao.getAttemptsBySession(sessionId)
|
||||||
|
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
|
||||||
|
attemptDao.getAttemptsByProblem(problemId)
|
||||||
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
|
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
|
||||||
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
|
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
|
||||||
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
|
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ZIP Export with images - Single format for reliability
|
// ZIP Export with images - Single format for reliability
|
||||||
suspend fun exportAllDataToZip(directory: File? = null): File {
|
suspend fun exportAllDataToZip(directory: File? = null): File {
|
||||||
try {
|
try {
|
||||||
@@ -76,7 +79,8 @@ class ClimbRepository(
|
|||||||
// Validate data integrity before export
|
// Validate data integrity before export
|
||||||
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
|
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
|
||||||
|
|
||||||
val exportData = ClimbDataExport(
|
val exportData =
|
||||||
|
ClimbDataExport(
|
||||||
exportedAt = LocalDateTime.now().toString(),
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
version = "1.0",
|
version = "1.0",
|
||||||
gyms = allGyms,
|
gyms = allGyms,
|
||||||
@@ -87,19 +91,29 @@ class ClimbRepository(
|
|||||||
|
|
||||||
// Collect all referenced image paths and validate they exist
|
// Collect all referenced image paths and validate they exist
|
||||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||||
val validImagePaths = referencedImagePaths.filter { imagePath ->
|
val validImagePaths =
|
||||||
|
referencedImagePaths
|
||||||
|
.filter { imagePath ->
|
||||||
try {
|
try {
|
||||||
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath)
|
val imageFile =
|
||||||
|
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||||
|
context,
|
||||||
|
imagePath
|
||||||
|
)
|
||||||
imageFile.exists() && imageFile.length() > 0
|
imageFile.exists() && imageFile.length() > 0
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}.toSet()
|
}
|
||||||
|
.toSet()
|
||||||
|
|
||||||
// Log any missing images for debugging
|
// Log any missing images for debugging
|
||||||
val missingImages = referencedImagePaths - validImagePaths
|
val missingImages = referencedImagePaths - validImagePaths
|
||||||
if (missingImages.isNotEmpty()) {
|
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(
|
return ZipExportImportUtils.createExportZip(
|
||||||
@@ -124,7 +138,8 @@ class ClimbRepository(
|
|||||||
// Validate data integrity before export
|
// Validate data integrity before export
|
||||||
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
|
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
|
||||||
|
|
||||||
val exportData = ClimbDataExport(
|
val exportData =
|
||||||
|
ClimbDataExport(
|
||||||
exportedAt = LocalDateTime.now().toString(),
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
version = "1.0",
|
version = "1.0",
|
||||||
gyms = allGyms,
|
gyms = allGyms,
|
||||||
@@ -135,14 +150,21 @@ class ClimbRepository(
|
|||||||
|
|
||||||
// Collect all referenced image paths and validate they exist
|
// Collect all referenced image paths and validate they exist
|
||||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||||
val validImagePaths = referencedImagePaths.filter { imagePath ->
|
val validImagePaths =
|
||||||
|
referencedImagePaths
|
||||||
|
.filter { imagePath ->
|
||||||
try {
|
try {
|
||||||
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath)
|
val imageFile =
|
||||||
|
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||||
|
context,
|
||||||
|
imagePath
|
||||||
|
)
|
||||||
imageFile.exists() && imageFile.length() > 0
|
imageFile.exists() && imageFile.length() > 0
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}.toSet()
|
}
|
||||||
|
.toSet()
|
||||||
|
|
||||||
ZipExportImportUtils.createExportZipToUri(
|
ZipExportImportUtils.createExportZipToUri(
|
||||||
context = context,
|
context = context,
|
||||||
@@ -171,7 +193,8 @@ class ClimbRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate the data structure
|
// Parse and validate the data structure
|
||||||
val importData = try {
|
val importData =
|
||||||
|
try {
|
||||||
json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
|
json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw Exception("Invalid data format: ${e.message}")
|
throw Exception("Invalid data format: ${e.message}")
|
||||||
@@ -196,7 +219,8 @@ class ClimbRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import problems with updated image paths
|
// Import problems with updated image paths
|
||||||
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths(
|
val updatedProblems =
|
||||||
|
ZipExportImportUtils.updateProblemImagePaths(
|
||||||
importData.problems,
|
importData.problems,
|
||||||
importResult.importedImagePaths
|
importResult.importedImagePaths
|
||||||
)
|
)
|
||||||
@@ -226,7 +250,6 @@ class ClimbRepository(
|
|||||||
throw Exception("Failed to import attempt: ${e.message}")
|
throw Exception("Failed to import attempt: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw Exception("Import failed: ${e.message}")
|
throw Exception("Import failed: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -242,24 +265,29 @@ class ClimbRepository(
|
|||||||
val gymIds = gyms.map { it.id }.toSet()
|
val gymIds = gyms.map { it.id }.toSet()
|
||||||
val invalidProblems = problems.filter { it.gymId !in gymIds }
|
val invalidProblems = problems.filter { it.gymId !in gymIds }
|
||||||
if (invalidProblems.isNotEmpty()) {
|
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
|
// Validate that all sessions reference valid gyms
|
||||||
val invalidSessions = sessions.filter { it.gymId !in gymIds }
|
val invalidSessions = sessions.filter { it.gymId !in gymIds }
|
||||||
if (invalidSessions.isNotEmpty()) {
|
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
|
// Validate that all attempts reference valid problems and sessions
|
||||||
val problemIds = problems.map { it.id }.toSet()
|
val problemIds = problems.map { it.id }.toSet()
|
||||||
val sessionIds = sessions.map { it.id }.toSet()
|
val sessionIds = sessions.map { it.id }.toSet()
|
||||||
|
|
||||||
val invalidAttempts = attempts.filter {
|
val invalidAttempts =
|
||||||
it.problemId !in problemIds || it.sessionId !in sessionIds
|
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
|
||||||
}
|
|
||||||
if (invalidAttempts.isNotEmpty()) {
|
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"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +304,8 @@ class ClimbRepository(
|
|||||||
if (importData.gyms.size > 1000 ||
|
if (importData.gyms.size > 1000 ||
|
||||||
importData.problems.size > 10000 ||
|
importData.problems.size > 10000 ||
|
||||||
importData.sessions.size > 10000 ||
|
importData.sessions.size > 10000 ||
|
||||||
importData.attempts.size > 100000) {
|
importData.attempts.size > 100000
|
||||||
|
) {
|
||||||
throw Exception("Import data is too large: possible corruption or malicious file")
|
throw Exception("Import data is too large: possible corruption or malicious file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +320,6 @@ class ClimbRepository(
|
|||||||
|
|
||||||
// Clear all images from storage
|
// Clear all images from storage
|
||||||
clearAllImages()
|
clearAllImages()
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw Exception("Reset failed: ${e.message}")
|
throw Exception("Reset failed: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -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.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@@ -31,12 +32,12 @@ data class ChartDataPoint(
|
|||||||
* Configuration for chart styling
|
* Configuration for chart styling
|
||||||
*/
|
*/
|
||||||
data class ChartStyle(
|
data class ChartStyle(
|
||||||
val lineColor: Color = Color(0xFF6366F1),
|
val lineColor: Color,
|
||||||
val fillColor: Color = Color(0x336366F1),
|
val fillColor: Color,
|
||||||
val lineWidth: Float = 3f,
|
val lineWidth: Float = 3f,
|
||||||
val gridColor: Color = Color(0xFFE5E7EB),
|
val gridColor: Color,
|
||||||
val textColor: Color = Color(0xFF374151),
|
val textColor: Color,
|
||||||
val backgroundColor: Color = Color.White
|
val backgroundColor: Color
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +47,13 @@ data class ChartStyle(
|
|||||||
fun LineChart(
|
fun LineChart(
|
||||||
data: List<ChartDataPoint>,
|
data: List<ChartDataPoint>,
|
||||||
modifier: Modifier = Modifier,
|
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,
|
showGrid: Boolean = true,
|
||||||
xAxisFormatter: (Float) -> String = { it.toString() },
|
xAxisFormatter: (Float) -> String = { it.toString() },
|
||||||
yAxisFormatter: (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 ->
|
screenPoints.forEach { point ->
|
||||||
|
// Draw outer circle (larger)
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = style.lineColor,
|
color = style.lineColor,
|
||||||
radius = style.lineWidth * 1.5f,
|
radius = 8f,
|
||||||
center = point
|
center = point
|
||||||
)
|
)
|
||||||
|
// Draw inner circle (white center)
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = style.backgroundColor,
|
color = style.backgroundColor,
|
||||||
radius = style.lineWidth * 0.8f,
|
radius = 5f,
|
||||||
center = point
|
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()
|
strokeWidth = 1.dp.toPx()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Draw label
|
// X-axis labels removed per user request
|
||||||
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()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,11 +543,18 @@ fun AddEditProblemScreen(
|
|||||||
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
|
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = difficultyGrade,
|
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 *") },
|
label = { Text("Grade *") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
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 {
|
} else {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
@@ -132,14 +132,12 @@ fun ProgressChartCard(
|
|||||||
progressData: List<ProgressDataPoint>,
|
progressData: List<ProgressDataPoint>,
|
||||||
problems: List<com.atridad.openclimb.data.model.Problem>,
|
problems: List<com.atridad.openclimb.data.model.Problem>,
|
||||||
) {
|
) {
|
||||||
// Find all grading systems that have been used
|
// Find all grading systems that have been used in the progress data
|
||||||
val usedSystems = remember(problems) {
|
val usedSystems = remember(progressData) {
|
||||||
problems.map { it.difficulty.system }.distinct().filter { system ->
|
progressData.map { it.difficultySystem }.distinct()
|
||||||
problems.any { it.difficulty.system == system }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedSystem by remember {
|
var selectedSystem by remember(usedSystems) {
|
||||||
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
|
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
|
||||||
}
|
}
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
@@ -456,7 +454,10 @@ fun gradeToNumeric(system: DifficultySystem, grade: String): Int {
|
|||||||
else -> 0
|
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.BorderStroke
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -35,7 +37,6 @@ import com.atridad.openclimb.ui.theme.CustomIcons
|
|||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -292,7 +293,7 @@ fun SessionDetailScreen(
|
|||||||
// Show stop icon for active sessions, delete icon for completed sessions
|
// Show stop icon for active sessions, delete icon for completed sessions
|
||||||
if (session?.status == SessionStatus.ACTIVE) {
|
if (session?.status == SessionStatus.ACTIVE) {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
session?.let { s ->
|
session.let { s ->
|
||||||
viewModel.endSession(context, s.id)
|
viewModel.endSession(context, s.id)
|
||||||
onNavigateBack()
|
onNavigateBack()
|
||||||
}
|
}
|
||||||
@@ -916,11 +917,6 @@ fun GymDetailScreen(
|
|||||||
problems.any { problem -> problem.id == attempt.problemId }
|
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 uniqueProblemsClimbed = gymAttempts.map { it.problemId }.toSet().size
|
||||||
val totalSessions = sessions.size
|
val totalSessions = sessions.size
|
||||||
val activeSessions = sessions.count { it.status == SessionStatus.ACTIVE }
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EnhancedAddAttemptDialog(
|
fun EnhancedAddAttemptDialog(
|
||||||
@@ -1835,8 +1761,12 @@ fun EnhancedAddAttemptDialog(
|
|||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
TextButton(onClick = { showCreateProblem = false }) {
|
IconButton(onClick = { showCreateProblem = false }) {
|
||||||
Text("← Back", color = MaterialTheme.colorScheme.primary)
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1930,9 +1860,14 @@ fun EnhancedAddAttemptDialog(
|
|||||||
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
|
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = newProblemGrade,
|
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 *") },
|
label = { Text("Grade *") },
|
||||||
placeholder = { Text("Enter custom grade") },
|
placeholder = { Text("Enter numeric grade (e.g. 5, 10, 15)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
colors =
|
colors =
|
||||||
@@ -1943,6 +1878,7 @@ fun EnhancedAddAttemptDialog(
|
|||||||
MaterialTheme.colorScheme.outline
|
MaterialTheme.colorScheme.outline
|
||||||
),
|
),
|
||||||
isError = newProblemGrade.isBlank(),
|
isError = newProblemGrade.isBlank(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
supportingText =
|
supportingText =
|
||||||
if (newProblemGrade.isBlank()) {
|
if (newProblemGrade.isBlank()) {
|
||||||
{
|
{
|
||||||
@@ -1951,7 +1887,14 @@ fun EnhancedAddAttemptDialog(
|
|||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else null
|
} else {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
"Custom grades must be whole numbers",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
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]
|
[versions]
|
||||||
agp = "8.12.1"
|
agp = "8.12.2"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||