Compare commits
39 Commits
87195aabf1
...
1.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
7770997fd4
|
|||
|
f45ff8963d
|
|||
|
5988cbf1fb
|
|||
| 13654cde70 | |||
| 9064dbe2ef | |||
|
0537da79e4
|
|||
|
4804049274
|
|||
|
8db6ed0e82
|
|||
|
8b9901383a
|
|||
|
cf2adeef7a
|
|||
|
a7481135b4
|
|||
|
748a23e1c0
|
|||
|
f078cfc6e1
|
|||
|
8bb1f422c1
|
|||
|
327dfba425
|
|||
|
96759e402d
|
|||
|
ed76fb2fb2
|
|||
|
870278f240
|
|||
|
4eef77bd3b
|
|||
|
2d957db948
|
|||
| 22bed6a961 | |||
|
b443c18a19
|
|||
|
89f1e350b3
|
|||
|
0f976f685f
|
|||
|
c07186a7df
|
|||
|
15a5e217a5
|
|||
|
b86ab591fe
|
|||
|
70c85d159e
|
|||
|
d6c5e937df
|
|||
|
829bbbff7a
|
|||
|
e1ebf412bd
|
|||
|
5c133b655e
|
|||
|
cc1edbc65c
|
|||
|
ca770b9db3
|
|||
|
7edb7c8191
|
|||
|
1ca6b33882
|
|||
|
bd6b5cc652
|
|||
|
6e16a30429
|
|||
|
66fdef78d9
|
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
release/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Log/OS Files
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android Studio generated files and folders
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.aab
|
||||||
|
*.apk
|
||||||
|
output-metadata.json
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
misc.xml
|
||||||
|
deploymentTargetDropDown.xml
|
||||||
|
render.experimental.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
google-services.json
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
#Fri Aug 15 12:27:13 MDT 2025
|
|
||||||
gradle.version=8.11.1
|
|
||||||
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
#Fri Aug 15 12:29:02 MDT 2025
|
|
||||||
java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home
|
|
||||||
Binary file not shown.
25
.idea/caches/deviceStreaming.xml
generated
25
.idea/caches/deviceStreaming.xml
generated
@@ -51,6 +51,18 @@
|
|||||||
<option name="screenX" value="1080" />
|
<option name="screenX" value="1080" />
|
||||||
<option name="screenY" value="2412" />
|
<option name="screenY" value="2412" />
|
||||||
</PersistentDeviceSelectionData>
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="35" />
|
||||||
|
<option name="brand" value="OnePlus" />
|
||||||
|
<option name="codename" value="OP5552L1" />
|
||||||
|
<option name="id" value="OP5552L1" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="OnePlus" />
|
||||||
|
<option name="name" value="CPH2415" />
|
||||||
|
<option name="screenDensity" value="480" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2412" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
<PersistentDeviceSelectionData>
|
<PersistentDeviceSelectionData>
|
||||||
<option name="api" value="34" />
|
<option name="api" value="34" />
|
||||||
<option name="brand" value="OPPO" />
|
<option name="brand" value="OPPO" />
|
||||||
@@ -817,6 +829,19 @@
|
|||||||
<option name="screenX" value="1080" />
|
<option name="screenX" value="1080" />
|
||||||
<option name="screenY" value="2424" />
|
<option name="screenY" value="2424" />
|
||||||
</PersistentDeviceSelectionData>
|
</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>
|
<PersistentDeviceSelectionData>
|
||||||
<option name="api" value="34" />
|
<option name="api" value="34" />
|
||||||
<option name="brand" value="samsung" />
|
<option name="brand" value="samsung" />
|
||||||
|
|||||||
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@@ -4,6 +4,14 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2025-09-07T04:49:14.182787Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Medium_Phone.avd" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -1,11 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="#JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
|
|||||||
8
.idea/misc.xml
generated
8
.idea/misc.xml
generated
@@ -1,10 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" />
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectType">
|
|
||||||
<option name="id" value="Android" />
|
|
||||||
</component>
|
|
||||||
</project>
|
</project>
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
kotlin version: 2.0.21
|
|
||||||
error message: java.lang.IllegalStateException: Storage for [/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] is already registered
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.set(PersistentStorage.kt:96)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addFileIfNeeded(LookupStorage.kt:165)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addAll$lambda$4(LookupStorage.kt:117)
|
|
||||||
at org.jetbrains.kotlin.utils.CollectionsKt.keysToMap(collections.kt:117)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addAll(LookupStorage.kt:117)
|
|
||||||
at org.jetbrains.kotlin.incremental.BuildUtilKt.update(buildUtil.kt:134)
|
|
||||||
at com.google.devtools.ksp.LookupStorageWrapperImpl.update(IncrementalContext.kt:231)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateLookupCache(IncrementalContextBase.kt:133)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateCaches(IncrementalContextBase.kt:365)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:471)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:362)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
|
|
||||||
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555)
|
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
|
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
|
|
||||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
|
|
||||||
at java.base/java.lang.Thread.run(Thread.java:1583)
|
|
||||||
Suppressed: java.lang.Exception: Storage[/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] registration stack trace
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageIfExists(LazyStorage.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.get(LazyStorage.kt:76)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.get(PersistentStorage.kt:92)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.get(LookupStorage.kt:99)
|
|
||||||
at com.google.devtools.ksp.LookupStorageWrapperImpl.get(IncrementalContext.kt:224)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.calcDirtyFiles(IncrementalContextBase.kt:234)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:196)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:189)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.handleException(KotlinSymbolProcessingExtension.kt:414)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:189)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
|
|
||||||
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
|
|
||||||
... 23 more
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
kotlin version: 2.0.21
|
|
||||||
error message: java.lang.IllegalStateException: Storage for [/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] is already registered
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.set(PersistentStorage.kt:96)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addFileIfNeeded(LookupStorage.kt:165)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addAll$lambda$4(LookupStorage.kt:117)
|
|
||||||
at org.jetbrains.kotlin.utils.CollectionsKt.keysToMap(collections.kt:117)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.addAll(LookupStorage.kt:117)
|
|
||||||
at org.jetbrains.kotlin.incremental.BuildUtilKt.update(buildUtil.kt:134)
|
|
||||||
at com.google.devtools.ksp.LookupStorageWrapperImpl.update(IncrementalContext.kt:231)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateLookupCache(IncrementalContextBase.kt:133)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateCaches(IncrementalContextBase.kt:365)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:471)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:362)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
|
|
||||||
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555)
|
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
|
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
|
|
||||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
|
|
||||||
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
|
|
||||||
at java.base/java.lang.Thread.run(Thread.java:1583)
|
|
||||||
Suppressed: java.lang.Exception: Storage[/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] registration stack trace
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
|
|
||||||
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageIfExists(LazyStorage.kt:53)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.LazyStorage.get(LazyStorage.kt:76)
|
|
||||||
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.get(PersistentStorage.kt:92)
|
|
||||||
at org.jetbrains.kotlin.incremental.LookupStorage.get(LookupStorage.kt:99)
|
|
||||||
at com.google.devtools.ksp.LookupStorageWrapperImpl.get(IncrementalContext.kt:224)
|
|
||||||
at com.google.devtools.ksp.common.IncrementalContextBase.calcDirtyFiles(IncrementalContextBase.kt:234)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:196)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:189)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.handleException(KotlinSymbolProcessingExtension.kt:414)
|
|
||||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:189)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
|
|
||||||
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
|
|
||||||
... 23 more
|
|
||||||
|
|
||||||
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"java.configuration.updateBuildConfiguration": "disabled"
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,8 @@ This is a FOSS Android app meant to help climbers track their sessions, routes/p
|
|||||||
|
|
||||||
You have two options:
|
You have two options:
|
||||||
|
|
||||||
1. Download the latest APK from the Released page
|
1. Download the latest APK from the Releases page
|
||||||
2. Use <a href="">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)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
@@ -8,21 +10,21 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.atridad.openclimb"
|
namespace = "com.atridad.openclimb"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.openclimb"
|
||||||
minSdk = 31
|
minSdk = 34
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 22
|
||||||
versionName = "0.1.0"
|
versionName = "1.4.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
@@ -30,17 +32,27 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "11"
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion.set(JavaLanguageVersion.of(17))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Core Android libraries
|
// Core Android libraries
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
@@ -57,6 +69,7 @@ dependencies {
|
|||||||
// Room Database
|
// Room Database
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
|
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
@@ -74,13 +87,18 @@ dependencies {
|
|||||||
// Image Loading
|
// Image Loading
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
// Charts - Placeholder for future implementation
|
|
||||||
// Charts will be implemented with a stable library in future versions
|
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.mockk)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
|
androidTestImplementation(libs.androidx.test.ext)
|
||||||
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
androidTestImplementation(libs.androidx.test.rules)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
|||||||
@@ -7,12 +7,15 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<!-- Hardware features -->
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
<!-- Permissions for notifications and foreground service -->
|
<!-- Permissions for notifications and foreground service -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -27,14 +30,16 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.OpenClimb">
|
android:theme="@style/Theme.OpenClimb.Splash">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<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 -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
@@ -45,17 +50,30 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_provider_paths" />
|
android:resource="@xml/file_provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<!-- Session tracking service -->
|
<!-- Session tracking service -->
|
||||||
<service
|
<service
|
||||||
android:name=".service.SessionTrackingService"
|
android:name=".service.SessionTrackingService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="specialUse">
|
android:foregroundServiceType="specialUse"
|
||||||
|
android:description="@string/session_tracking_service_description">
|
||||||
<meta-data
|
<meta-data
|
||||||
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>
|
||||||
|
|||||||
@@ -1,27 +1,52 @@
|
|||||||
package com.atridad.openclimb
|
package com.atridad.openclimb
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.atridad.openclimb.ui.OpenClimbApp
|
import com.atridad.openclimb.ui.OpenClimbApp
|
||||||
import com.atridad.openclimb.ui.theme.OpenClimbTheme
|
import com.atridad.openclimb.ui.theme.OpenClimbTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
setTheme(R.style.Theme_OpenClimb)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
shortcutAction = intent?.action
|
||||||
|
lastUsedGymId = intent?.getStringExtra("LAST_USED_GYM_ID")
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
OpenClimbTheme {
|
OpenClimbTheme {
|
||||||
Surface(
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier.fillMaxSize()
|
OpenClimbApp(
|
||||||
) {
|
shortcutAction = shortcutAction,
|
||||||
OpenClimbApp()
|
lastUsedGymId = lastUsedGymId,
|
||||||
|
onShortcutActionProcessed = { clearShortcutAction() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
|
||||||
|
shortcutAction = intent.action
|
||||||
|
lastUsedGymId = intent.getStringExtra("LAST_USED_GYM_ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ interface AttemptDao {
|
|||||||
@Query("SELECT COUNT(*) FROM attempts")
|
@Query("SELECT COUNT(*) FROM attempts")
|
||||||
suspend fun getAttemptsCount(): Int
|
suspend fun getAttemptsCount(): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM attempts")
|
||||||
|
suspend fun deleteAllAttempts()
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId")
|
@Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId")
|
||||||
suspend fun getAttemptsCountBySession(sessionId: String): Int
|
suspend fun getAttemptsCountBySession(sessionId: String): Int
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ interface ClimbSessionDao {
|
|||||||
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
||||||
suspend fun getActiveSession(): ClimbSession?
|
suspend fun getActiveSession(): ClimbSession?
|
||||||
|
|
||||||
|
@Query("DELETE FROM climb_sessions")
|
||||||
|
suspend fun deleteAllSessions()
|
||||||
|
|
||||||
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
|
||||||
fun getActiveSessionFlow(): Flow<ClimbSession?>
|
fun getActiveSessionFlow(): Flow<ClimbSession?>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,7 @@ interface GymDao {
|
|||||||
|
|
||||||
@Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'")
|
@Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'")
|
||||||
fun searchGyms(searchQuery: String): Flow<List<Gym>>
|
fun searchGyms(searchQuery: String): Flow<List<Gym>>
|
||||||
|
|
||||||
|
@Query("DELETE FROM gyms")
|
||||||
|
suspend fun deleteAllGyms()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,4 +59,10 @@ interface ProblemDao {
|
|||||||
ORDER BY updatedAt DESC
|
ORDER BY updatedAt DESC
|
||||||
""")
|
""")
|
||||||
fun searchProblems(searchQuery: String): Flow<List<Problem>>
|
fun searchProblems(searchQuery: String): Flow<List<Problem>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM problems")
|
||||||
|
suspend fun getProblemsCount(): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM problems")
|
||||||
|
suspend fun deleteAllProblems()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class AttemptResult {
|
enum class AttemptResult {
|
||||||
SUCCESS, // Completed the problem/route
|
SUCCESS,
|
||||||
FALL, // Fell but made progress
|
FALL,
|
||||||
NO_PROGRESS, // Couldn't make meaningful progress
|
NO_PROGRESS,
|
||||||
FLASH, // Completed on first try
|
FLASH,
|
||||||
REDPOINT, // Completed after previous attempts
|
|
||||||
ONSIGHT // Completed on first try without prior knowledge
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ data class ClimbSession(
|
|||||||
@PrimaryKey
|
@PrimaryKey
|
||||||
val id: String,
|
val id: String,
|
||||||
val gymId: String,
|
val gymId: String,
|
||||||
val date: String, // ISO date string
|
val date: String,
|
||||||
val startTime: String? = null, // When session was started
|
val startTime: String? = null,
|
||||||
val endTime: String? = null, // When session was completed
|
val endTime: String? = null,
|
||||||
val duration: Long? = null, // Duration in minutes (calculated when completed)
|
val duration: Long? = null,
|
||||||
val status: SessionStatus = SessionStatus.ACTIVE,
|
val status: SessionStatus = SessionStatus.ACTIVE,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
@@ -65,7 +65,7 @@ data class ClimbSession(
|
|||||||
val start = LocalDateTime.parse(startTime)
|
val start = LocalDateTime.parse(startTime)
|
||||||
val end = LocalDateTime.parse(endTime)
|
val end = LocalDateTime.parse(endTime)
|
||||||
java.time.Duration.between(start, end).toMinutes()
|
java.time.Duration.between(start, end).toMinutes()
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|||||||
@@ -5,5 +5,13 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
enum class ClimbType {
|
enum class ClimbType {
|
||||||
ROPE,
|
ROPE,
|
||||||
BOULDER
|
BOULDER;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display name
|
||||||
|
*/
|
||||||
|
fun getDisplayName(): String = when (this) {
|
||||||
|
ROPE -> "Rope"
|
||||||
|
BOULDER -> "Bouldering"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,105 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class DifficultySystem {
|
enum class DifficultySystem {
|
||||||
// Rope climbing systems
|
// Bouldering
|
||||||
YDS, // Yosemite Decimal System (5.1 - 5.15d)
|
V_SCALE, // V-Scale (VB - V17)
|
||||||
FRENCH, // French system (3 - 9c+)
|
FONT, // Fontainebleau (3 - 8C+)
|
||||||
UIAA, // UIAA system (I - XII+)
|
|
||||||
BRITISH, // British system (Mod - E11)
|
|
||||||
|
|
||||||
// Bouldering systems
|
// Rope
|
||||||
V_SCALE, // V-Scale (VB - V17)
|
YDS, // Yosemite Decimal System (5.0 - 5.15d)
|
||||||
FONT, // Fontainebleau (3 - 9A+)
|
|
||||||
|
|
||||||
// Custom system for gyms that use their own colors/naming
|
// Custom difficulty systems
|
||||||
CUSTOM
|
CUSTOM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display name for the UI
|
||||||
|
*/
|
||||||
|
fun getDisplayName(): String = when (this) {
|
||||||
|
V_SCALE -> "V Scale"
|
||||||
|
FONT -> "Font Scale"
|
||||||
|
YDS -> "YDS (Yosemite)"
|
||||||
|
CUSTOM -> "Custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this system is for bouldering
|
||||||
|
*/
|
||||||
|
fun isBoulderingSystem(): Boolean = when (this) {
|
||||||
|
V_SCALE, FONT -> true
|
||||||
|
YDS -> false
|
||||||
|
CUSTOM -> true // Custom is available for all
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this system is for rope climbing
|
||||||
|
*/
|
||||||
|
fun isRopeSystem(): Boolean = when (this) {
|
||||||
|
YDS -> true
|
||||||
|
V_SCALE, FONT -> false
|
||||||
|
CUSTOM -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available grades for this system
|
||||||
|
*/
|
||||||
|
fun getAvailableGrades(): List<String> = when (this) {
|
||||||
|
V_SCALE -> listOf("VB", "V0", "V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9", "V10", "V11", "V12", "V13", "V14", "V15", "V16", "V17")
|
||||||
|
FONT -> listOf("3", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6A+", "6B", "6B+", "6C", "6C+", "7A", "7A+", "7B", "7B+", "7C", "7C+", "8A", "8A+", "8B", "8B+", "8C", "8C+")
|
||||||
|
YDS -> listOf("5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "5.10a", "5.10b", "5.10c", "5.10d", "5.11a", "5.11b", "5.11c", "5.11d", "5.12a", "5.12b", "5.12c", "5.12d", "5.13a", "5.13b", "5.13c", "5.13d", "5.14a", "5.14b", "5.14c", "5.14d", "5.15a", "5.15b", "5.15c", "5.15d")
|
||||||
|
CUSTOM -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Get all difficulty systems based on type
|
||||||
|
*/
|
||||||
|
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> = when (climbType) {
|
||||||
|
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }
|
||||||
|
ClimbType.ROPE -> entries.filter { it.isRopeSystem() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DifficultyGrade(
|
data class DifficultyGrade(
|
||||||
val system: DifficultySystem,
|
val system: DifficultySystem,
|
||||||
val grade: String,
|
val grade: String,
|
||||||
val numericValue: Int // For comparison and analytics
|
val numericValue: Int
|
||||||
)
|
) {
|
||||||
|
/**
|
||||||
|
* Compare this grade with another grade of the same system
|
||||||
|
* Returns negative if this grade is easier, positive if harder, 0 if equal
|
||||||
|
*/
|
||||||
|
fun compareTo(other: DifficultyGrade): Int {
|
||||||
|
if (system != other.system) return 0
|
||||||
|
|
||||||
|
return when (system) {
|
||||||
|
DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade)
|
||||||
|
DifficultySystem.FONT -> compareFontGrades(grade, other.grade)
|
||||||
|
DifficultySystem.YDS -> compareYDSGrades(grade, other.grade)
|
||||||
|
DifficultySystem.CUSTOM -> grade.compareTo(other.grade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
|
||||||
|
// Handle VB (easiest) specially
|
||||||
|
if (grade1 == "VB" && grade2 != "VB") return -1
|
||||||
|
if (grade2 == "VB" && grade1 != "VB") return 1
|
||||||
|
if (grade1 == "VB" && grade2 == "VB") return 0
|
||||||
|
|
||||||
|
// Extract numeric values for V grades
|
||||||
|
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
|
||||||
|
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
|
||||||
|
return num1.compareTo(num2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareFontGrades(grade1: String, grade2: String): Int {
|
||||||
|
// Simple string comparison for Font grades
|
||||||
|
return grade1.compareTo(grade2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareYDSGrades(grade1: String, grade2: String): Int {
|
||||||
|
// Simple string comparison for YDS grades
|
||||||
|
return grade1.compareTo(grade2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ data class Gym(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val location: String? = null,
|
val location: String? = null,
|
||||||
val supportedClimbTypes: List<ClimbType>,
|
val supportedClimbTypes: List<ClimbType>,
|
||||||
val difficultySystems: List<DifficultySystem>, // What systems this gym uses
|
val difficultySystems: List<DifficultySystem>,
|
||||||
val customDifficultyGrades: List<String> = emptyList(), // For gyms using colors/custom names
|
val customDifficultyGrades: List<String> = emptyList(),
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
val createdAt: String, // ISO string format for serialization
|
val createdAt: String,
|
||||||
val updatedAt: String
|
val updatedAt: String
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ data class Problem(
|
|||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val climbType: ClimbType,
|
val climbType: ClimbType,
|
||||||
val difficulty: DifficultyGrade,
|
val difficulty: DifficultyGrade,
|
||||||
val setter: String? = null, // Route setter name
|
val setter: String? = null,
|
||||||
val tags: List<String> = emptyList(), // e.g., "overhang", "slab", "crimpy"
|
val tags: List<String> = emptyList(),
|
||||||
val location: String? = null, // Wall section, area in gym
|
val location: String? = null,
|
||||||
val imagePaths: List<String> = emptyList(), // Local file paths to photos
|
val imagePaths: List<String> = emptyList(),
|
||||||
val isActive: Boolean = true, // Whether the problem is still up
|
val isActive: Boolean = true,
|
||||||
val dateSet: String? = null, // When the problem was set
|
val dateSet: String? = null,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val updatedAt: String
|
val updatedAt: String
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package com.atridad.openclimb.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ProblemProgress(
|
|
||||||
val problemId: String,
|
|
||||||
val totalAttempts: Int,
|
|
||||||
val successfulAttempts: Int,
|
|
||||||
val firstAttemptDate: String,
|
|
||||||
val lastAttemptDate: String,
|
|
||||||
val bestResult: AttemptResult,
|
|
||||||
val averageAttempts: Double,
|
|
||||||
val successRate: Double,
|
|
||||||
val personalBest: String? = null, // Highest hold or completion details
|
|
||||||
val notes: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class SessionSummary(
|
|
||||||
val sessionId: String,
|
|
||||||
val date: String,
|
|
||||||
val totalAttempts: Int,
|
|
||||||
val successfulAttempts: Int,
|
|
||||||
val uniqueProblems: Int,
|
|
||||||
val avgDifficulty: Double,
|
|
||||||
val maxDifficulty: DifficultyGrade,
|
|
||||||
val climbTypes: List<ClimbType>,
|
|
||||||
val duration: Long?, // in minutes
|
|
||||||
val notes: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ClimbingStats(
|
|
||||||
val totalSessions: Int,
|
|
||||||
val totalAttempts: Int,
|
|
||||||
val totalSuccesses: Int,
|
|
||||||
val overallSuccessRate: Double,
|
|
||||||
val uniqueProblemsAttempted: Int,
|
|
||||||
val uniqueProblemsCompleted: Int,
|
|
||||||
val averageSessionDuration: Double, // in minutes
|
|
||||||
val favoriteGym: String?,
|
|
||||||
val mostAttemptedDifficulty: DifficultyGrade?,
|
|
||||||
val currentStreak: Int, // consecutive sessions
|
|
||||||
val longestStreak: Int,
|
|
||||||
val firstClimbDate: String?,
|
|
||||||
val lastClimbDate: String?,
|
|
||||||
val improvementTrend: String? = null // "improving", "stable", "declining"
|
|
||||||
)
|
|
||||||
@@ -1,33 +1,26 @@
|
|||||||
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.ImageUtils
|
|
||||||
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) {
|
||||||
private val 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gym operations
|
// Gym operations
|
||||||
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
|
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
|
||||||
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
|
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
|
||||||
@@ -35,247 +28,324 @@ class ClimbRepository(
|
|||||||
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
|
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
|
||||||
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
|
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
|
||||||
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
|
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
|
||||||
|
|
||||||
// Problem operations
|
// Problem operations
|
||||||
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
|
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
|
||||||
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
|
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
|
||||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
|
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
|
||||||
fun getActiveProblems(): Flow<List<Problem>> = problemDao.getActiveProblems()
|
|
||||||
suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem)
|
suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem)
|
||||||
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
|
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
|
||||||
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
|
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
|
||||||
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
|
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
|
||||||
|
|
||||||
// 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>> =
|
||||||
fun getRecentSessions(limit: Int = 10): Flow<List<ClimbSession>> = sessionDao.getRecentSessions(limit)
|
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()
|
||||||
fun getSessionsByStatus(status: SessionStatus): Flow<List<ClimbSession>> = sessionDao.getSessionsByStatus(status)
|
|
||||||
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()
|
||||||
suspend fun getAttemptById(id: String): Attempt? = attemptDao.getAttemptById(id)
|
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = attemptDao.getAttemptsBySession(sessionId)
|
attemptDao.getAttemptsBySession(sessionId)
|
||||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = attemptDao.getAttemptsByProblem(problemId)
|
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
|
||||||
// JSON Export functionality
|
|
||||||
suspend fun exportAllDataToJson(directory: File? = null): File {
|
|
||||||
val exportDir = directory ?: File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "OpenClimb")
|
|
||||||
if (!exportDir.exists()) {
|
|
||||||
exportDir.mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
|
|
||||||
val exportFile = File(exportDir, "openclimb_export_$timestamp.json")
|
|
||||||
|
|
||||||
val allGyms = gymDao.getAllGyms().first()
|
|
||||||
val allProblems = problemDao.getAllProblems().first()
|
|
||||||
val allSessions = sessionDao.getAllSessions().first()
|
|
||||||
val allAttempts = attemptDao.getAllAttempts().first()
|
|
||||||
|
|
||||||
val exportData = ClimbDataExport(
|
|
||||||
exportedAt = LocalDateTime.now().toString(),
|
|
||||||
gyms = allGyms,
|
|
||||||
problems = allProblems,
|
|
||||||
sessions = allSessions,
|
|
||||||
attempts = allAttempts
|
|
||||||
)
|
|
||||||
|
|
||||||
val jsonString = json.encodeToString(exportData)
|
|
||||||
exportFile.writeText(jsonString)
|
|
||||||
|
|
||||||
return exportFile
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun exportAllDataToUri(context: Context, uri: android.net.Uri) {
|
|
||||||
val gyms = gymDao.getAllGyms().first()
|
|
||||||
val problems = problemDao.getAllProblems().first()
|
|
||||||
val sessions = sessionDao.getAllSessions().first()
|
|
||||||
val attempts = attemptDao.getAllAttempts().first()
|
|
||||||
|
|
||||||
val exportData = ClimbDataExport(
|
|
||||||
exportedAt = LocalDateTime.now().toString(),
|
|
||||||
gyms = gyms,
|
|
||||||
problems = problems,
|
|
||||||
sessions = sessions,
|
|
||||||
attempts = attempts
|
|
||||||
)
|
|
||||||
|
|
||||||
val jsonString = json.encodeToString(exportData)
|
|
||||||
|
|
||||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
|
||||||
outputStream.write(jsonString.toByteArray())
|
|
||||||
} ?: throw Exception("Could not open output stream")
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun importDataFromJson(file: File) {
|
|
||||||
try {
|
|
||||||
val jsonContent = file.readText()
|
|
||||||
val importData = json.decodeFromString<ClimbDataExport>(jsonContent)
|
|
||||||
|
|
||||||
// Import gyms (replace if exists due to primary key constraint)
|
|
||||||
importData.gyms.forEach { gym ->
|
|
||||||
try {
|
|
||||||
gymDao.insertGym(gym)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// If insertion fails due to primary key conflict, update instead
|
|
||||||
gymDao.updateGym(gym)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import problems
|
|
||||||
importData.problems.forEach { problem ->
|
|
||||||
try {
|
|
||||||
problemDao.insertProblem(problem)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
problemDao.updateProblem(problem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import sessions
|
|
||||||
importData.sessions.forEach { session ->
|
|
||||||
try {
|
|
||||||
sessionDao.insertSession(session)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
sessionDao.updateSession(session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import attempts
|
|
||||||
importData.attempts.forEach { attempt ->
|
|
||||||
try {
|
|
||||||
attemptDao.insertAttempt(attempt)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
attemptDao.updateAttempt(attempt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw Exception("Failed to import data: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZIP Export functionality with images
|
|
||||||
suspend fun exportAllDataToZip(directory: File? = null): File {
|
suspend fun exportAllDataToZip(directory: File? = null): File {
|
||||||
val allGyms = gymDao.getAllGyms().first()
|
try {
|
||||||
val allProblems = problemDao.getAllProblems().first()
|
// Collect all data with proper error handling
|
||||||
val allSessions = sessionDao.getAllSessions().first()
|
val allGyms = gymDao.getAllGyms().first()
|
||||||
val allAttempts = attemptDao.getAllAttempts().first()
|
val allProblems = problemDao.getAllProblems().first()
|
||||||
|
val allSessions = sessionDao.getAllSessions().first()
|
||||||
val exportData = ClimbDataExport(
|
val allAttempts = attemptDao.getAllAttempts().first()
|
||||||
exportedAt = LocalDateTime.now().toString(),
|
|
||||||
gyms = allGyms,
|
// Validate data integrity before export
|
||||||
problems = allProblems,
|
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
|
||||||
sessions = allSessions,
|
|
||||||
attempts = allAttempts
|
val exportData =
|
||||||
)
|
ClimbDataExport(
|
||||||
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
// Collect all referenced image paths
|
version = "1.0",
|
||||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
gyms = allGyms,
|
||||||
|
problems = allProblems,
|
||||||
return ZipExportImportUtils.createExportZip(
|
sessions = allSessions,
|
||||||
context = context,
|
attempts = allAttempts
|
||||||
exportData = exportData,
|
)
|
||||||
referencedImagePaths = referencedImagePaths,
|
|
||||||
directory = directory
|
// Collect all referenced image paths and validate they exist
|
||||||
)
|
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||||
|
val validImagePaths =
|
||||||
|
referencedImagePaths
|
||||||
|
.filter { imagePath ->
|
||||||
|
try {
|
||||||
|
val imageFile =
|
||||||
|
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||||
|
context,
|
||||||
|
imagePath
|
||||||
|
)
|
||||||
|
imageFile.exists() && imageFile.length() > 0
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
// Log any missing images for debugging
|
||||||
|
val missingImages = referencedImagePaths - validImagePaths
|
||||||
|
if (missingImages.isNotEmpty()) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"ClimbRepository",
|
||||||
|
"Some referenced images are missing: $missingImages"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipExportImportUtils.createExportZip(
|
||||||
|
context = context,
|
||||||
|
exportData = exportData,
|
||||||
|
referencedImagePaths = validImagePaths,
|
||||||
|
directory = directory
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Export failed: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
|
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||||
val gyms = gymDao.getAllGyms().first()
|
try {
|
||||||
val problems = problemDao.getAllProblems().first()
|
// Collect all data with proper error handling
|
||||||
val sessions = sessionDao.getAllSessions().first()
|
val allGyms = gymDao.getAllGyms().first()
|
||||||
val attempts = attemptDao.getAllAttempts().first()
|
val allProblems = problemDao.getAllProblems().first()
|
||||||
|
val allSessions = sessionDao.getAllSessions().first()
|
||||||
val exportData = ClimbDataExport(
|
val allAttempts = attemptDao.getAllAttempts().first()
|
||||||
exportedAt = LocalDateTime.now().toString(),
|
|
||||||
gyms = gyms,
|
// Validate data integrity before export
|
||||||
problems = problems,
|
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
|
||||||
sessions = sessions,
|
|
||||||
attempts = attempts
|
val exportData =
|
||||||
)
|
ClimbDataExport(
|
||||||
|
exportedAt = LocalDateTime.now().toString(),
|
||||||
// Collect all referenced image paths
|
version = "1.0",
|
||||||
val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet()
|
gyms = allGyms,
|
||||||
|
problems = allProblems,
|
||||||
ZipExportImportUtils.createExportZipToUri(
|
sessions = allSessions,
|
||||||
context = context,
|
attempts = allAttempts
|
||||||
uri = uri,
|
)
|
||||||
exportData = exportData,
|
|
||||||
referencedImagePaths = referencedImagePaths
|
// Collect all referenced image paths and validate they exist
|
||||||
)
|
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||||
|
val validImagePaths =
|
||||||
|
referencedImagePaths
|
||||||
|
.filter { imagePath ->
|
||||||
|
try {
|
||||||
|
val imageFile =
|
||||||
|
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||||
|
context,
|
||||||
|
imagePath
|
||||||
|
)
|
||||||
|
imageFile.exists() && imageFile.length() > 0
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
ZipExportImportUtils.createExportZipToUri(
|
||||||
|
context = context,
|
||||||
|
uri = uri,
|
||||||
|
exportData = exportData,
|
||||||
|
referencedImagePaths = validImagePaths
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Export failed: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun importDataFromZip(file: File) {
|
suspend fun importDataFromZip(file: File) {
|
||||||
try {
|
try {
|
||||||
|
// Validate the ZIP file
|
||||||
|
if (!file.exists() || file.length() == 0L) {
|
||||||
|
throw Exception("Invalid ZIP file: file is empty or doesn't exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate the ZIP contents
|
||||||
val importResult = ZipExportImportUtils.extractImportZip(context, file)
|
val importResult = ZipExportImportUtils.extractImportZip(context, file)
|
||||||
val importData = json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
|
|
||||||
|
// Validate JSON content
|
||||||
// Update problem image paths with the new imported paths
|
if (importResult.jsonContent.isBlank()) {
|
||||||
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths(
|
throw Exception("Invalid ZIP file: no data.json found or empty content")
|
||||||
importData.problems,
|
}
|
||||||
importResult.importedImagePaths
|
|
||||||
)
|
// Parse and validate the data structure
|
||||||
|
val importData =
|
||||||
// Import gyms (replace if exists due to primary key constraint)
|
try {
|
||||||
|
json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Invalid data format: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate data integrity
|
||||||
|
validateImportData(importData)
|
||||||
|
|
||||||
|
// Clear existing data to avoid conflicts
|
||||||
|
attemptDao.deleteAllAttempts()
|
||||||
|
sessionDao.deleteAllSessions()
|
||||||
|
problemDao.deleteAllProblems()
|
||||||
|
gymDao.deleteAllGyms()
|
||||||
|
|
||||||
|
// Import gyms first (problems depend on gyms)
|
||||||
importData.gyms.forEach { gym ->
|
importData.gyms.forEach { gym ->
|
||||||
try {
|
try {
|
||||||
gymDao.insertGym(gym)
|
gymDao.insertGym(gym)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// If insertion fails due to primary key conflict, update instead
|
throw Exception("Failed to import gym ${gym.name}: ${e.message}")
|
||||||
gymDao.updateGym(gym)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import problems with updated image paths
|
// Import problems with updated image paths
|
||||||
|
val updatedProblems =
|
||||||
|
ZipExportImportUtils.updateProblemImagePaths(
|
||||||
|
importData.problems,
|
||||||
|
importResult.importedImagePaths
|
||||||
|
)
|
||||||
|
|
||||||
updatedProblems.forEach { problem ->
|
updatedProblems.forEach { problem ->
|
||||||
try {
|
try {
|
||||||
problemDao.insertProblem(problem)
|
problemDao.insertProblem(problem)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
problemDao.updateProblem(problem)
|
throw Exception("Failed to import problem ${problem.name}: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import sessions
|
// Import sessions
|
||||||
importData.sessions.forEach { session ->
|
importData.sessions.forEach { session ->
|
||||||
try {
|
try {
|
||||||
sessionDao.insertSession(session)
|
sessionDao.insertSession(session)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
sessionDao.updateSession(session)
|
throw Exception("Failed to import session: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import attempts
|
// Import attempts last (depends on problems and sessions)
|
||||||
importData.attempts.forEach { attempt ->
|
importData.attempts.forEach { attempt ->
|
||||||
try {
|
try {
|
||||||
attemptDao.insertAttempt(attempt)
|
attemptDao.insertAttempt(attempt)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
attemptDao.updateAttempt(attempt)
|
throw Exception("Failed to import attempt: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw Exception("Failed to import data: ${e.message}")
|
throw Exception("Import failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateDataIntegrity(
|
||||||
|
gyms: List<Gym>,
|
||||||
|
problems: List<Problem>,
|
||||||
|
sessions: List<ClimbSession>,
|
||||||
|
attempts: List<Attempt>
|
||||||
|
) {
|
||||||
|
// Validate that all problems reference valid gyms
|
||||||
|
val gymIds = gyms.map { it.id }.toSet()
|
||||||
|
val invalidProblems = problems.filter { it.gymId !in gymIds }
|
||||||
|
if (invalidProblems.isNotEmpty()) {
|
||||||
|
throw Exception(
|
||||||
|
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that all sessions reference valid gyms
|
||||||
|
val invalidSessions = sessions.filter { it.gymId !in gymIds }
|
||||||
|
if (invalidSessions.isNotEmpty()) {
|
||||||
|
throw Exception(
|
||||||
|
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that all attempts reference valid problems and sessions
|
||||||
|
val problemIds = problems.map { it.id }.toSet()
|
||||||
|
val sessionIds = sessions.map { it.id }.toSet()
|
||||||
|
|
||||||
|
val invalidAttempts =
|
||||||
|
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
|
||||||
|
if (invalidAttempts.isNotEmpty()) {
|
||||||
|
throw Exception(
|
||||||
|
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateImportData(importData: ClimbDataExport) {
|
||||||
|
if (importData.gyms.isEmpty()) {
|
||||||
|
throw Exception("Import data is invalid: no gyms found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importData.version.isBlank()) {
|
||||||
|
throw Exception("Import data is invalid: no version information")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reasonable data sizes to prevent malicious imports
|
||||||
|
if (importData.gyms.size > 1000 ||
|
||||||
|
importData.problems.size > 10000 ||
|
||||||
|
importData.sessions.size > 10000 ||
|
||||||
|
importData.attempts.size > 100000
|
||||||
|
) {
|
||||||
|
throw Exception("Import data is too large: possible corruption or malicious file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resetAllData() {
|
||||||
|
try {
|
||||||
|
// Clear all data from database
|
||||||
|
attemptDao.deleteAllAttempts()
|
||||||
|
sessionDao.deleteAllSessions()
|
||||||
|
problemDao.deleteAllProblems()
|
||||||
|
gymDao.deleteAllGyms()
|
||||||
|
|
||||||
|
// Clear all images from storage
|
||||||
|
clearAllImages()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Reset failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearAllImages() {
|
||||||
|
try {
|
||||||
|
// Get the images directory
|
||||||
|
val imagesDir = File(context.filesDir, "images")
|
||||||
|
if (imagesDir.exists() && imagesDir.isDirectory) {
|
||||||
|
val deletedCount = imagesDir.listFiles()?.size ?: 0
|
||||||
|
imagesDir.deleteRecursively()
|
||||||
|
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
data class ClimbDataExport(
|
data class ClimbDataExport(
|
||||||
val exportedAt: String,
|
val exportedAt: String,
|
||||||
val gyms: List<Gym>,
|
val version: String = "1.0",
|
||||||
val problems: List<Problem>,
|
val gyms: List<Gym>,
|
||||||
val sessions: List<ClimbSession>,
|
val problems: List<Problem>,
|
||||||
val attempts: List<Attempt>
|
val sessions: List<ClimbSession>,
|
||||||
)
|
val attempts: List<Attempt>
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,36 +4,33 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
data class BottomNavigationItem(
|
data class BottomNavigationItem(val screen: Screen, val icon: ImageVector, val label: String)
|
||||||
val screen: Screen,
|
|
||||||
val icon: ImageVector,
|
|
||||||
val label: String
|
|
||||||
)
|
|
||||||
|
|
||||||
val bottomNavigationItems = listOf(
|
val bottomNavigationItems =
|
||||||
BottomNavigationItem(
|
listOf(
|
||||||
screen = Screen.Sessions,
|
BottomNavigationItem(
|
||||||
icon = Icons.Default.PlayArrow,
|
screen = Screen.Sessions,
|
||||||
label = "Sessions"
|
icon = Icons.Default.PlayArrow,
|
||||||
),
|
label = "Sessions"
|
||||||
BottomNavigationItem(
|
),
|
||||||
screen = Screen.Problems,
|
BottomNavigationItem(
|
||||||
icon = Icons.Default.Star,
|
screen = Screen.Problems,
|
||||||
label = "Problems"
|
icon = Icons.Default.Star,
|
||||||
),
|
label = "Problems"
|
||||||
BottomNavigationItem(
|
),
|
||||||
screen = Screen.Analytics,
|
BottomNavigationItem(
|
||||||
icon = Icons.Default.Info,
|
screen = Screen.Analytics,
|
||||||
label = "Analytics"
|
icon = Icons.Default.Info,
|
||||||
),
|
label = "Analytics"
|
||||||
BottomNavigationItem(
|
),
|
||||||
screen = Screen.Gyms,
|
BottomNavigationItem(
|
||||||
icon = Icons.Default.LocationOn,
|
screen = Screen.Gyms,
|
||||||
label = "Gyms"
|
icon = Icons.Default.LocationOn,
|
||||||
),
|
label = "Gyms"
|
||||||
BottomNavigationItem(
|
),
|
||||||
screen = Screen.Settings,
|
BottomNavigationItem(
|
||||||
icon = Icons.Default.Settings,
|
screen = Screen.Settings,
|
||||||
label = "Settings"
|
icon = Icons.Default.Settings,
|
||||||
)
|
label = "Settings"
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import android.app.PendingIntent
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.atridad.openclimb.MainActivity
|
import com.atridad.openclimb.MainActivity
|
||||||
@@ -16,15 +15,17 @@ import com.atridad.openclimb.data.repository.ClimbRepository
|
|||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
class SessionTrackingService : Service() {
|
class SessionTrackingService : Service() {
|
||||||
|
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private var notificationJob: Job? = null
|
private var notificationJob: Job? = null
|
||||||
|
private var monitoringJob: Job? = null
|
||||||
|
|
||||||
private lateinit var repository: ClimbRepository
|
private lateinit var repository: ClimbRepository
|
||||||
|
private lateinit var notificationManager: NotificationManager
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val NOTIFICATION_ID = 1001
|
const val NOTIFICATION_ID = 1001
|
||||||
@@ -40,9 +41,10 @@ class SessionTrackingService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createStopIntent(context: Context): Intent {
|
fun createStopIntent(context: Context, sessionId: String): Intent {
|
||||||
return Intent(context, SessionTrackingService::class.java).apply {
|
return Intent(context, SessionTrackingService::class.java).apply {
|
||||||
action = ACTION_STOP_SESSION
|
action = ACTION_STOP_SESSION
|
||||||
|
putExtra(EXTRA_SESSION_ID, sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +54,7 @@ class SessionTrackingService : Service() {
|
|||||||
|
|
||||||
val database = OpenClimbDatabase.getDatabase(this)
|
val database = OpenClimbDatabase.getDatabase(this)
|
||||||
repository = ClimbRepository(database, this)
|
repository = ClimbRepository(database, this)
|
||||||
|
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
@@ -65,87 +68,185 @@ class SessionTrackingService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ACTION_STOP_SESSION -> {
|
ACTION_STOP_SESSION -> {
|
||||||
stopSessionTracking()
|
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
|
||||||
|
serviceScope.launch {
|
||||||
|
try {
|
||||||
|
val targetSession = when {
|
||||||
|
sessionId != null -> repository.getSessionById(sessionId)
|
||||||
|
else -> repository.getActiveSession()
|
||||||
|
}
|
||||||
|
if (targetSession != null && targetSession.status == com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
|
||||||
|
val completed = with(com.atridad.openclimb.data.model.ClimbSession) { targetSession.complete() }
|
||||||
|
repository.updateSession(completed)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stopSessionTracking()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return START_STICKY
|
|
||||||
|
return START_REDELIVER_INTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun startSessionTracking(sessionId: String) {
|
private fun startSessionTracking(sessionId: String) {
|
||||||
notificationJob?.cancel()
|
notificationJob?.cancel()
|
||||||
|
monitoringJob?.cancel()
|
||||||
|
|
||||||
|
try {
|
||||||
|
createAndShowNotification(sessionId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
notificationJob = serviceScope.launch {
|
notificationJob = serviceScope.launch {
|
||||||
while (isActive) {
|
try {
|
||||||
updateNotification(sessionId)
|
if (!isNotificationActive()) {
|
||||||
delay(1000)
|
delay(1000L)
|
||||||
|
createAndShowNotification(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (isActive) {
|
||||||
|
delay(5000L)
|
||||||
|
updateNotification(sessionId)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
monitoringJob = serviceScope.launch {
|
||||||
|
try {
|
||||||
|
while (isActive) {
|
||||||
|
delay(10000L)
|
||||||
|
|
||||||
|
if (!isNotificationActive()) {
|
||||||
|
updateNotification(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val session = repository.getSessionById(sessionId)
|
||||||
|
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
|
||||||
|
stopSessionTracking()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopSessionTracking() {
|
private fun stopSessionTracking() {
|
||||||
notificationJob?.cancel()
|
notificationJob?.cancel()
|
||||||
|
monitoringJob?.cancel()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isNotificationActive(): Boolean {
|
||||||
|
return try {
|
||||||
|
val activeNotifications = notificationManager.activeNotifications
|
||||||
|
activeNotifications.any { it.id == NOTIFICATION_ID }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun updateNotification(sessionId: String) {
|
private suspend fun updateNotification(sessionId: String) {
|
||||||
try {
|
try {
|
||||||
val session = repository.getSessionById(sessionId)
|
createAndShowNotification(sessionId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
|
||||||
|
try {
|
||||||
|
delay(10000L)
|
||||||
|
createAndShowNotification(sessionId)
|
||||||
|
} catch (retryException: Exception) {
|
||||||
|
retryException.printStackTrace()
|
||||||
|
stopSessionTracking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAndShowNotification(sessionId: String) {
|
||||||
|
try {
|
||||||
|
val session = runBlocking {
|
||||||
|
repository.getSessionById(sessionId)
|
||||||
|
}
|
||||||
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
|
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
|
||||||
stopSessionTracking()
|
stopSessionTracking()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val gym = repository.getGymById(session.gymId)
|
val gym = runBlocking {
|
||||||
val attempts = repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
|
repository.getGymById(session.gymId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val attempts = runBlocking {
|
||||||
|
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
val duration = session.startTime?.let { startTime ->
|
val duration = session.startTime?.let { startTime ->
|
||||||
try {
|
try {
|
||||||
val start = LocalDateTime.parse(startTime)
|
val start = LocalDateTime.parse(startTime)
|
||||||
val now = LocalDateTime.now()
|
val now = LocalDateTime.now()
|
||||||
val minutes = ChronoUnit.MINUTES.between(start, now)
|
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
|
||||||
val hours = minutes / 60
|
val hours = totalSeconds / 3600
|
||||||
val remainingMinutes = minutes % 60
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
|
||||||
when {
|
when {
|
||||||
hours > 0 -> "${hours}h ${remainingMinutes}m"
|
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||||
remainingMinutes > 0 -> "${remainingMinutes}m"
|
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||||
else -> "< 1m"
|
else -> "${totalSeconds}s"
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
"Active"
|
"Active"
|
||||||
}
|
}
|
||||||
} ?: "Active"
|
} ?: "Active"
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("OpenClimb Session Active")
|
.setContentTitle("Climbing Session Active")
|
||||||
.setContentText("${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts")
|
.setContentText("${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts")
|
||||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
.setSmallIcon(R.drawable.ic_mountains)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
.setContentIntent(createOpenAppIntent())
|
.setContentIntent(createOpenAppIntent())
|
||||||
.addAction(
|
.addAction(
|
||||||
R.drawable.ic_launcher_foreground,
|
R.drawable.ic_mountains,
|
||||||
"Open Session",
|
"Open Session",
|
||||||
createOpenAppIntent()
|
createOpenAppIntent()
|
||||||
)
|
)
|
||||||
.addAction(
|
.addAction(
|
||||||
R.drawable.ic_launcher_foreground,
|
android.R.drawable.ic_menu_close_clear_cancel,
|
||||||
"End Session",
|
"End Session",
|
||||||
createStopIntent()
|
createStopPendingIntent(sessionId)
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Handle errors gracefully
|
e.printStackTrace()
|
||||||
stopSessionTracking()
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createOpenAppIntent(): PendingIntent {
|
private fun createOpenAppIntent(): PendingIntent {
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
action = "OPEN_SESSION"
|
||||||
}
|
}
|
||||||
return PendingIntent.getActivity(
|
return PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
@@ -155,8 +256,8 @@ class SessionTrackingService : Service() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createStopIntent(): PendingIntent {
|
private fun createStopPendingIntent(sessionId: String): PendingIntent {
|
||||||
val intent = createStopIntent(this)
|
val intent = createStopIntent(this, sessionId)
|
||||||
return PendingIntent.getService(
|
return PendingIntent.getService(
|
||||||
this,
|
this,
|
||||||
1,
|
1,
|
||||||
@@ -166,24 +267,26 @@ class SessionTrackingService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
val channel = NotificationChannel(
|
||||||
val channel = NotificationChannel(
|
CHANNEL_ID,
|
||||||
CHANNEL_ID,
|
"Session Tracking",
|
||||||
"Session Tracking",
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
NotificationManager.IMPORTANCE_LOW
|
).apply {
|
||||||
).apply {
|
description = "Shows active climbing session information"
|
||||||
description = "Shows active climbing session information"
|
setShowBadge(false)
|
||||||
setShowBadge(false)
|
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
|
||||||
}
|
enableLights(false)
|
||||||
|
enableVibration(false)
|
||||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
setSound(null, null)
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
notificationJob?.cancel()
|
notificationJob?.cancel()
|
||||||
|
monitoringJob?.cancel()
|
||||||
serviceScope.cancel()
|
serviceScope.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package com.atridad.openclimb.ui
|
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.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
@@ -18,210 +22,352 @@ import com.atridad.openclimb.data.database.OpenClimbDatabase
|
|||||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||||
import com.atridad.openclimb.navigation.Screen
|
import com.atridad.openclimb.navigation.Screen
|
||||||
import com.atridad.openclimb.navigation.bottomNavigationItems
|
import com.atridad.openclimb.navigation.bottomNavigationItems
|
||||||
|
import com.atridad.openclimb.ui.components.NotificationPermissionDialog
|
||||||
import com.atridad.openclimb.ui.screens.*
|
import com.atridad.openclimb.ui.screens.*
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
|
||||||
|
import com.atridad.openclimb.utils.AppShortcutManager
|
||||||
|
import com.atridad.openclimb.utils.NotificationPermissionUtils
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OpenClimbApp() {
|
fun OpenClimbApp(
|
||||||
|
shortcutAction: String? = null,
|
||||||
|
lastUsedGymId: String? = null,
|
||||||
|
onShortcutActionProcessed: () -> Unit = {}
|
||||||
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
|
||||||
val currentDestination = currentBackStackEntry?.destination?.route
|
var lastUsedGym by remember { mutableStateOf<com.atridad.openclimb.data.model.Gym?>(null) }
|
||||||
|
|
||||||
val database = remember { OpenClimbDatabase.getDatabase(context) }
|
val database = remember { OpenClimbDatabase.getDatabase(context) }
|
||||||
val repository = remember { ClimbRepository(database, context) }
|
val repository = remember { ClimbRepository(database, context) }
|
||||||
val viewModel: ClimbViewModel = viewModel(
|
val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository))
|
||||||
factory = ClimbViewModelFactory(repository)
|
|
||||||
)
|
// Notification permission state
|
||||||
|
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
|
||||||
// FAB configuration
|
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) }
|
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = { OpenClimbBottomNavigation(navController = navController) },
|
||||||
OpenClimbBottomNavigation(navController = navController)
|
floatingActionButton = {
|
||||||
},
|
fabConfig?.let { config ->
|
||||||
floatingActionButton = {
|
FloatingActionButton(
|
||||||
fabConfig?.let { config ->
|
onClick = config.onClick,
|
||||||
FloatingActionButton(
|
containerColor = MaterialTheme.colorScheme.primary
|
||||||
onClick = config.onClick,
|
) {
|
||||||
containerColor = MaterialTheme.colorScheme.primary
|
Icon(
|
||||||
) {
|
imageVector = config.icon,
|
||||||
Icon(
|
contentDescription = config.contentDescription
|
||||||
imageVector = config.icon,
|
)
|
||||||
contentDescription = config.contentDescription
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.Sessions,
|
startDestination = Screen.Sessions,
|
||||||
modifier = Modifier.padding(innerPadding)
|
modifier = Modifier.padding(innerPadding)
|
||||||
) {
|
) {
|
||||||
// Main screens
|
|
||||||
composable<Screen.Sessions> {
|
composable<Screen.Sessions> {
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
|
||||||
val activeSession by viewModel.activeSession.collectAsState()
|
|
||||||
LaunchedEffect(gyms, activeSession) {
|
LaunchedEffect(gyms, activeSession) {
|
||||||
fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
|
fabConfig =
|
||||||
FabConfig(
|
if (gyms.isNotEmpty() && activeSession == null) {
|
||||||
icon = Icons.Default.Add,
|
FabConfig(
|
||||||
contentDescription = "Start Session",
|
icon = Icons.Default.PlayArrow,
|
||||||
onClick = {
|
contentDescription = "Start Session",
|
||||||
if (gyms.size == 1) {
|
onClick = {
|
||||||
viewModel.startSession(context, gyms.first().id)
|
if (NotificationPermissionUtils
|
||||||
} else {
|
.shouldRequestNotificationPermission() &&
|
||||||
navController.navigate(Screen.AddEditSession())
|
!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
|
||||||
}
|
}
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
SessionsScreen(
|
SessionsScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateToSessionDetail = { sessionId ->
|
onNavigateToSessionDetail = { sessionId ->
|
||||||
navController.navigate(Screen.SessionDetail(sessionId))
|
navController.navigate(Screen.SessionDetail(sessionId))
|
||||||
},
|
}
|
||||||
onNavigateToAddSession = { gymId ->
|
|
||||||
navController.navigate(Screen.AddEditSession(gymId = gymId))
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Screen.Problems> {
|
composable<Screen.Problems> {
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
|
||||||
LaunchedEffect(gyms) {
|
LaunchedEffect(gyms) {
|
||||||
fabConfig = if (gyms.isNotEmpty()) {
|
fabConfig =
|
||||||
FabConfig(
|
if (gyms.isNotEmpty()) {
|
||||||
icon = Icons.Default.Add,
|
FabConfig(
|
||||||
contentDescription = "Add Problem",
|
icon = Icons.Default.Add,
|
||||||
onClick = {
|
contentDescription = "Add Problem",
|
||||||
navController.navigate(Screen.AddEditProblem())
|
onClick = {
|
||||||
|
navController.navigate(Screen.AddEditProblem())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ProblemsScreen(
|
ProblemsScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateToProblemDetail = { problemId ->
|
onNavigateToProblemDetail = { problemId ->
|
||||||
navController.navigate(Screen.ProblemDetail(problemId))
|
navController.navigate(Screen.ProblemDetail(problemId))
|
||||||
},
|
}
|
||||||
onNavigateToAddProblem = { gymId ->
|
|
||||||
navController.navigate(Screen.AddEditProblem(gymId = gymId))
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Screen.Analytics> {
|
composable<Screen.Analytics> {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) { fabConfig = null }
|
||||||
fabConfig = null // No FAB for analytics
|
|
||||||
}
|
|
||||||
AnalyticsScreen(viewModel = viewModel)
|
AnalyticsScreen(viewModel = viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Screen.Gyms> {
|
composable<Screen.Gyms> {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
fabConfig = FabConfig(
|
fabConfig =
|
||||||
icon = Icons.Default.Add,
|
FabConfig(
|
||||||
contentDescription = "Add Gym",
|
icon = Icons.Default.Add,
|
||||||
onClick = {
|
contentDescription = "Add Gym",
|
||||||
navController.navigate(Screen.AddEditGym())
|
onClick = { navController.navigate(Screen.AddEditGym()) }
|
||||||
}
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
GymsScreen(
|
GymsScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateToGymDetail = { gymId ->
|
onNavigateToGymDetail = { gymId ->
|
||||||
navController.navigate(Screen.GymDetail(gymId))
|
navController.navigate(Screen.GymDetail(gymId))
|
||||||
},
|
}
|
||||||
onNavigateToAddGym = {
|
|
||||||
navController.navigate(Screen.AddEditGym())
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Screen.Settings> {
|
composable<Screen.Settings> {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) { fabConfig = null }
|
||||||
fabConfig = null // No FAB for settings
|
|
||||||
}
|
|
||||||
SettingsScreen(viewModel = viewModel)
|
SettingsScreen(viewModel = viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail screens
|
|
||||||
composable<Screen.SessionDetail> { backStackEntry ->
|
composable<Screen.SessionDetail> { backStackEntry ->
|
||||||
val args = backStackEntry.toRoute<Screen.SessionDetail>()
|
val args = backStackEntry.toRoute<Screen.SessionDetail>()
|
||||||
|
LaunchedEffect(Unit) { fabConfig = null }
|
||||||
SessionDetailScreen(
|
SessionDetailScreen(
|
||||||
sessionId = args.sessionId,
|
sessionId = args.sessionId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToEdit = { sessionId ->
|
onNavigateToProblemDetail = { problemId ->
|
||||||
navController.navigate(Screen.AddEditSession(sessionId = sessionId))
|
navController.navigate(Screen.ProblemDetail(problemId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Screen.ProblemDetail> { backStackEntry ->
|
composable<Screen.ProblemDetail> { backStackEntry ->
|
||||||
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
|
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
|
||||||
|
LaunchedEffect(Unit) { fabConfig = null }
|
||||||
ProblemDetailScreen(
|
ProblemDetailScreen(
|
||||||
problemId = args.problemId,
|
problemId = args.problemId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToEdit = { problemId ->
|
onNavigateToEdit = { problemId ->
|
||||||
navController.navigate(Screen.AddEditProblem(problemId = problemId))
|
navController.navigate(Screen.AddEditProblem(problemId = problemId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Screen.GymDetail> { backStackEntry ->
|
composable<Screen.GymDetail> { backStackEntry ->
|
||||||
val args = backStackEntry.toRoute<Screen.GymDetail>()
|
val args = backStackEntry.toRoute<Screen.GymDetail>()
|
||||||
|
LaunchedEffect(Unit) { fabConfig = null }
|
||||||
GymDetailScreen(
|
GymDetailScreen(
|
||||||
gymId = args.gymId,
|
gymId = args.gymId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToEdit = { gymId ->
|
onNavigateToEdit = { gymId ->
|
||||||
navController.navigate(Screen.AddEditGym(gymId = 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 ->
|
composable<Screen.AddEditGym> { backStackEntry ->
|
||||||
val args = backStackEntry.toRoute<Screen.AddEditGym>()
|
val args = backStackEntry.toRoute<Screen.AddEditGym>()
|
||||||
|
LaunchedEffect(Unit) { fabConfig = null }
|
||||||
AddEditGymScreen(
|
AddEditGymScreen(
|
||||||
gymId = args.gymId,
|
gymId = args.gymId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() }
|
onNavigateBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Screen.AddEditProblem> { backStackEntry ->
|
composable<Screen.AddEditProblem> { backStackEntry ->
|
||||||
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
|
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
|
||||||
|
LaunchedEffect(Unit) { fabConfig = null }
|
||||||
AddEditProblemScreen(
|
AddEditProblemScreen(
|
||||||
problemId = args.problemId,
|
problemId = args.problemId,
|
||||||
gymId = args.gymId,
|
gymId = args.gymId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() }
|
onNavigateBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Screen.AddEditSession> { backStackEntry ->
|
composable<Screen.AddEditSession> { backStackEntry ->
|
||||||
val args = backStackEntry.toRoute<Screen.AddEditSession>()
|
val args = backStackEntry.toRoute<Screen.AddEditSession>()
|
||||||
|
LaunchedEffect(Unit) { fabConfig = null }
|
||||||
AddEditSessionScreen(
|
AddEditSessionScreen(
|
||||||
sessionId = args.sessionId,
|
sessionId = args.sessionId,
|
||||||
gymId = args.gymId,
|
gymId = args.gymId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() }
|
onNavigateBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification permission dialog
|
||||||
|
if (showNotificationPermissionDialog) {
|
||||||
|
NotificationPermissionDialog(
|
||||||
|
onDismiss = { showNotificationPermissionDialog = false },
|
||||||
|
onRequestPermission = {
|
||||||
|
permissionLauncher.launch(
|
||||||
|
NotificationPermissionUtils.getNotificationPermissionString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,46 +375,41 @@ fun OpenClimbApp() {
|
|||||||
fun OpenClimbBottomNavigation(navController: NavHostController) {
|
fun OpenClimbBottomNavigation(navController: NavHostController) {
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
bottomNavigationItems.forEach { item ->
|
bottomNavigationItems.forEach { item ->
|
||||||
val isSelected = when (item.screen) {
|
val isSelected =
|
||||||
is Screen.Sessions -> currentRoute?.contains("Session") == true
|
when (item.screen) {
|
||||||
is Screen.Problems -> currentRoute?.contains("Problem") == true
|
is Screen.Sessions -> currentRoute?.contains("Session") == true
|
||||||
is Screen.Gyms -> currentRoute?.contains("Gym") == true
|
is Screen.Problems -> currentRoute?.contains("Problem") == true
|
||||||
is Screen.Analytics -> currentRoute?.contains("Analytics") == true
|
is Screen.Gyms -> currentRoute?.contains("Gym") == true
|
||||||
is Screen.Settings -> currentRoute?.contains("Settings") == true
|
is Screen.Analytics -> currentRoute?.contains("Analytics") == true
|
||||||
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
|
is Screen.Settings -> currentRoute?.contains("Settings") == true
|
||||||
}
|
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
|
||||||
|
}
|
||||||
NavigationBarItem(
|
|
||||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
NavigationBarItem(
|
||||||
label = { Text(item.label) },
|
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||||
selected = isSelected,
|
label = { Text(item.label) },
|
||||||
onClick = {
|
selected = isSelected,
|
||||||
navController.navigate(item.screen) {
|
onClick = {
|
||||||
// Pop up to the start destination of the graph to
|
navController.navigate(item.screen) {
|
||||||
// avoid building up a large stack of destinations
|
// Clear the entire back stack and go to the selected tab's root screen
|
||||||
// on the back stack as users select items
|
popUpTo(0) { inclusive = true }
|
||||||
popUpTo(Screen.Sessions) {
|
// Avoid multiple copies of the same destination when
|
||||||
saveState = true
|
// reselecting the same item
|
||||||
}
|
launchSingleTop = true
|
||||||
// Avoid multiple copies of the same destination when
|
// Don't restore state - always start fresh when switching tabs
|
||||||
// reselecting the same item
|
restoreState = false
|
||||||
launchSingleTop = true
|
}
|
||||||
// Restore state when reselecting a previously selected item
|
|
||||||
restoreState = true
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class FabConfig(
|
data class FabConfig(
|
||||||
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
val contentDescription: String,
|
val contentDescription: String,
|
||||||
val onClick: () -> Unit
|
val onClick: () -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,17 @@ package com.atridad.openclimb.ui.components
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.rounded.Close
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.atridad.openclimb.data.model.ClimbSession
|
import com.atridad.openclimb.data.model.ClimbSession
|
||||||
import com.atridad.openclimb.data.model.Gym
|
import com.atridad.openclimb.data.model.Gym
|
||||||
|
import com.atridad.openclimb.ui.theme.CustomIcons
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
@@ -26,6 +25,16 @@ fun ActiveSessionBanner(
|
|||||||
onEndSession: () -> Unit
|
onEndSession: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (activeSession != null) {
|
if (activeSession != null) {
|
||||||
|
// Add a timer that updates every second for real-time duration counting
|
||||||
|
var currentTime by remember { mutableStateOf(LocalDateTime.now()) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(1000) // Update every second
|
||||||
|
currentTime = LocalDateTime.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -69,7 +78,7 @@ fun ActiveSessionBanner(
|
|||||||
)
|
)
|
||||||
|
|
||||||
activeSession.startTime?.let { startTime ->
|
activeSession.startTime?.let { startTime ->
|
||||||
val duration = calculateDuration(startTime)
|
val duration = calculateDuration(startTime, currentTime)
|
||||||
Text(
|
Text(
|
||||||
text = duration,
|
text = duration,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -86,7 +95,7 @@ fun ActiveSessionBanner(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Close,
|
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
|
||||||
contentDescription = "End session"
|
contentDescription = "End session"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -95,94 +104,20 @@ fun ActiveSessionBanner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String {
|
||||||
fun StartSessionButton(
|
|
||||||
gyms: List<Gym>,
|
|
||||||
onStartSession: (String) -> Unit
|
|
||||||
) {
|
|
||||||
var showGymSelection by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (gyms.isEmpty()) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "No gyms available",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Add a gym first to start a session",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button(
|
|
||||||
onClick = { showGymSelection = true },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text("Start Session")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showGymSelection) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showGymSelection = false },
|
|
||||||
title = { Text("Select Gym") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
gyms.forEach { gym ->
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
onStartSession(gym.id)
|
|
||||||
showGymSelection = false
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = gym.name,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { showGymSelection = false }) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateDuration(startTimeString: String): String {
|
|
||||||
return try {
|
return try {
|
||||||
val startTime = LocalDateTime.parse(startTimeString)
|
val startTime = LocalDateTime.parse(startTimeString)
|
||||||
val now = LocalDateTime.now()
|
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
|
||||||
val minutes = ChronoUnit.MINUTES.between(startTime, now)
|
val hours = totalSeconds / 3600
|
||||||
val hours = minutes / 60
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
val remainingMinutes = minutes % 60
|
val seconds = totalSeconds % 60
|
||||||
|
|
||||||
when {
|
when {
|
||||||
hours > 0 -> "${hours}h ${remainingMinutes}m"
|
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||||
remainingMinutes > 0 -> "${remainingMinutes}m"
|
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||||
else -> "< 1m"
|
else -> "${totalSeconds}s"
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
"Active"
|
"Active"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ fun FullscreenImageViewer(
|
|||||||
LaunchedEffect(pagerState.currentPage) {
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
thumbnailListState.animateScrollToItem(
|
thumbnailListState.animateScrollToItem(
|
||||||
index = pagerState.currentPage,
|
index = pagerState.currentPage,
|
||||||
scrollOffset = -200 // Center the item
|
scrollOffset = -200
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.atridad.openclimb.ui.components
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -20,7 +19,6 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.atridad.openclimb.utils.ImageUtils
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImagePicker(
|
fun ImagePicker(
|
||||||
@@ -41,7 +39,7 @@ fun ImagePicker(
|
|||||||
val remainingSlots = maxImages - currentCount
|
val remainingSlots = maxImages - currentCount
|
||||||
val urisToProcess = uris.take(remainingSlots)
|
val urisToProcess = uris.take(remainingSlots)
|
||||||
|
|
||||||
// Process each selected image
|
// Process images
|
||||||
val newImagePaths = mutableListOf<String>()
|
val newImagePaths = mutableListOf<String>()
|
||||||
urisToProcess.forEach { uri ->
|
urisToProcess.forEach { uri ->
|
||||||
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.*
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data point for the line chart
|
||||||
|
*/
|
||||||
|
data class ChartDataPoint(
|
||||||
|
val x: Float,
|
||||||
|
val y: Float,
|
||||||
|
val label: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for chart styling
|
||||||
|
*/
|
||||||
|
data class ChartStyle(
|
||||||
|
val lineColor: Color,
|
||||||
|
val fillColor: Color,
|
||||||
|
val lineWidth: Float = 3f,
|
||||||
|
val gridColor: Color,
|
||||||
|
val textColor: Color,
|
||||||
|
val backgroundColor: Color
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Line Chart with area fill below the line
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LineChart(
|
||||||
|
data: List<ChartDataPoint>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
style: ChartStyle = ChartStyle(
|
||||||
|
lineColor = MaterialTheme.colorScheme.primary,
|
||||||
|
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||||
|
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||||
|
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
backgroundColor = MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
showGrid: Boolean = true,
|
||||||
|
xAxisFormatter: (Float) -> String = { it.toString() },
|
||||||
|
yAxisFormatter: (Float) -> String = { it.toString() }
|
||||||
|
) {
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
if (data.isEmpty()) return@Canvas
|
||||||
|
|
||||||
|
val padding = with(density) { 32.dp.toPx() }
|
||||||
|
val chartWidth = size.width - padding * 2
|
||||||
|
val chartHeight = size.height - padding * 2
|
||||||
|
|
||||||
|
// Calculate data bounds
|
||||||
|
val dataMinY = data.minOf { it.y }
|
||||||
|
val dataMaxY = data.maxOf { it.y }
|
||||||
|
|
||||||
|
// Add some padding to Y-axis (10% above and below the data range)
|
||||||
|
val yPadding = if (dataMaxY == dataMinY) 1f else (dataMaxY - dataMinY) * 0.1f
|
||||||
|
val minY = dataMinY - yPadding
|
||||||
|
val maxY = dataMaxY + yPadding
|
||||||
|
|
||||||
|
val minX = data.minOf { it.x }
|
||||||
|
val maxX = data.maxOf { it.x }
|
||||||
|
|
||||||
|
val xRange = if (maxX - minX == 0f) 1f else maxX - minX // Minimum range of 1 for single points
|
||||||
|
val yRange = maxY - minY
|
||||||
|
|
||||||
|
// Ensure we have valid ranges
|
||||||
|
if (yRange == 0f) return@Canvas
|
||||||
|
|
||||||
|
// Convert data points to screen coordinates
|
||||||
|
val screenPoints = data.map { point ->
|
||||||
|
val x = padding + (point.x - minX) / xRange * chartWidth
|
||||||
|
val y = padding + chartHeight - (point.y - minY) / yRange * chartHeight
|
||||||
|
Offset(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw background
|
||||||
|
drawRect(
|
||||||
|
color = style.backgroundColor,
|
||||||
|
topLeft = Offset(padding, padding),
|
||||||
|
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw grid
|
||||||
|
if (showGrid) {
|
||||||
|
drawGrid(
|
||||||
|
padding = padding,
|
||||||
|
chartWidth = chartWidth,
|
||||||
|
chartHeight = chartHeight,
|
||||||
|
gridColor = style.gridColor,
|
||||||
|
minX = minX,
|
||||||
|
maxX = maxX,
|
||||||
|
minY = minY,
|
||||||
|
maxY = maxY,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
textColor = style.textColor,
|
||||||
|
xAxisFormatter = xAxisFormatter,
|
||||||
|
yAxisFormatter = yAxisFormatter,
|
||||||
|
actualDataPoints = data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw area fill
|
||||||
|
if (screenPoints.size > 1) {
|
||||||
|
drawAreaFill(
|
||||||
|
points = screenPoints,
|
||||||
|
padding = padding,
|
||||||
|
chartHeight = chartHeight,
|
||||||
|
fillColor = style.fillColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw line
|
||||||
|
if (screenPoints.size > 1) {
|
||||||
|
drawLine(
|
||||||
|
points = screenPoints,
|
||||||
|
lineColor = style.lineColor,
|
||||||
|
lineWidth = style.lineWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw data points - more pronounced
|
||||||
|
screenPoints.forEach { point ->
|
||||||
|
// Draw outer circle (larger)
|
||||||
|
drawCircle(
|
||||||
|
color = style.lineColor,
|
||||||
|
radius = 8f,
|
||||||
|
center = point
|
||||||
|
)
|
||||||
|
// Draw inner circle (white center)
|
||||||
|
drawCircle(
|
||||||
|
color = style.backgroundColor,
|
||||||
|
radius = 5f,
|
||||||
|
center = point
|
||||||
|
)
|
||||||
|
// Draw border for better visibility
|
||||||
|
drawCircle(
|
||||||
|
color = style.lineColor,
|
||||||
|
radius = 8f,
|
||||||
|
center = point,
|
||||||
|
style = Stroke(width = 2f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawGrid(
|
||||||
|
padding: Float,
|
||||||
|
chartWidth: Float,
|
||||||
|
chartHeight: Float,
|
||||||
|
gridColor: Color,
|
||||||
|
minX: Float,
|
||||||
|
maxX: Float,
|
||||||
|
minY: Float,
|
||||||
|
maxY: Float,
|
||||||
|
textMeasurer: TextMeasurer,
|
||||||
|
textColor: Color,
|
||||||
|
xAxisFormatter: (Float) -> String,
|
||||||
|
yAxisFormatter: (Float) -> String,
|
||||||
|
actualDataPoints: List<ChartDataPoint>
|
||||||
|
) {
|
||||||
|
val textStyle = TextStyle(
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw vertical grid lines (X-axis) - only at integer values for sessions
|
||||||
|
val xRange = maxX - minX
|
||||||
|
if (xRange > 0) {
|
||||||
|
val startX = kotlin.math.ceil(minX).toInt()
|
||||||
|
val endX = kotlin.math.floor(maxX).toInt()
|
||||||
|
|
||||||
|
for (sessionNum in startX..endX) {
|
||||||
|
val x = padding + (sessionNum.toFloat() - minX) / xRange * chartWidth
|
||||||
|
|
||||||
|
// Draw grid line
|
||||||
|
drawLine(
|
||||||
|
color = gridColor,
|
||||||
|
start = Offset(x, padding),
|
||||||
|
end = Offset(x, padding + chartHeight),
|
||||||
|
strokeWidth = 1.dp.toPx()
|
||||||
|
)
|
||||||
|
|
||||||
|
// X-axis labels removed per user request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw horizontal grid lines (Y-axis) - only at actual data point values
|
||||||
|
val yRange = maxY - minY
|
||||||
|
if (yRange > 0) {
|
||||||
|
// Get unique Y values from actual data points
|
||||||
|
val actualYValues = actualDataPoints.map { kotlin.math.round(it.y).toInt() }.toSet()
|
||||||
|
|
||||||
|
actualYValues.forEach { gradeValue ->
|
||||||
|
val y = padding + chartHeight - (gradeValue.toFloat() - minY) / yRange * chartHeight
|
||||||
|
|
||||||
|
// Only draw if within chart bounds
|
||||||
|
if (y >= padding && y <= padding + chartHeight) {
|
||||||
|
// Draw grid line
|
||||||
|
drawLine(
|
||||||
|
color = gridColor,
|
||||||
|
start = Offset(padding, y),
|
||||||
|
end = Offset(padding + chartWidth, y),
|
||||||
|
strokeWidth = 1.dp.toPx()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw label
|
||||||
|
val text = yAxisFormatter(gradeValue.toFloat())
|
||||||
|
val textSize = textMeasurer.measure(text, textStyle)
|
||||||
|
drawText(
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
text = text,
|
||||||
|
style = textStyle,
|
||||||
|
topLeft = Offset(
|
||||||
|
padding - textSize.size.width - 8.dp.toPx(),
|
||||||
|
y - textSize.size.height / 2f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawAreaFill(
|
||||||
|
points: List<Offset>,
|
||||||
|
padding: Float,
|
||||||
|
chartHeight: Float,
|
||||||
|
fillColor: Color
|
||||||
|
) {
|
||||||
|
val bottomY = padding + chartHeight // This represents the bottom of the chart area
|
||||||
|
|
||||||
|
val path = Path().apply {
|
||||||
|
// Start from bottom-left (at chart bottom level)
|
||||||
|
moveTo(points.first().x, bottomY)
|
||||||
|
|
||||||
|
// Draw to first point
|
||||||
|
lineTo(points.first().x, points.first().y)
|
||||||
|
|
||||||
|
// Draw line through all points
|
||||||
|
for (i in 1 until points.size) {
|
||||||
|
lineTo(points[i].x, points[i].y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the path by going to bottom-right (at chart bottom level) and back to start
|
||||||
|
lineTo(points.last().x, bottomY)
|
||||||
|
lineTo(points.first().x, bottomY)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPath(
|
||||||
|
path = path,
|
||||||
|
color = fillColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawLine(
|
||||||
|
points: List<Offset>,
|
||||||
|
lineColor: Color,
|
||||||
|
lineWidth: Float
|
||||||
|
) {
|
||||||
|
val path = Path().apply {
|
||||||
|
moveTo(points.first().x, points.first().y)
|
||||||
|
for (i in 1 until points.size) {
|
||||||
|
lineTo(points[i].x, points[i].y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPath(
|
||||||
|
path = path,
|
||||||
|
color = lineColor,
|
||||||
|
style = Stroke(
|
||||||
|
width = lineWidth,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
join = StrokeJoin.Round
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.atridad.openclimb.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotificationPermissionDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRequestPermission: () -> Unit
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
dismissOnBackPress = false,
|
||||||
|
dismissOnClickOutside = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Notifications,
|
||||||
|
contentDescription = "Notifications",
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Enable Notifications",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = MaterialTheme.typography.headlineSmall.fontWeight,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "OpenClimb needs notification permission to show your active climbing session. This helps you track your progress and ensures the session doesn't get interrupted.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Not Now")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onRequestPermission()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Enable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,9 @@ 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
|
||||||
import androidx.compose.foundation.selection.selectable
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -17,21 +16,13 @@ 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.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.platform.LocalContext
|
||||||
import com.atridad.openclimb.data.model.*
|
import com.atridad.openclimb.data.model.*
|
||||||
import com.atridad.openclimb.ui.components.ImagePicker
|
import com.atridad.openclimb.ui.components.ImagePicker
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
// Data class for attempt input
|
|
||||||
data class AttemptInput(
|
|
||||||
val problemId: String,
|
|
||||||
val result: AttemptResult,
|
|
||||||
val highestHold: String = "",
|
|
||||||
val notes: String = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AddEditGymScreen(
|
fun AddEditGymScreen(
|
||||||
@@ -47,32 +38,56 @@ fun AddEditGymScreen(
|
|||||||
|
|
||||||
val isEditing = gymId != null
|
val isEditing = gymId != null
|
||||||
|
|
||||||
|
// Calculate available difficulty systems based on selected climb types
|
||||||
|
val availableDifficultySystems = if (selectedClimbTypes.isEmpty()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
selectedClimbTypes.flatMap { climbType ->
|
||||||
|
DifficultySystem.getSystemsForClimbType(climbType)
|
||||||
|
}.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset selected difficulty systems when available systems change
|
||||||
|
LaunchedEffect(availableDifficultySystems) {
|
||||||
|
selectedDifficultySystems = selectedDifficultySystems.filter { it in availableDifficultySystems }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing gym data for editing
|
||||||
|
LaunchedEffect(gymId) {
|
||||||
|
if (gymId != null) {
|
||||||
|
val existingGym = viewModel.getGymById(gymId).first()
|
||||||
|
existingGym?.let { gym ->
|
||||||
|
name = gym.name
|
||||||
|
location = gym.location ?: ""
|
||||||
|
notes = gym.notes ?: ""
|
||||||
|
selectedClimbTypes = gym.supportedClimbTypes.toSet()
|
||||||
|
selectedDifficultySystems = gym.difficultySystems.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(if (isEditing) "Edit Gym" else "Add Gym") },
|
title = { Text(if (isEditing) "Edit Gym" else "Add Gym") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val gym = if (isEditing) {
|
val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
|
||||||
Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
|
|
||||||
} else {
|
|
||||||
Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
viewModel.updateGym(gym)
|
viewModel.updateGym(gym.copy(id = gymId!!))
|
||||||
} else {
|
} else {
|
||||||
viewModel.addGym(gym)
|
viewModel.addGym(gym)
|
||||||
}
|
}
|
||||||
onNavigateBack()
|
onNavigateBack()
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty()
|
enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty() && selectedDifficultySystems.isNotEmpty()
|
||||||
) {
|
) {
|
||||||
Text("Save")
|
Text("Save")
|
||||||
}
|
}
|
||||||
@@ -142,7 +157,7 @@ fun AddEditGymScreen(
|
|||||||
onCheckedChange = null
|
onCheckedChange = null
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() })
|
Text(climbType.getDisplayName())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,29 +178,38 @@ fun AddEditGymScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
DifficultySystem.entries.forEach { system ->
|
if (selectedClimbTypes.isEmpty()) {
|
||||||
Row(
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
text = "Select climb types first to see available difficulty systems",
|
||||||
modifier = Modifier
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
.fillMaxWidth()
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
.selectable(
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
selected = system in selectedDifficultySystems,
|
)
|
||||||
onClick = {
|
} else {
|
||||||
selectedDifficultySystems = if (system in selectedDifficultySystems) {
|
availableDifficultySystems.forEach { system ->
|
||||||
selectedDifficultySystems - system
|
Row(
|
||||||
} else {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
selectedDifficultySystems + system
|
modifier = Modifier
|
||||||
}
|
.fillMaxWidth()
|
||||||
},
|
.selectable(
|
||||||
role = Role.Checkbox
|
selected = system in selectedDifficultySystems,
|
||||||
|
onClick = {
|
||||||
|
selectedDifficultySystems = if (system in selectedDifficultySystems) {
|
||||||
|
selectedDifficultySystems - system
|
||||||
|
} else {
|
||||||
|
selectedDifficultySystems + system
|
||||||
|
}
|
||||||
|
},
|
||||||
|
role = Role.Checkbox
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = system in selectedDifficultySystems,
|
||||||
|
onCheckedChange = null
|
||||||
)
|
)
|
||||||
) {
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Checkbox(
|
Text(system.getDisplayName())
|
||||||
checked = system in selectedDifficultySystems,
|
}
|
||||||
onCheckedChange = null
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(system.name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,6 +268,7 @@ fun AddEditProblemScreen(
|
|||||||
notes = p.notes ?: ""
|
notes = p.notes ?: ""
|
||||||
isActive = p.isActive
|
isActive = p.isActive
|
||||||
imagePaths = p.imagePaths
|
imagePaths = p.imagePaths
|
||||||
|
selectedGym = gyms.find { it.id == p.gymId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,8 +279,39 @@ fun AddEditProblemScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val availableDifficultySystems = selectedGym?.difficultySystems ?: DifficultySystem.entries.toList()
|
|
||||||
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
|
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
|
||||||
|
val availableDifficultySystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
|
||||||
|
selectedGym?.difficultySystems?.contains(system) != false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select climb type if there's only one available
|
||||||
|
LaunchedEffect(availableClimbTypes) {
|
||||||
|
if (availableClimbTypes.size == 1 && selectedClimbType != availableClimbTypes.first()) {
|
||||||
|
selectedClimbType = availableClimbTypes.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select or reset difficulty system based on climb type
|
||||||
|
LaunchedEffect(selectedClimbType, availableDifficultySystems) {
|
||||||
|
when {
|
||||||
|
// If current system is not compatible, select the first available one
|
||||||
|
selectedDifficultySystem !in availableDifficultySystems -> {
|
||||||
|
selectedDifficultySystem = availableDifficultySystems.firstOrNull() ?: DifficultySystem.CUSTOM
|
||||||
|
}
|
||||||
|
// If there's only one available system and nothing is selected, auto-select it
|
||||||
|
availableDifficultySystems.size == 1 && selectedDifficultySystem != availableDifficultySystems.first() -> {
|
||||||
|
selectedDifficultySystem = availableDifficultySystems.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset grade when difficulty system changes (unless it's a valid grade for the new system)
|
||||||
|
LaunchedEffect(selectedDifficultySystem) {
|
||||||
|
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
||||||
|
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
|
||||||
|
difficultyGrade = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -263,7 +319,7 @@ fun AddEditProblemScreen(
|
|||||||
title = { Text(if (isEditing) "Edit Problem" else "Add Problem") },
|
title = { Text(if (isEditing) "Edit Problem" else "Add Problem") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
@@ -437,7 +493,7 @@ fun AddEditProblemScreen(
|
|||||||
availableClimbTypes.forEach { climbType ->
|
availableClimbTypes.forEach { climbType ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedClimbType = climbType },
|
onClick = { selectedClimbType = climbType },
|
||||||
label = { Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) },
|
label = { Text(climbType.getDisplayName()) },
|
||||||
selected = selectedClimbType == climbType
|
selected = selectedClimbType == climbType
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -476,7 +532,7 @@ fun AddEditProblemScreen(
|
|||||||
items(availableDifficultySystems) { system ->
|
items(availableDifficultySystems) { system ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { selectedDifficultySystem = system },
|
onClick = { selectedDifficultySystem = system },
|
||||||
label = { Text(system.name) },
|
label = { Text(system.getDisplayName()) },
|
||||||
selected = selectedDifficultySystem == system
|
selected = selectedDifficultySystem == system
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -484,23 +540,58 @@ fun AddEditProblemScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
|
||||||
value = difficultyGrade,
|
OutlinedTextField(
|
||||||
onValueChange = { difficultyGrade = it },
|
value = difficultyGrade,
|
||||||
label = { Text("Grade *") },
|
onValueChange = { newValue ->
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// Only allow integers for custom scales
|
||||||
singleLine = true,
|
if (newValue.isEmpty() || newValue.all { it.isDigit() }) {
|
||||||
placeholder = {
|
difficultyGrade = newValue
|
||||||
Text(when (selectedDifficultySystem) {
|
}
|
||||||
DifficultySystem.V_SCALE -> "e.g., V0, V4, V10"
|
},
|
||||||
DifficultySystem.FONT -> "e.g., 3, 6A+, 8B"
|
label = { Text("Grade *") },
|
||||||
DifficultySystem.YDS -> "e.g., 5.8, 5.12a"
|
modifier = Modifier.fillMaxWidth(),
|
||||||
DifficultySystem.FRENCH -> "e.g., 6a, 7c+"
|
singleLine = true,
|
||||||
DifficultySystem.CUSTOM -> "Custom grade"
|
placeholder = { Text("Enter numeric grade (e.g. 5, 10, 15)") },
|
||||||
else -> "Enter grade"
|
supportingText = { Text("Custom grades must be whole numbers") },
|
||||||
})
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = !expanded },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = difficultyGrade,
|
||||||
|
onValueChange = { },
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Grade *") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, enabled = true)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
availableGrades.forEach { grade ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(grade) },
|
||||||
|
onClick = {
|
||||||
|
difficultyGrade = grade
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,7 +643,7 @@ fun AddEditProblemScreen(
|
|||||||
label = { Text("Tags (Optional)") },
|
label = { Text("Tags (Optional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
placeholder = { Text("e.g., overhang, crimpy, dynamic (comma-separated)") }
|
placeholder = { Text("e.g., crimpy, dynamic (comma-separated)") }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -605,17 +696,26 @@ fun AddEditSessionScreen(
|
|||||||
) {
|
) {
|
||||||
val isEditing = sessionId != null
|
val isEditing = sessionId != null
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
val problems by viewModel.problems.collectAsState()
|
val context = LocalContext.current
|
||||||
|
|
||||||
// Session form state
|
// Session form state
|
||||||
var selectedGym by remember { mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } }) }
|
var selectedGym by remember { mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } }) }
|
||||||
var sessionDate by remember { mutableStateOf(LocalDateTime.now().toLocalDate().toString()) }
|
var sessionDate by remember { mutableStateOf(LocalDateTime.now().toLocalDate().toString()) }
|
||||||
var duration by remember { mutableStateOf("") }
|
var duration by remember { mutableStateOf("") }
|
||||||
var sessionNotes by remember { mutableStateOf("") }
|
var sessionNotes by remember { mutableStateOf("") }
|
||||||
|
|
||||||
// Attempt tracking state
|
// Load existing session data for editing
|
||||||
var attempts by remember { mutableStateOf(listOf<AttemptInput>()) }
|
LaunchedEffect(sessionId) {
|
||||||
var showAddAttemptDialog by remember { mutableStateOf(false) }
|
if (sessionId != null) {
|
||||||
|
val existingSession = viewModel.getSessionById(sessionId).first()
|
||||||
|
existingSession?.let { session ->
|
||||||
|
selectedGym = gyms.find { it.id == session.gymId }
|
||||||
|
sessionDate = session.date.split("T")[0] // Extract date part
|
||||||
|
duration = session.duration?.toString() ?: ""
|
||||||
|
sessionNotes = session.notes ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(gymId, gyms) {
|
LaunchedEffect(gymId, gyms) {
|
||||||
if (gymId != null && selectedGym == null) {
|
if (gymId != null && selectedGym == null) {
|
||||||
@@ -629,33 +729,21 @@ fun AddEditSessionScreen(
|
|||||||
title = { Text(if (isEditing) "Edit Session" else "Add Session") },
|
title = { Text(if (isEditing) "Edit Session" else "Add Session") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedGym?.let { gym ->
|
selectedGym?.let { gym ->
|
||||||
val session = ClimbSession.create(
|
|
||||||
gymId = gym.id,
|
|
||||||
notes = sessionNotes.ifBlank { null }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
|
val session = ClimbSession.create(
|
||||||
|
gymId = gym.id,
|
||||||
|
notes = sessionNotes.ifBlank { null }
|
||||||
|
)
|
||||||
viewModel.updateSession(session.copy(id = sessionId!!))
|
viewModel.updateSession(session.copy(id = sessionId!!))
|
||||||
} else {
|
} else {
|
||||||
viewModel.addSession(session)
|
viewModel.startSession(context, gym.id, sessionNotes.ifBlank { null })
|
||||||
|
|
||||||
attempts.forEach { attemptInput ->
|
|
||||||
val attempt = Attempt.create(
|
|
||||||
sessionId = session.id,
|
|
||||||
problemId = attemptInput.problemId,
|
|
||||||
result = attemptInput.result,
|
|
||||||
highestHold = attemptInput.highestHold.ifBlank { null },
|
|
||||||
notes = attemptInput.notes.ifBlank { null }
|
|
||||||
)
|
|
||||||
viewModel.addAttempt(attempt)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
onNavigateBack()
|
onNavigateBack()
|
||||||
}
|
}
|
||||||
@@ -666,15 +754,6 @@ fun AddEditSessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
if (selectedGym != null) {
|
|
||||||
FloatingActionButton(
|
|
||||||
onClick = { showAddAttemptDialog = true }
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add Attempt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -770,286 +849,9 @@ fun AddEditSessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempts Section
|
|
||||||
item {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Attempts (${attempts.size})",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempts.isEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "No attempts recorded yet. Add an attempt to track your progress.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempts List
|
|
||||||
items(attempts.size) { index ->
|
|
||||||
val attempt = attempts[index]
|
|
||||||
val problem = problems.find { it.id == attempt.problemId }
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.Top
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = problem?.name ?: "Unknown Problem",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
problem?.difficulty?.let { difficulty ->
|
|
||||||
Text(
|
|
||||||
text = "${difficulty.system.name}: ${difficulty.grade}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Result: ${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = when (attempt.result) {
|
|
||||||
AttemptResult.SUCCESS, AttemptResult.FLASH,
|
|
||||||
AttemptResult.REDPOINT, AttemptResult.ONSIGHT -> MaterialTheme.colorScheme.primary
|
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (attempt.highestHold.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
text = "Highest hold: ${attempt.highestHold}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt.notes.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
text = attempt.notes,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
attempts = attempts.toMutableList().apply { removeAt(index) }
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Delete, contentDescription = "Remove attempt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAddAttemptDialog && selectedGym != null) {
|
|
||||||
AddAttemptDialog(
|
|
||||||
problems = problems.filter { it.gymId == selectedGym!!.id && it.isActive },
|
|
||||||
onDismiss = { showAddAttemptDialog = false },
|
|
||||||
onAddAttempt = { attemptInput ->
|
|
||||||
attempts = attempts + attemptInput
|
|
||||||
showAddAttemptDialog = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun AddAttemptDialog(
|
|
||||||
problems: List<Problem>,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onAddAttempt: (AttemptInput) -> Unit
|
|
||||||
) {
|
|
||||||
var selectedProblem by remember { mutableStateOf<Problem?>(null) }
|
|
||||||
var selectedResult by remember { mutableStateOf(AttemptResult.FALL) }
|
|
||||||
var highestHold by remember { mutableStateOf("") }
|
|
||||||
var notes by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Add Attempt",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
// Problem Selection
|
|
||||||
Text(
|
|
||||||
text = "Problem",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
|
|
||||||
if (problems.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "No active problems in this gym. Add some problems first.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.height(120.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
items(problems) { problem ->
|
|
||||||
Card(
|
|
||||||
onClick = { selectedProblem = problem },
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = if (selectedProblem?.id == problem.id)
|
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surface
|
|
||||||
),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(12.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = problem.name ?: "Unnamed Problem",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result Selection
|
|
||||||
Text(
|
|
||||||
text = "Result",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(modifier = Modifier.selectableGroup()) {
|
|
||||||
AttemptResult.entries.forEach { result ->
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.selectable(
|
|
||||||
selected = selectedResult == result,
|
|
||||||
onClick = { selectedResult = result },
|
|
||||||
role = Role.RadioButton
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = selectedResult == result,
|
|
||||||
onClick = null
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = result.name.lowercase().replaceFirstChar { it.uppercase() },
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highest Hold
|
|
||||||
OutlinedTextField(
|
|
||||||
value = highestHold,
|
|
||||||
onValueChange = { highestHold = it },
|
|
||||||
label = { Text("Highest Hold (Optional)") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
placeholder = { Text("e.g., 'jugs near the top', 'crux move'") }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Notes
|
|
||||||
OutlinedTextField(
|
|
||||||
value = notes,
|
|
||||||
onValueChange = { notes = it },
|
|
||||||
label = { Text("Notes (Optional)") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
minLines = 2,
|
|
||||||
placeholder = { Text("e.g., 'need to work on heel hooks', 'pumped out'") }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Buttons
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
TextButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
selectedProblem?.let { problem ->
|
|
||||||
onAddAttempt(
|
|
||||||
AttemptInput(
|
|
||||||
problemId = problem.id,
|
|
||||||
result = selectedResult,
|
|
||||||
highestHold = highestHold,
|
|
||||||
notes = notes
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = selectedProblem != null,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Text("Add Attempt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.atridad.openclimb.R
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
|
import com.atridad.openclimb.data.model.ClimbType
|
||||||
|
import com.atridad.openclimb.data.model.DifficultySystem
|
||||||
|
import com.atridad.openclimb.ui.components.ChartDataPoint
|
||||||
|
import com.atridad.openclimb.ui.components.LineChart
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AnalyticsScreen(
|
fun AnalyticsScreen(
|
||||||
@@ -26,11 +32,23 @@ fun AnalyticsScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Row(
|
||||||
text = "Analytics",
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontWeight = FontWeight.Bold
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
)
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
|
contentDescription = "OpenClimb Logo",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Analytics",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overall Stats
|
// Overall Stats
|
||||||
@@ -43,20 +61,10 @@ fun AnalyticsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success Rate
|
// Progress Chart
|
||||||
item {
|
item {
|
||||||
val successfulAttempts = attempts.count {
|
val progressData = calculateProgressOverTime(sessions, problems, attempts)
|
||||||
it.result.name in listOf("SUCCESS", "FLASH", "REDPOINT", "ONSIGHT")
|
ProgressChartCard(progressData = progressData, problems = problems)
|
||||||
}
|
|
||||||
val successRate = if (attempts.isNotEmpty()) {
|
|
||||||
(successfulAttempts.toDouble() / attempts.size * 100).toInt()
|
|
||||||
} else 0
|
|
||||||
|
|
||||||
SuccessRateCard(
|
|
||||||
successRate = successRate,
|
|
||||||
successfulAttempts = successfulAttempts,
|
|
||||||
totalAttempts = attempts.size
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favorite Gym
|
// Favorite Gym
|
||||||
@@ -79,41 +87,6 @@ fun AnalyticsScreen(
|
|||||||
val recentSessions = sessions.take(5)
|
val recentSessions = sessions.take(5)
|
||||||
RecentActivityCard(recentSessions = recentSessions.size)
|
RecentActivityCard(recentSessions = recentSessions.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
item {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Progress Charts",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Detailed charts and analytics coming soon!",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "📊",
|
|
||||||
style = MaterialTheme.typography.displaySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,14 +126,22 @@ fun OverallStatsCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SuccessRateCard(
|
fun ProgressChartCard(
|
||||||
successRate: Int,
|
progressData: List<ProgressDataPoint>,
|
||||||
successfulAttempts: Int,
|
problems: List<com.atridad.openclimb.data.model.Problem>,
|
||||||
totalAttempts: Int
|
|
||||||
) {
|
) {
|
||||||
|
// Find all grading systems that have been used in the progress data
|
||||||
|
val usedSystems = remember(progressData) {
|
||||||
|
progressData.map { it.difficultySystem }.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedSystem by remember(usedSystems) {
|
||||||
|
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
|
||||||
|
}
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
@@ -169,38 +150,120 @@ fun SuccessRateCard(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
text = "Success Rate",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "$successRate%",
|
text = "Progress Over Time",
|
||||||
style = MaterialTheme.typography.displaySmall,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
// Scale selector dropdown
|
||||||
Text(
|
if (usedSystems.size > 1) {
|
||||||
text = "$successfulAttempts successful",
|
ExposedDropdownMenuBox(
|
||||||
style = MaterialTheme.typography.bodyMedium
|
expanded = expanded,
|
||||||
)
|
onExpandedChange = { expanded = !expanded }
|
||||||
Text(
|
) {
|
||||||
text = "out of $totalAttempts attempts",
|
OutlinedTextField(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
value = when (selectedSystem) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
DifficultySystem.V_SCALE -> "V-Scale"
|
||||||
)
|
DifficultySystem.FONT -> "Font"
|
||||||
|
DifficultySystem.YDS -> "YDS"
|
||||||
|
DifficultySystem.CUSTOM -> "Custom"
|
||||||
|
},
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true)
|
||||||
|
.width(120.dp),
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
usedSystems.forEach { system ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(when (system) {
|
||||||
|
DifficultySystem.V_SCALE -> "V-Scale"
|
||||||
|
DifficultySystem.FONT -> "Font"
|
||||||
|
DifficultySystem.YDS -> "YDS"
|
||||||
|
DifficultySystem.CUSTOM -> "Custom"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selectedSystem = system
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Filter progress data by selected scale
|
||||||
|
val filteredProgressData = remember(progressData, selectedSystem) {
|
||||||
|
progressData.filter { it.difficultySystem == selectedSystem }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredProgressData.isNotEmpty()) {
|
||||||
|
val chartData = remember(filteredProgressData) {
|
||||||
|
// Convert progress data to chart data points ordered by session
|
||||||
|
filteredProgressData
|
||||||
|
.sortedBy { it.date }
|
||||||
|
.mapIndexed { index, p ->
|
||||||
|
ChartDataPoint(
|
||||||
|
x = (index + 1).toFloat(),
|
||||||
|
y = p.maxGradeNumeric.toFloat(),
|
||||||
|
label = "Session ${index + 1}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LineChart(
|
||||||
|
data = chartData,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(220.dp),
|
||||||
|
xAxisFormatter = { value ->
|
||||||
|
"S${value.toInt()}" // S1, S2, S3, etc.
|
||||||
|
},
|
||||||
|
yAxisFormatter = { value ->
|
||||||
|
numericToGrade(selectedSystem, value.toInt())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "X: session number, Y: max ${when(selectedSystem) {
|
||||||
|
DifficultySystem.V_SCALE -> "V-grade"
|
||||||
|
DifficultySystem.FONT -> "Font grade"
|
||||||
|
DifficultySystem.YDS -> "YDS grade"
|
||||||
|
DifficultySystem.CUSTOM -> "custom grade"
|
||||||
|
}} achieved",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "No progress data available for ${when(selectedSystem) {
|
||||||
|
DifficultySystem.V_SCALE -> "V-Scale"
|
||||||
|
DifficultySystem.FONT -> "Font"
|
||||||
|
DifficultySystem.YDS -> "YDS"
|
||||||
|
DifficultySystem.CUSTOM -> "Custom"
|
||||||
|
}} system",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,3 +337,207 @@ fun RecentActivityCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class ProgressDataPoint(
|
||||||
|
val date: String,
|
||||||
|
val maxGrade: String,
|
||||||
|
val maxGradeNumeric: Int,
|
||||||
|
val climbType: ClimbType,
|
||||||
|
val difficultySystem: DifficultySystem
|
||||||
|
)
|
||||||
|
|
||||||
|
fun calculateProgressOverTime(
|
||||||
|
sessions: List<com.atridad.openclimb.data.model.ClimbSession>,
|
||||||
|
problems: List<com.atridad.openclimb.data.model.Problem>,
|
||||||
|
attempts: List<com.atridad.openclimb.data.model.Attempt>
|
||||||
|
): List<ProgressDataPoint> {
|
||||||
|
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val sessionProgress = sessions.mapNotNull { session ->
|
||||||
|
val sessionAttempts = attempts.filter { it.sessionId == session.id }
|
||||||
|
if (sessionAttempts.isEmpty()) return@mapNotNull null
|
||||||
|
val attemptedProblemIds = sessionAttempts.map { it.problemId }.distinct()
|
||||||
|
val attemptedProblems = problems.filter { it.id in attemptedProblemIds }
|
||||||
|
if (attemptedProblems.isEmpty()) return@mapNotNull null
|
||||||
|
val highestGradeProblem = attemptedProblems.maxByOrNull { problem ->
|
||||||
|
gradeToNumeric(problem.difficulty.system, problem.difficulty.grade)
|
||||||
|
}
|
||||||
|
if (highestGradeProblem != null) {
|
||||||
|
ProgressDataPoint(
|
||||||
|
date = session.date,
|
||||||
|
maxGrade = highestGradeProblem.difficulty.grade,
|
||||||
|
maxGradeNumeric = gradeToNumeric(highestGradeProblem.difficulty.system, highestGradeProblem.difficulty.grade),
|
||||||
|
climbType = highestGradeProblem.climbType,
|
||||||
|
difficultySystem = highestGradeProblem.difficulty.system
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
return sessionProgress.sortedBy { it.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun gradeToNumeric(system: DifficultySystem, grade: String): Int {
|
||||||
|
return when (system) {
|
||||||
|
DifficultySystem.V_SCALE -> {
|
||||||
|
when (grade) {
|
||||||
|
"VB" -> 0
|
||||||
|
else -> grade.removePrefix("V").toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DifficultySystem.FONT -> {
|
||||||
|
when (grade) {
|
||||||
|
"3" -> 3
|
||||||
|
"4A" -> 4
|
||||||
|
"4B" -> 5
|
||||||
|
"4C" -> 6
|
||||||
|
"5A" -> 7
|
||||||
|
"5B" -> 8
|
||||||
|
"5C" -> 9
|
||||||
|
"6A" -> 10
|
||||||
|
"6A+" -> 11
|
||||||
|
"6B" -> 12
|
||||||
|
"6B+" -> 13
|
||||||
|
"6C" -> 14
|
||||||
|
"6C+" -> 15
|
||||||
|
"7A" -> 16
|
||||||
|
"7A+" -> 17
|
||||||
|
"7B" -> 18
|
||||||
|
"7B+" -> 19
|
||||||
|
"7C" -> 20
|
||||||
|
"7C+" -> 21
|
||||||
|
"8A" -> 22
|
||||||
|
"8A+" -> 23
|
||||||
|
"8B" -> 24
|
||||||
|
"8B+" -> 25
|
||||||
|
"8C" -> 26
|
||||||
|
"8C+" -> 27
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DifficultySystem.YDS -> {
|
||||||
|
when (grade) {
|
||||||
|
"5.0" -> 50
|
||||||
|
"5.1" -> 51
|
||||||
|
"5.2" -> 52
|
||||||
|
"5.3" -> 53
|
||||||
|
"5.4" -> 54
|
||||||
|
"5.5" -> 55
|
||||||
|
"5.6" -> 56
|
||||||
|
"5.7" -> 57
|
||||||
|
"5.8" -> 58
|
||||||
|
"5.9" -> 59
|
||||||
|
"5.10a" -> 60
|
||||||
|
"5.10b" -> 61
|
||||||
|
"5.10c" -> 62
|
||||||
|
"5.10d" -> 63
|
||||||
|
"5.11a" -> 64
|
||||||
|
"5.11b" -> 65
|
||||||
|
"5.11c" -> 66
|
||||||
|
"5.11d" -> 67
|
||||||
|
"5.12a" -> 68
|
||||||
|
"5.12b" -> 69
|
||||||
|
"5.12c" -> 70
|
||||||
|
"5.12d" -> 71
|
||||||
|
"5.13a" -> 72
|
||||||
|
"5.13b" -> 73
|
||||||
|
"5.13c" -> 74
|
||||||
|
"5.13d" -> 75
|
||||||
|
"5.14a" -> 76
|
||||||
|
"5.14b" -> 77
|
||||||
|
"5.14c" -> 78
|
||||||
|
"5.14d" -> 79
|
||||||
|
"5.15a" -> 80
|
||||||
|
"5.15b" -> 81
|
||||||
|
"5.15c" -> 82
|
||||||
|
"5.15d" -> 83
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DifficultySystem.CUSTOM -> {
|
||||||
|
// Custom grades are numeric strings, so parse them directly
|
||||||
|
grade.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun numericToGrade(system: DifficultySystem, numeric: Int): String {
|
||||||
|
return when (system) {
|
||||||
|
DifficultySystem.V_SCALE -> {
|
||||||
|
when (numeric) {
|
||||||
|
0 -> "VB"
|
||||||
|
else -> "V$numeric"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DifficultySystem.FONT -> {
|
||||||
|
when (numeric) {
|
||||||
|
3 -> "3"
|
||||||
|
4 -> "4A"
|
||||||
|
5 -> "4B"
|
||||||
|
6 -> "4C"
|
||||||
|
7 -> "5A"
|
||||||
|
8 -> "5B"
|
||||||
|
9 -> "5C"
|
||||||
|
10 -> "6A"
|
||||||
|
11 -> "6A+"
|
||||||
|
12 -> "6B"
|
||||||
|
13 -> "6B+"
|
||||||
|
14 -> "6C"
|
||||||
|
15 -> "6C+"
|
||||||
|
16 -> "7A"
|
||||||
|
17 -> "7A+"
|
||||||
|
18 -> "7B"
|
||||||
|
19 -> "7B+"
|
||||||
|
20 -> "7C"
|
||||||
|
21 -> "7C+"
|
||||||
|
22 -> "8A"
|
||||||
|
23 -> "8A+"
|
||||||
|
24 -> "8B"
|
||||||
|
25 -> "8B+"
|
||||||
|
26 -> "8C"
|
||||||
|
27 -> "8C+"
|
||||||
|
else -> numeric.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DifficultySystem.YDS -> {
|
||||||
|
when (numeric) {
|
||||||
|
50 -> "5.0"
|
||||||
|
51 -> "5.1"
|
||||||
|
52 -> "5.2"
|
||||||
|
53 -> "5.3"
|
||||||
|
54 -> "5.4"
|
||||||
|
55 -> "5.5"
|
||||||
|
56 -> "5.6"
|
||||||
|
57 -> "5.7"
|
||||||
|
58 -> "5.8"
|
||||||
|
59 -> "5.9"
|
||||||
|
60 -> "5.10a"
|
||||||
|
61 -> "5.10b"
|
||||||
|
62 -> "5.10c"
|
||||||
|
63 -> "5.10d"
|
||||||
|
64 -> "5.11a"
|
||||||
|
65 -> "5.11b"
|
||||||
|
66 -> "5.11c"
|
||||||
|
67 -> "5.11d"
|
||||||
|
68 -> "5.12a"
|
||||||
|
69 -> "5.12b"
|
||||||
|
70 -> "5.12c"
|
||||||
|
71 -> "5.12d"
|
||||||
|
72 -> "5.13a"
|
||||||
|
73 -> "5.13b"
|
||||||
|
74 -> "5.13c"
|
||||||
|
75 -> "5.13d"
|
||||||
|
76 -> "5.14a"
|
||||||
|
77 -> "5.14b"
|
||||||
|
78 -> "5.14c"
|
||||||
|
79 -> "5.14d"
|
||||||
|
80 -> "5.15a"
|
||||||
|
81 -> "5.15b"
|
||||||
|
82 -> "5.15c"
|
||||||
|
83 -> "5.15d"
|
||||||
|
else -> numeric.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DifficultySystem.CUSTOM -> numeric.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,14 @@ package com.atridad.openclimb.ui.screens
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.atridad.openclimb.R
|
||||||
import com.atridad.openclimb.data.model.Gym
|
import com.atridad.openclimb.data.model.Gym
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
|
|
||||||
@@ -19,8 +18,7 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
|||||||
@Composable
|
@Composable
|
||||||
fun GymsScreen(
|
fun GymsScreen(
|
||||||
viewModel: ClimbViewModel,
|
viewModel: ClimbViewModel,
|
||||||
onNavigateToGymDetail: (String) -> Unit,
|
onNavigateToGymDetail: (String) -> Unit
|
||||||
onNavigateToAddGym: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
|
|
||||||
@@ -29,11 +27,23 @@ fun GymsScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = "Climbing Gyms",
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontWeight = FontWeight.Bold
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
)
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
|
contentDescription = "OpenClimb Logo",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Climbing Gyms",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -95,7 +105,7 @@ fun GymCard(
|
|||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = { },
|
onClick = { },
|
||||||
label = {
|
label = {
|
||||||
Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() })
|
Text(climbType.getDisplayName())
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(end = 4.dp)
|
modifier = Modifier.padding(end = 4.dp)
|
||||||
)
|
)
|
||||||
@@ -105,7 +115,7 @@ fun GymCard(
|
|||||||
if (gym.difficultySystems.isNotEmpty()) {
|
if (gym.difficultySystems.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.name }}",
|
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,16 +2,18 @@ package com.atridad.openclimb.ui.screens
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.atridad.openclimb.R
|
||||||
|
import com.atridad.openclimb.data.model.ClimbType
|
||||||
|
import com.atridad.openclimb.data.model.Gym
|
||||||
import com.atridad.openclimb.data.model.Problem
|
import com.atridad.openclimb.data.model.Problem
|
||||||
import com.atridad.openclimb.ui.components.FullscreenImageViewer
|
import com.atridad.openclimb.ui.components.FullscreenImageViewer
|
||||||
import com.atridad.openclimb.ui.components.ImageDisplay
|
import com.atridad.openclimb.ui.components.ImageDisplay
|
||||||
@@ -21,38 +23,157 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
|||||||
@Composable
|
@Composable
|
||||||
fun ProblemsScreen(
|
fun ProblemsScreen(
|
||||||
viewModel: ClimbViewModel,
|
viewModel: ClimbViewModel,
|
||||||
onNavigateToProblemDetail: (String) -> Unit,
|
onNavigateToProblemDetail: (String) -> Unit
|
||||||
onNavigateToAddProblem: (String?) -> Unit
|
|
||||||
) {
|
) {
|
||||||
val problems by viewModel.problems.collectAsState()
|
val problems by viewModel.problems.collectAsState()
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
var showImageViewer by remember { mutableStateOf(false) }
|
var showImageViewer by remember { mutableStateOf(false) }
|
||||||
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var selectedImageIndex by remember { mutableStateOf(0) }
|
var selectedImageIndex by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
|
||||||
|
var selectedGym by remember { mutableStateOf<Gym?>(null) }
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
val filteredProblems = problems.filter { problem ->
|
||||||
|
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
|
||||||
|
val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
|
||||||
|
climbTypeMatch && gymMatch
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = "Problems & Routes",
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontWeight = FontWeight.Bold
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
)
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
|
contentDescription = "OpenClimb Logo",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Problems & Routes",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
if (problems.isEmpty()) {
|
// Filters Section
|
||||||
|
if (problems.isNotEmpty()) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Filters",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Climb Type Filter
|
||||||
|
Text(
|
||||||
|
text = "Climb Type",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
FilterChip(
|
||||||
|
onClick = { selectedClimbType = null },
|
||||||
|
label = { Text("All Types") },
|
||||||
|
selected = selectedClimbType == null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(ClimbType.entries) { climbType ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = { selectedClimbType = climbType },
|
||||||
|
label = { Text(climbType.getDisplayName()) },
|
||||||
|
selected = selectedClimbType == climbType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Gym Filter
|
||||||
|
Text(
|
||||||
|
text = "Gym",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
FilterChip(
|
||||||
|
onClick = { selectedGym = null },
|
||||||
|
label = { Text("All Gyms") },
|
||||||
|
selected = selectedGym == null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(gyms) { gym ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = { selectedGym = gym },
|
||||||
|
label = { Text(gym.name) },
|
||||||
|
selected = selectedGym?.id == gym.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter result count
|
||||||
|
if (selectedClimbType != null || selectedGym != null) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Showing ${filteredProblems.size} of ${problems.size} problems",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredProblems.isEmpty()) {
|
||||||
EmptyStateMessage(
|
EmptyStateMessage(
|
||||||
title = if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet",
|
title = if (problems.isEmpty()) {
|
||||||
message = if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!",
|
if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet"
|
||||||
|
} else {
|
||||||
|
"No Problems Match Filters"
|
||||||
|
},
|
||||||
|
message = if (problems.isEmpty()) {
|
||||||
|
if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!"
|
||||||
|
} else {
|
||||||
|
"Try adjusting your filters to see more problems."
|
||||||
|
},
|
||||||
onActionClick = { },
|
onActionClick = { },
|
||||||
actionText = ""
|
actionText = ""
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(problems) { problem ->
|
items(filteredProblems) { problem ->
|
||||||
ProblemCard(
|
ProblemCard(
|
||||||
problem = problem,
|
problem = problem,
|
||||||
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
|
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
|
||||||
@@ -124,7 +245,7 @@ fun ProblemCard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = problem.climbType.name.lowercase().replaceFirstChar { it.uppercase() },
|
text = problem.climbType.getDisplayName(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ package com.atridad.openclimb.ui.screens
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.atridad.openclimb.R
|
||||||
import com.atridad.openclimb.data.model.ClimbSession
|
import com.atridad.openclimb.data.model.ClimbSession
|
||||||
import com.atridad.openclimb.data.model.SessionStatus
|
import com.atridad.openclimb.data.model.SessionStatus
|
||||||
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
||||||
@@ -24,13 +28,13 @@ import java.time.format.DateTimeFormatter
|
|||||||
@Composable
|
@Composable
|
||||||
fun SessionsScreen(
|
fun SessionsScreen(
|
||||||
viewModel: ClimbViewModel,
|
viewModel: ClimbViewModel,
|
||||||
onNavigateToSessionDetail: (String) -> Unit,
|
onNavigateToSessionDetail: (String) -> Unit
|
||||||
onNavigateToAddSession: (String?) -> Unit
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sessions by viewModel.sessions.collectAsState()
|
val sessions by viewModel.sessions.collectAsState()
|
||||||
val gyms by viewModel.gyms.collectAsState()
|
val gyms by viewModel.gyms.collectAsState()
|
||||||
val activeSession by viewModel.activeSession.collectAsState()
|
val activeSession by viewModel.activeSession.collectAsState()
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
// Filter out active sessions from regular session list
|
// Filter out active sessions from regular session list
|
||||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||||
@@ -45,16 +49,20 @@ fun SessionsScreen(
|
|||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
|
contentDescription = "OpenClimb Logo",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Climbing Sessions",
|
text = "Climbing Sessions",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -97,6 +105,79 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show UI state messages and errors
|
||||||
|
uiState.message?.let { message ->
|
||||||
|
LaunchedEffect(message) {
|
||||||
|
kotlinx.coroutines.delay(5000)
|
||||||
|
viewModel.clearMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.error?.let { error ->
|
||||||
|
LaunchedEffect(error) {
|
||||||
|
kotlinx.coroutines.delay(5000)
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -135,7 +216,7 @@ fun SessionCard(
|
|||||||
|
|
||||||
session.duration?.let { duration ->
|
session.duration?.let { duration ->
|
||||||
Text(
|
Text(
|
||||||
text = "Duration: ${duration} minutes",
|
text = "Duration: $duration minutes",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -197,7 +278,7 @@ private fun formatDate(dateString: String): String {
|
|||||||
return try {
|
return try {
|
||||||
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
|
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
|
||||||
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
|
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
dateString
|
dateString
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.atridad.openclimb.ui.screens
|
|||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import android.os.Environment
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -13,8 +12,10 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.atridad.openclimb.R
|
||||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -25,12 +26,16 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// State for reset confirmation dialog
|
||||||
|
var showResetDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val packageInfo = remember {
|
val packageInfo = remember {
|
||||||
context.packageManager.getPackageInfo(context.packageName, 0)
|
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
}
|
}
|
||||||
val appVersion = packageInfo.versionName
|
val appVersion = packageInfo.versionName
|
||||||
|
|
||||||
// File picker launcher for import - accepts both ZIP and JSON files
|
// File picker launcher for import - only accepts ZIP files
|
||||||
val importLauncher = rememberLauncherForActivityResult(
|
val importLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.GetContent()
|
contract = ActivityResultContracts.GetContent()
|
||||||
) { uri ->
|
) { uri ->
|
||||||
@@ -45,9 +50,13 @@ fun SettingsScreen(
|
|||||||
} else null
|
} else null
|
||||||
} ?: "import_file"
|
} ?: "import_file"
|
||||||
|
|
||||||
val extension = fileName.substringAfterLast(".", "")
|
// Only allow ZIP files
|
||||||
val tempFileName = if (extension.isNotEmpty()) "temp_import.$extension" else "temp_import"
|
if (!fileName.lowercase().endsWith(".zip")) {
|
||||||
val tempFile = File(context.cacheDir, tempFileName)
|
viewModel.setError("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.")
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
|
||||||
|
val tempFile = File(context.cacheDir, "temp_import.zip")
|
||||||
|
|
||||||
inputStream?.use { input ->
|
inputStream?.use { input ->
|
||||||
tempFile.outputStream().use { output ->
|
tempFile.outputStream().use { output ->
|
||||||
@@ -61,7 +70,7 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// File picker launcher for export - save location (ZIP format with images)
|
// File picker launcher for export - ZIP format with images
|
||||||
val exportZipLauncher = rememberLauncherForActivityResult(
|
val exportZipLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.CreateDocument("application/zip")
|
contract = ActivityResultContracts.CreateDocument("application/zip")
|
||||||
) { uri ->
|
) { uri ->
|
||||||
@@ -74,19 +83,6 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON export launcher
|
|
||||||
val exportJsonLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.CreateDocument("application/json")
|
|
||||||
) { uri ->
|
|
||||||
uri?.let {
|
|
||||||
try {
|
|
||||||
viewModel.exportDataToUri(context, uri)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
viewModel.setError("Failed to save file: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -94,11 +90,23 @@ fun SettingsScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Row(
|
||||||
text = "Settings",
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontWeight = FontWeight.Bold
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
)
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
|
contentDescription = "OpenClimb Logo",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Settings",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data Management Section
|
// Data Management Section
|
||||||
@@ -166,19 +174,13 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Export Data Only") },
|
headlineContent = { Text("Import Data") },
|
||||||
supportingContent = { Text("Export climbing data to JSON without images") },
|
supportingContent = { Text("Import climbing data from ZIP file (recommended format)") },
|
||||||
leadingContent = { Icon(Icons.Default.Share, contentDescription = null) },
|
leadingContent = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val defaultFileName = "openclimb_export_${
|
importLauncher.launch("application/zip")
|
||||||
java.time.LocalDateTime.now()
|
|
||||||
.toString()
|
|
||||||
.replace(":", "-")
|
|
||||||
.replace(".", "-")
|
|
||||||
}.json"
|
|
||||||
exportJsonLauncher.launch(defaultFileName)
|
|
||||||
},
|
},
|
||||||
enabled = !uiState.isLoading
|
enabled = !uiState.isLoading
|
||||||
) {
|
) {
|
||||||
@@ -188,7 +190,7 @@ fun SettingsScreen(
|
|||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Export JSON")
|
Text("Import")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,17 +202,17 @@ fun SettingsScreen(
|
|||||||
Card(
|
Card(
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Import Data") },
|
headlineContent = { Text("Reset All Data") },
|
||||||
supportingContent = { Text("Import climbing data from ZIP or JSON file") },
|
supportingContent = { Text("Permanently delete all gyms, problems, sessions, attempts, and images") },
|
||||||
leadingContent = { Icon(Icons.Default.Add, contentDescription = null) },
|
leadingContent = { Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
importLauncher.launch("*/*")
|
showResetDialog = true
|
||||||
},
|
},
|
||||||
enabled = !uiState.isLoading
|
enabled = !uiState.isLoading
|
||||||
) {
|
) {
|
||||||
@@ -220,7 +222,7 @@ fun SettingsScreen(
|
|||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Import")
|
Text("Reset", color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,9 +258,22 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Version") },
|
headlineContent = {
|
||||||
supportingContent = { Text(appVersion ?: "Unknown") },
|
Row(
|
||||||
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_mountains),
|
||||||
|
contentDescription = "OpenClimb Logo",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text("OpenClimb")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingContent = { Text("Track your climbing progress") },
|
||||||
|
leadingContent = { }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,8 +286,8 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("About") },
|
headlineContent = { Text("Version") },
|
||||||
supportingContent = { Text("OpenClimb - Track your climbing progress") },
|
supportingContent = { Text(appVersion ?: "Unknown") },
|
||||||
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
|
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -364,4 +379,51 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset confirmation dialog
|
||||||
|
if (showResetDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showResetDialog = false },
|
||||||
|
title = { Text("Reset All Data") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Are you sure you want to reset all data?")
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "This will permanently delete:",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "This action cannot be undone. Consider exporting your data first.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.resetAllData()
|
||||||
|
showResetDialog = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Reset All Data", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showResetDialog = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,13 +63,4 @@ val ClimbNeutralVariant30 = Color(0xFF484848)
|
|||||||
val ClimbNeutralVariant50 = Color(0xFF797979)
|
val ClimbNeutralVariant50 = Color(0xFF797979)
|
||||||
val ClimbNeutralVariant60 = Color(0xFF939393)
|
val ClimbNeutralVariant60 = Color(0xFF939393)
|
||||||
val ClimbNeutralVariant80 = Color(0xFFC7C7C7)
|
val ClimbNeutralVariant80 = Color(0xFFC7C7C7)
|
||||||
val ClimbNeutralVariant90 = Color(0xFFE3E3E3)
|
val ClimbNeutralVariant90 = Color(0xFFE3E3E3)
|
||||||
|
|
||||||
// Legacy colors for backward compatibility
|
|
||||||
val Purple80 = ClimbOrange80
|
|
||||||
val PurpleGrey80 = ClimbGrey80
|
|
||||||
val Pink80 = ClimbBlue80
|
|
||||||
|
|
||||||
val Purple40 = ClimbOrange40
|
|
||||||
val PurpleGrey40 = ClimbGrey40
|
|
||||||
val Pink40 = ClimbBlue40
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.atridad.openclimb.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
object CustomIcons {
|
||||||
|
fun Stop(color: Color = Color.Black): ImageVector = ImageVector.Builder(
|
||||||
|
name = "Stop",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f
|
||||||
|
).path(
|
||||||
|
fill = SolidColor(color)
|
||||||
|
) {
|
||||||
|
moveTo(6f, 6f)
|
||||||
|
horizontalLineTo(18f)
|
||||||
|
verticalLineTo(18f)
|
||||||
|
horizontalLineTo(6f)
|
||||||
|
close()
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.atridad.openclimb.ui.theme
|
package com.atridad.openclimb.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
@@ -10,7 +9,6 @@ import androidx.compose.material3.dynamicLightColorScheme
|
|||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
@@ -98,7 +96,7 @@ fun OpenClimbTheme(
|
|||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
dynamicColor && true -> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
}
|
}
|
||||||
@@ -110,8 +108,8 @@ fun OpenClimbTheme(
|
|||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as Activity).window
|
val window = (view.context as Activity).window
|
||||||
window.statusBarColor = colorScheme.primary.toArgb()
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,388 +8,429 @@ import com.atridad.openclimb.data.repository.ClimbRepository
|
|||||||
import com.atridad.openclimb.service.SessionTrackingService
|
import com.atridad.openclimb.service.SessionTrackingService
|
||||||
import com.atridad.openclimb.utils.ImageUtils
|
import com.atridad.openclimb.utils.ImageUtils
|
||||||
import com.atridad.openclimb.utils.SessionShareUtils
|
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.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class ClimbViewModel(
|
class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
|
||||||
private val repository: ClimbRepository
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
// UI State flows
|
// UI State flows
|
||||||
private val _uiState = MutableStateFlow(ClimbUiState())
|
private val _uiState = MutableStateFlow(ClimbUiState())
|
||||||
val uiState: StateFlow<ClimbUiState> = _uiState.asStateFlow()
|
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
// 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
|
// Gym operations
|
||||||
fun addGym(gym: Gym) {
|
fun addGym(gym: Gym) {
|
||||||
|
viewModelScope.launch { repository.insertGym(gym) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addGym(gym: Gym, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertGym(gym)
|
repository.insertGym(gym)
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateGym(gym: Gym) {
|
fun updateGym(gym: Gym) {
|
||||||
|
viewModelScope.launch { repository.updateGym(gym) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGym(gym: Gym, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateGym(gym)
|
repository.updateGym(gym)
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteGym(gym: Gym) {
|
fun deleteGym(gym: Gym) {
|
||||||
|
viewModelScope.launch { repository.deleteGym(gym) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteGym(gym: Gym, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteGym(gym)
|
repository.deleteGym(gym)
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getGymById(id: String): Flow<Gym?> = flow {
|
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
|
||||||
emit(repository.getGymById(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Problem operations
|
// Problem operations
|
||||||
fun addProblem(problem: Problem) {
|
fun addProblem(problem: Problem) {
|
||||||
|
viewModelScope.launch { repository.insertProblem(problem) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addProblem(problem: Problem, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertProblem(problem)
|
repository.insertProblem(problem)
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProblem(problem: Problem) {
|
fun updateProblem(problem: Problem) {
|
||||||
|
viewModelScope.launch { repository.updateProblem(problem) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProblem(problem: Problem, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateProblem(problem)
|
repository.updateProblem(problem)
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteProblem(problem: Problem, context: Context) {
|
fun deleteProblem(problem: Problem, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Delete associated images
|
// Delete associated images
|
||||||
problem.imagePaths.forEach { imagePath ->
|
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
|
||||||
ImageUtils.deleteImage(context, imagePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
repository.deleteProblem(problem)
|
repository.deleteProblem(problem)
|
||||||
|
|
||||||
// Clean up any remaining orphaned images
|
|
||||||
cleanupOrphanedImages(context)
|
cleanupOrphanedImages(context)
|
||||||
|
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun cleanupOrphanedImages(context: Context) {
|
private suspend fun cleanupOrphanedImages(context: Context) {
|
||||||
val allProblems = repository.getAllProblems().first()
|
val allProblems = repository.getAllProblems().first()
|
||||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||||
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
|
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getProblemById(id: String): Flow<Problem?> = flow {
|
fun getProblemById(id: String): Flow<Problem?> = flow { emit(repository.getProblemById(id)) }
|
||||||
emit(repository.getProblemById(id))
|
|
||||||
}
|
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
|
||||||
|
|
||||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> =
|
|
||||||
repository.getProblemsByGym(gymId)
|
|
||||||
|
|
||||||
// Session operations
|
// Session operations
|
||||||
fun addSession(session: ClimbSession) {
|
fun addSession(session: ClimbSession) {
|
||||||
|
viewModelScope.launch { repository.insertSession(session) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addSession(session: ClimbSession, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertSession(session)
|
repository.insertSession(session)
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSession(session: ClimbSession) {
|
fun updateSession(session: ClimbSession) {
|
||||||
|
viewModelScope.launch { repository.updateSession(session) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSession(session: ClimbSession, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.updateSession(session)
|
repository.updateSession(session)
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteSession(session: ClimbSession) {
|
fun deleteSession(session: ClimbSession) {
|
||||||
|
viewModelScope.launch { repository.deleteSession(session) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSession(session: ClimbSession, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteSession(session)
|
repository.deleteSession(session)
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSessionById(id: String): Flow<ClimbSession?> = flow {
|
fun getSessionById(id: String): Flow<ClimbSession?> = flow {
|
||||||
emit(repository.getSessionById(id))
|
emit(repository.getSessionById(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
||||||
repository.getSessionsByGym(gymId)
|
repository.getSessionsByGym(gymId)
|
||||||
|
|
||||||
|
// Get last used gym for shortcut functionality
|
||||||
|
suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym()
|
||||||
|
|
||||||
// Active session management
|
// Active session management
|
||||||
fun startSession(context: Context, gymId: String, notes: String? = null) {
|
fun startSession(context: Context, gymId: String, notes: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val existingActive = repository.getActiveSession()
|
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
|
||||||
if (existingActive != null) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
|
||||||
error = "There's already an active session. Please end it first."
|
.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
|
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)
|
val newSession = ClimbSession.create(gymId = gymId, notes = notes)
|
||||||
repository.insertSession(newSession)
|
repository.insertSession(newSession)
|
||||||
|
|
||||||
|
android.util.Log.d(
|
||||||
|
"ClimbViewModel",
|
||||||
|
"Starting tracking service for session: ${newSession.id}"
|
||||||
|
)
|
||||||
// Start the tracking service
|
// Start the tracking service
|
||||||
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
|
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
|
||||||
context.startForegroundService(serviceIntent)
|
context.startForegroundService(serviceIntent)
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
message = "Session started successfully!"
|
|
||||||
)
|
android.util.Log.d("ClimbViewModel", "Session started successfully")
|
||||||
|
_uiState.value = _uiState.value.copy(message = "Session started successfully!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun endSession(context: Context, sessionId: String) {
|
fun endSession(context: Context, sessionId: String) {
|
||||||
viewModelScope.launch {
|
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)
|
val session = repository.getSessionById(sessionId)
|
||||||
if (session != null && session.status == SessionStatus.ACTIVE) {
|
if (session != null && session.status == SessionStatus.ACTIVE) {
|
||||||
val completedSession = with(ClimbSession) { session.complete() }
|
val completedSession = with(ClimbSession) { session.complete() }
|
||||||
repository.updateSession(completedSession)
|
repository.updateSession(completedSession)
|
||||||
|
|
||||||
// Stop the tracking service
|
val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
|
||||||
val serviceIntent = SessionTrackingService.createStopIntent(context)
|
|
||||||
context.startService(serviceIntent)
|
context.startService(serviceIntent)
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
message = "Session completed!"
|
|
||||||
)
|
_uiState.value = _uiState.value.copy(message = "Session completed!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pauseSession(sessionId: String) {
|
fun ensureSessionTrackingServiceRunning(context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val session = repository.getSessionById(sessionId)
|
val activeSession = repository.getActiveSession()
|
||||||
if (session != null && session.status == SessionStatus.ACTIVE) {
|
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
|
||||||
val pausedSession = session.copy(
|
val serviceIntent =
|
||||||
status = SessionStatus.PAUSED,
|
SessionTrackingService.createStartIntent(context, activeSession.id)
|
||||||
updatedAt = java.time.LocalDateTime.now().toString()
|
context.startForegroundService(serviceIntent)
|
||||||
)
|
|
||||||
repository.updateSession(pausedSession)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resumeSession(sessionId: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val session = repository.getSessionById(sessionId)
|
|
||||||
if (session != null && session.status == SessionStatus.PAUSED) {
|
|
||||||
val resumedSession = session.copy(
|
|
||||||
status = SessionStatus.ACTIVE,
|
|
||||||
updatedAt = java.time.LocalDateTime.now().toString()
|
|
||||||
)
|
|
||||||
repository.updateSession(resumedSession)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt operations
|
// Attempt operations
|
||||||
fun addAttempt(attempt: Attempt) {
|
fun addAttempt(attempt: Attempt) {
|
||||||
|
viewModelScope.launch { repository.insertAttempt(attempt) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addAttempt(attempt: Attempt, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.insertAttempt(attempt)
|
repository.insertAttempt(attempt)
|
||||||
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateAttempt(attempt: Attempt) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.updateAttempt(attempt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteAttempt(attempt: Attempt) {
|
fun deleteAttempt(attempt: Attempt) {
|
||||||
|
viewModelScope.launch { repository.deleteAttempt(attempt) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteAttempt(attempt: Attempt, context: Context) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteAttempt(attempt)
|
repository.deleteAttempt(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 updateAttempt(attempt: Attempt) {
|
||||||
// Analytics operations
|
viewModelScope.launch { repository.updateAttempt(attempt) }
|
||||||
// fun getProblemProgress(problemId: String): Flow<ProblemProgress?> =
|
}
|
||||||
// repository.getProblemProgress(problemId)
|
|
||||||
|
fun updateAttempt(attempt: Attempt, context: Context) {
|
||||||
// fun getSessionSummary(sessionId: String): Flow<SessionSummary?> =
|
|
||||||
// repository.getSessionSummary(sessionId)
|
|
||||||
|
|
||||||
// Export operations
|
|
||||||
fun exportData(context: Context, directory: File? = null) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
repository.updateAttempt(attempt)
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||||
val exportFile = repository.exportAllDataToJson(directory)
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
message = "Data exported to: ${exportFile.absolutePath}"
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = "Export failed: ${e.message}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun exportDataToUri(context: Context, uri: android.net.Uri) {
|
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||||
viewModelScope.launch {
|
repository.getAttemptsBySession(sessionId)
|
||||||
try {
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
|
||||||
repository.exportAllDataToUri(context, uri)
|
repository.getAttemptsByProblem(problemId)
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
message = "Data exported successfully"
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = "Export failed: ${e.message}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZIP Export operations with images
|
|
||||||
fun exportDataToZip(context: Context, directory: File? = null) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
|
||||||
val exportFile = repository.exportAllDataToZip(directory)
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
message = "Data with images exported to: ${exportFile.absolutePath}"
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = "Export failed: ${e.message}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
|
fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
repository.exportAllDataToZipUri(context, uri)
|
repository.exportAllDataToZipUri(context, uri)
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value =
|
||||||
isLoading = false,
|
_uiState.value.copy(
|
||||||
message = "Data with images exported successfully"
|
isLoading = false,
|
||||||
)
|
message = "Data with images exported successfully"
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value =
|
||||||
isLoading = false,
|
_uiState.value.copy(
|
||||||
error = "Export failed: ${e.message}"
|
isLoading = false,
|
||||||
)
|
error = "Export failed: ${e.message}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun importData(file: File) {
|
fun importData(file: File) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
|
|
||||||
// Check if it's a ZIP file or JSON file
|
if (!file.name.lowercase().endsWith(".zip")) {
|
||||||
if (file.name.lowercase().endsWith(".zip")) {
|
throw Exception(
|
||||||
repository.importDataFromZip(file)
|
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
|
||||||
} else {
|
)
|
||||||
repository.importDataFromJson(file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
repository.importDataFromZip(file)
|
||||||
isLoading = false,
|
|
||||||
message = "Data imported successfully from ${file.name}"
|
_uiState.value =
|
||||||
)
|
_uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
message = "Data imported successfully from ${file.name}"
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value =
|
||||||
isLoading = false,
|
_uiState.value.copy(
|
||||||
error = "Import failed: ${e.message}"
|
isLoading = false,
|
||||||
)
|
error = "Import failed: ${e.message}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI state operations
|
// UI state operations
|
||||||
fun clearMessage() {
|
fun clearMessage() {
|
||||||
_uiState.value = _uiState.value.copy(message = null)
|
_uiState.value = _uiState.value.copy(message = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
_uiState.value = _uiState.value.copy(error = null)
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setError(message: String) {
|
fun setError(message: String) {
|
||||||
_uiState.value = _uiState.value.copy(error = message)
|
_uiState.value = _uiState.value.copy(error = message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search operations
|
fun resetAllData() {
|
||||||
fun searchGyms(query: String): Flow<List<Gym>> = repository.searchGyms(query)
|
viewModelScope.launch {
|
||||||
fun searchProblems(query: String): Flow<List<Problem>> = repository.searchProblems(query)
|
try {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
// Share operations
|
|
||||||
suspend fun generateSessionShareCard(
|
repository.resetAllData()
|
||||||
context: Context,
|
|
||||||
sessionId: String
|
_uiState.value =
|
||||||
): File? = withContext(Dispatchers.IO) {
|
_uiState.value.copy(
|
||||||
try {
|
isLoading = false,
|
||||||
val session = repository.getSessionById(sessionId) ?: return@withContext null
|
message = "All data has been reset successfully"
|
||||||
val attempts = repository.getAttemptsBySession(sessionId).first()
|
)
|
||||||
val problems = repository.getAllProblems().first().filter { problem ->
|
} catch (e: Exception) {
|
||||||
attempts.any { it.problemId == problem.id }
|
_uiState.value =
|
||||||
|
_uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
|
||||||
}
|
}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
fun shareSessionCard(context: Context, imageFile: File) {
|
||||||
SessionShareUtils.shareSessionCard(context, imageFile)
|
SessionShareUtils.shareSessionCard(context, imageFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ClimbUiState(
|
data class ClimbUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package com.atridad.openclimb.utils
|
package com.atridad.openclimb.utils
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
|
||||||
object ImageUtils {
|
object ImageUtils {
|
||||||
|
|
||||||
@@ -34,37 +35,96 @@ object ImageUtils {
|
|||||||
*/
|
*/
|
||||||
fun saveImageFromUri(context: Context, imageUri: Uri): String? {
|
fun saveImageFromUri(context: Context, imageUri: Uri): String? {
|
||||||
return try {
|
return try {
|
||||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
// Decode bitmap from a fresh stream to avoid mark/reset dependency
|
||||||
inputStream?.use { input ->
|
val originalBitmap = context.contentResolver.openInputStream(imageUri)?.use { input ->
|
||||||
// Decode and compress the image
|
BitmapFactory.decodeStream(input)
|
||||||
val originalBitmap = BitmapFactory.decodeStream(input)
|
} ?: return null
|
||||||
val compressedBitmap = compressImage(originalBitmap)
|
|
||||||
|
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||||
// Generate unique filename
|
val compressedBitmap = compressImage(orientedBitmap)
|
||||||
val filename = "${UUID.randomUUID()}.jpg"
|
|
||||||
val imageFile = File(getImagesDirectory(context), filename)
|
// Generate unique filename
|
||||||
|
val filename = "${UUID.randomUUID()}.jpg"
|
||||||
// Save compressed image
|
val imageFile = File(getImagesDirectory(context), filename)
|
||||||
FileOutputStream(imageFile).use { output ->
|
|
||||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
// Save compressed image
|
||||||
}
|
FileOutputStream(imageFile).use { output ->
|
||||||
|
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||||
// Clean up bitmaps
|
|
||||||
originalBitmap.recycle()
|
|
||||||
compressedBitmap.recycle()
|
|
||||||
|
|
||||||
// Return relative path
|
|
||||||
"$IMAGES_DIR/$filename"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up bitmaps
|
||||||
|
originalBitmap.recycle()
|
||||||
|
if (orientedBitmap != originalBitmap) {
|
||||||
|
orientedBitmap.recycle()
|
||||||
|
}
|
||||||
|
compressedBitmap.recycle()
|
||||||
|
|
||||||
|
// Return relative path
|
||||||
|
"$IMAGES_DIR/$filename"
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects image orientation based on EXIF data
|
||||||
|
*/
|
||||||
|
private fun correctImageOrientation(context: Context, imageUri: Uri, bitmap: Bitmap): Bitmap {
|
||||||
|
return try {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||||
|
inputStream?.use { input ->
|
||||||
|
val exif = android.media.ExifInterface(input)
|
||||||
|
val orientation = exif.getAttributeInt(
|
||||||
|
android.media.ExifInterface.TAG_ORIENTATION,
|
||||||
|
android.media.ExifInterface.ORIENTATION_NORMAL
|
||||||
|
)
|
||||||
|
|
||||||
|
val matrix = android.graphics.Matrix()
|
||||||
|
when (orientation) {
|
||||||
|
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
||||||
|
matrix.postRotate(90f)
|
||||||
|
}
|
||||||
|
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
||||||
|
matrix.postRotate(180f)
|
||||||
|
}
|
||||||
|
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
||||||
|
matrix.postRotate(270f)
|
||||||
|
}
|
||||||
|
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||||
|
matrix.postScale(1f, -1f)
|
||||||
|
}
|
||||||
|
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
|
matrix.postRotate(90f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
|
matrix.postRotate(-90f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matrix.isIdentity) {
|
||||||
|
bitmap
|
||||||
|
} else {
|
||||||
|
android.graphics.Bitmap.createBitmap(
|
||||||
|
bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: bitmap
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
bitmap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compresses and resizes an image bitmap
|
* Compresses and resizes an image bitmap
|
||||||
*/
|
*/
|
||||||
|
@SuppressLint("UseKtx")
|
||||||
private fun compressImage(original: Bitmap): Bitmap {
|
private fun compressImage(original: Bitmap): Bitmap {
|
||||||
val width = original.width
|
val width = original.width
|
||||||
val height = original.height
|
val height = original.height
|
||||||
@@ -79,7 +139,7 @@ object ImageUtils {
|
|||||||
return if (scaleFactor < 1f) {
|
return if (scaleFactor < 1f) {
|
||||||
val newWidth = (width * scaleFactor).toInt()
|
val newWidth = (width * scaleFactor).toInt()
|
||||||
val newHeight = (height * scaleFactor).toInt()
|
val newHeight = (height * scaleFactor).toInt()
|
||||||
Bitmap.createScaledBitmap(original, newWidth, newHeight, true)
|
original.scale(newWidth, newHeight)
|
||||||
} else {
|
} else {
|
||||||
original
|
original
|
||||||
}
|
}
|
||||||
@@ -110,30 +170,7 @@ object ImageUtils {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies an image file to export directory
|
|
||||||
* @param context Android context
|
|
||||||
* @param relativePath The relative path of the image
|
|
||||||
* @param exportDir The directory to copy to
|
|
||||||
* @return The filename in the export directory, null if failed
|
|
||||||
*/
|
|
||||||
fun copyImageForExport(context: Context, relativePath: String, exportDir: File): String? {
|
|
||||||
return try {
|
|
||||||
val sourceFile = getImageFile(context, relativePath)
|
|
||||||
if (!sourceFile.exists()) return null
|
|
||||||
|
|
||||||
val filename = sourceFile.name
|
|
||||||
val destFile = File(exportDir, filename)
|
|
||||||
|
|
||||||
sourceFile.copyTo(destFile, overwrite = true)
|
|
||||||
filename
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Imports an image file from the import directory
|
* Imports an image file from the import directory
|
||||||
* @param context Android context
|
* @param context Android context
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.atridad.openclimb.utils
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
object NotificationPermissionUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if notification permission is granted
|
||||||
|
*/
|
||||||
|
fun isNotificationPermissionGranted(context: Context): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if notification permission should be requested
|
||||||
|
*/
|
||||||
|
fun shouldRequestNotificationPermission(): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification permission string
|
||||||
|
*/
|
||||||
|
fun getNotificationPermissionString(): String {
|
||||||
|
return Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import java.io.FileOutputStream
|
|||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.core.graphics.toColorInt
|
||||||
|
|
||||||
object SessionShareUtils {
|
object SessionShareUtils {
|
||||||
|
|
||||||
@@ -22,7 +24,8 @@ object SessionShareUtils {
|
|||||||
val uniqueProblemsCompleted: Int,
|
val uniqueProblemsCompleted: Int,
|
||||||
val averageGrade: String?,
|
val averageGrade: String?,
|
||||||
val sessionDuration: String,
|
val sessionDuration: String,
|
||||||
val topResult: AttemptResult?
|
val topResult: AttemptResult?,
|
||||||
|
val topGrade: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
fun calculateSessionStats(
|
fun calculateSessionStats(
|
||||||
@@ -32,9 +35,7 @@ object SessionShareUtils {
|
|||||||
): SessionStats {
|
): SessionStats {
|
||||||
val successfulResults = listOf(
|
val successfulResults = listOf(
|
||||||
AttemptResult.SUCCESS,
|
AttemptResult.SUCCESS,
|
||||||
AttemptResult.FLASH,
|
AttemptResult.FLASH
|
||||||
AttemptResult.REDPOINT,
|
|
||||||
AttemptResult.ONSIGHT
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val successfulAttempts = attempts.filter { it.result in successfulResults }
|
val successfulAttempts = attempts.filter { it.result in successfulResults }
|
||||||
@@ -42,22 +43,39 @@ object SessionShareUtils {
|
|||||||
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
|
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
|
||||||
|
|
||||||
val attemptedProblems = problems.filter { it.id in uniqueProblems }
|
val attemptedProblems = problems.filter { it.id in uniqueProblems }
|
||||||
val averageGrade = if (attemptedProblems.isNotEmpty()) {
|
|
||||||
// This is a simplified average - in reality you'd need proper grade conversion
|
// Calculate separate averages for different climbing types and difficulty systems
|
||||||
val gradeValues = attemptedProblems.mapNotNull { problem ->
|
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
|
||||||
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
|
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
|
||||||
}
|
|
||||||
if (gradeValues.isNotEmpty()) {
|
val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder")
|
||||||
"V${gradeValues.average().roundToInt()}"
|
val ropeAverage = calculateAverageGrade(ropeProblems, "Rope")
|
||||||
} else null
|
|
||||||
} else null
|
// Combine averages for display
|
||||||
|
val averageGrade = when {
|
||||||
|
boulderAverage != null && ropeAverage != null -> "$boulderAverage / $ropeAverage"
|
||||||
|
boulderAverage != null -> boulderAverage
|
||||||
|
ropeAverage != null -> ropeAverage
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine highest achieved grade (only from completed problems: SUCCESS or FLASH)
|
||||||
|
val completedProblems = problems.filter { it.id in uniqueCompletedProblems }
|
||||||
|
val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER }
|
||||||
|
val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE }
|
||||||
|
val topBoulder = highestGradeForProblems(completedBoulder)
|
||||||
|
val topRope = highestGradeForProblems(completedRope)
|
||||||
|
val topGrade = when {
|
||||||
|
topBoulder != null && topRope != null -> "$topBoulder / $topRope"
|
||||||
|
topBoulder != null -> topBoulder
|
||||||
|
topRope != null -> topRope
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
|
val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
|
||||||
val topResult = attempts.maxByOrNull {
|
val topResult = attempts.maxByOrNull {
|
||||||
when (it.result) {
|
when (it.result) {
|
||||||
AttemptResult.ONSIGHT -> 5
|
AttemptResult.FLASH -> 3
|
||||||
AttemptResult.FLASH -> 4
|
|
||||||
AttemptResult.REDPOINT -> 3
|
|
||||||
AttemptResult.SUCCESS -> 2
|
AttemptResult.SUCCESS -> 2
|
||||||
AttemptResult.FALL -> 1
|
AttemptResult.FALL -> 1
|
||||||
else -> 0
|
else -> 0
|
||||||
@@ -72,34 +90,81 @@ object SessionShareUtils {
|
|||||||
uniqueProblemsCompleted = uniqueCompletedProblems.size,
|
uniqueProblemsCompleted = uniqueCompletedProblems.size,
|
||||||
averageGrade = averageGrade,
|
averageGrade = averageGrade,
|
||||||
sessionDuration = duration,
|
sessionDuration = duration,
|
||||||
topResult = topResult
|
topResult = topResult,
|
||||||
|
topGrade = topGrade
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateDuration(startTime: String?, endTime: String?): String {
|
/**
|
||||||
return try {
|
* Calculate average grade for a specific set of problems, respecting their difficulty systems
|
||||||
if (startTime != null && endTime != null) {
|
*/
|
||||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
private fun calculateAverageGrade(problems: List<Problem>, climbingType: String): String? {
|
||||||
val start = LocalDateTime.parse(startTime, formatter)
|
if (problems.isEmpty()) return null
|
||||||
val end = LocalDateTime.parse(endTime, formatter)
|
|
||||||
val duration = java.time.Duration.between(start, end)
|
// Group problems by difficulty system
|
||||||
|
val problemsBySystem = problems.groupBy { it.difficulty.system }
|
||||||
val hours = duration.toHours()
|
|
||||||
val minutes = duration.toMinutes() % 60
|
val averages = mutableListOf<String>()
|
||||||
|
|
||||||
when {
|
problemsBySystem.forEach { (system, systemProblems) ->
|
||||||
hours > 0 -> "${hours}h ${minutes}m"
|
when (system) {
|
||||||
minutes > 0 -> "${minutes}m"
|
DifficultySystem.V_SCALE -> {
|
||||||
else -> "< 1m"
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
"Unknown"
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
"Unknown"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return if (averages.isNotEmpty()) {
|
||||||
|
if (averages.size == 1) {
|
||||||
|
averages.first()
|
||||||
|
} else {
|
||||||
|
averages.joinToString(" / ")
|
||||||
|
}
|
||||||
|
} else null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun generateShareCard(
|
fun generateShareCard(
|
||||||
context: Context,
|
context: Context,
|
||||||
session: ClimbSession,
|
session: ClimbSession,
|
||||||
@@ -107,17 +172,17 @@ object SessionShareUtils {
|
|||||||
stats: SessionStats
|
stats: SessionStats
|
||||||
): File? {
|
): File? {
|
||||||
return try {
|
return try {
|
||||||
val width = 1080
|
val width = 1242 // 3:4 aspect at higher resolution for better fit
|
||||||
val height = 1350
|
val height = 1656
|
||||||
|
|
||||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
val bitmap = createBitmap(width, height)
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
|
|
||||||
val gradientDrawable = GradientDrawable(
|
val gradientDrawable = GradientDrawable(
|
||||||
GradientDrawable.Orientation.TOP_BOTTOM,
|
GradientDrawable.Orientation.TOP_BOTTOM,
|
||||||
intArrayOf(
|
intArrayOf(
|
||||||
Color.parseColor("#667eea"),
|
"#667eea".toColorInt(),
|
||||||
Color.parseColor("#764ba2")
|
"#764ba2".toColorInt()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
gradientDrawable.setBounds(0, 0, width, height)
|
gradientDrawable.setBounds(0, 0, width, height)
|
||||||
@@ -133,7 +198,7 @@ object SessionShareUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val subtitlePaint = Paint().apply {
|
val subtitlePaint = Paint().apply {
|
||||||
color = Color.parseColor("#E8E8E8")
|
color = "#E8E8E8".toColorInt()
|
||||||
textSize = 48f
|
textSize = 48f
|
||||||
typeface = Typeface.DEFAULT
|
typeface = Typeface.DEFAULT
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
@@ -141,7 +206,7 @@ object SessionShareUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val statLabelPaint = Paint().apply {
|
val statLabelPaint = Paint().apply {
|
||||||
color = Color.parseColor("#B8B8B8")
|
color = "#B8B8B8".toColorInt()
|
||||||
textSize = 36f
|
textSize = 36f
|
||||||
typeface = Typeface.DEFAULT
|
typeface = Typeface.DEFAULT
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
@@ -157,12 +222,12 @@ object SessionShareUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val cardPaint = Paint().apply {
|
val cardPaint = Paint().apply {
|
||||||
color = Color.parseColor("#40FFFFFF")
|
color = "#40FFFFFF".toColorInt()
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw main card background
|
// Draw main card background
|
||||||
val cardRect = RectF(60f, 200f, width - 60f, height - 100f)
|
val cardRect = RectF(60f, 200f, width - 60f, height - 120f)
|
||||||
canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
|
canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
|
||||||
|
|
||||||
// Draw content
|
// Draw content
|
||||||
@@ -183,35 +248,48 @@ object SessionShareUtils {
|
|||||||
// Stats grid
|
// Stats grid
|
||||||
val statsStartY = yPosition
|
val statsStartY = yPosition
|
||||||
val columnWidth = width / 2f
|
val columnWidth = width / 2f
|
||||||
|
val columnMaxTextWidth = columnWidth - 120f
|
||||||
|
|
||||||
// Left column stats
|
// Left column stats
|
||||||
var leftY = statsStartY
|
var leftY = statsStartY
|
||||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint)
|
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||||
leftY += 140f
|
leftY += 120f
|
||||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint)
|
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||||
leftY += 140f
|
leftY += 120f
|
||||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint)
|
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||||
|
|
||||||
// Right column stats
|
// Right column stats
|
||||||
var rightY = statsStartY
|
var rightY = statsStartY
|
||||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint)
|
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||||
rightY += 140f
|
rightY += 120f
|
||||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint)
|
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||||
rightY += 140f
|
rightY += 120f
|
||||||
|
|
||||||
stats.averageGrade?.let { grade ->
|
var rightYAfter = rightY
|
||||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Avg Grade", grade, statLabelPaint, statValuePaint)
|
stats.topGrade?.let { grade ->
|
||||||
|
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||||
|
rightYAfter += 120f
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success rate arc
|
// Grade range(s)
|
||||||
if (stats.totalAttempts > 0) {
|
val boulderRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.BOULDER })
|
||||||
val successRate = (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100
|
val ropeRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE })
|
||||||
drawSuccessRateArc(canvas, width / 2f, height - 280f, successRate, statLabelPaint, statValuePaint)
|
val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f
|
||||||
|
if (boulderRange != null && ropeRange != null) {
|
||||||
|
// Two evenly spaced items
|
||||||
|
drawStatItemFitting(canvas, columnWidth / 2f, rangesY, "Boulder Range", boulderRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||||
|
drawStatItemFitting(canvas, width - columnWidth / 2f, rangesY, "Rope Range", ropeRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||||
|
} else if (boulderRange != null || ropeRange != null) {
|
||||||
|
// Single centered item
|
||||||
|
val singleRange = boulderRange ?: ropeRange ?: ""
|
||||||
|
drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// App branding
|
// App branding
|
||||||
val brandingPaint = Paint().apply {
|
val brandingPaint = Paint().apply {
|
||||||
color = Color.parseColor("#80FFFFFF")
|
color = "#80FFFFFF".toColorInt()
|
||||||
textSize = 32f
|
textSize = 32f
|
||||||
typeface = Typeface.DEFAULT
|
typeface = Typeface.DEFAULT
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
@@ -255,57 +333,50 @@ object SessionShareUtils {
|
|||||||
canvas.drawText(label, x, y + 50f, labelPaint)
|
canvas.drawText(label, x, y + 50f, labelPaint)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun drawSuccessRateArc(
|
/**
|
||||||
|
* Draws a stat item while fitting the value text to a max width by reducing text size if needed.
|
||||||
|
*/
|
||||||
|
private fun drawStatItemFitting(
|
||||||
canvas: Canvas,
|
canvas: Canvas,
|
||||||
centerX: Float,
|
x: Float,
|
||||||
centerY: Float,
|
y: Float,
|
||||||
successRate: Float,
|
label: String,
|
||||||
|
value: String,
|
||||||
labelPaint: Paint,
|
labelPaint: Paint,
|
||||||
valuePaint: Paint
|
valuePaint: Paint,
|
||||||
|
maxTextWidth: Float
|
||||||
) {
|
) {
|
||||||
val radius = 80f
|
val tempPaint = Paint(valuePaint)
|
||||||
val strokeWidth = 16f
|
var textSize = tempPaint.textSize
|
||||||
|
var textWidth = tempPaint.measureText(value)
|
||||||
// Background arc
|
while (textWidth > maxTextWidth && textSize > 36f) {
|
||||||
val bgPaint = Paint().apply {
|
textSize -= 2f
|
||||||
color = Color.parseColor("#40FFFFFF")
|
tempPaint.textSize = textSize
|
||||||
style = Paint.Style.STROKE
|
textWidth = tempPaint.measureText(value)
|
||||||
this.strokeWidth = strokeWidth
|
|
||||||
isAntiAlias = true
|
|
||||||
strokeCap = Paint.Cap.ROUND
|
|
||||||
}
|
}
|
||||||
|
canvas.drawText(value, x, y, tempPaint)
|
||||||
// Success arc
|
canvas.drawText(label, x, y + 50f, labelPaint)
|
||||||
val successPaint = Paint().apply {
|
|
||||||
color = Color.parseColor("#4CAF50")
|
|
||||||
style = Paint.Style.STROKE
|
|
||||||
this.strokeWidth = strokeWidth
|
|
||||||
isAntiAlias = true
|
|
||||||
strokeCap = Paint.Cap.ROUND
|
|
||||||
}
|
|
||||||
|
|
||||||
val rect = RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius)
|
|
||||||
|
|
||||||
// Draw background arc (full circle)
|
|
||||||
canvas.drawArc(rect, -90f, 360f, false, bgPaint)
|
|
||||||
|
|
||||||
// Draw success arc
|
|
||||||
val sweepAngle = (successRate / 100f) * 360f
|
|
||||||
canvas.drawArc(rect, -90f, sweepAngle, false, successPaint)
|
|
||||||
|
|
||||||
// Draw percentage text
|
|
||||||
val percentText = "${successRate.roundToInt()}%"
|
|
||||||
canvas.drawText(percentText, centerX, centerY + 10f, valuePaint)
|
|
||||||
canvas.drawText("Success Rate", centerX, centerY + 60f, labelPaint)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a range string like "X - Y" for the given problems, based on their difficulty grades.
|
||||||
|
*/
|
||||||
|
private fun gradeRangeForProblems(problems: List<Problem>): String? {
|
||||||
|
if (problems.isEmpty()) return null
|
||||||
|
val grades = problems.map { it.difficulty }
|
||||||
|
val sorted = grades.sortedWith { a, b -> a.compareTo(b) }
|
||||||
|
return "${sorted.first().grade} - ${sorted.last().grade}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private fun formatSessionDate(dateString: String): String {
|
private fun formatSessionDate(dateString: String): String {
|
||||||
return try {
|
return try {
|
||||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||||
val date = LocalDateTime.parse(dateString, formatter)
|
val date = LocalDateTime.parse(dateString, formatter)
|
||||||
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
|
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
|
||||||
date.format(displayFormatter)
|
date.format(displayFormatter)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
dateString.take(10)
|
dateString.take(10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,4 +404,48 @@ object SessionShareUtils {
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the highest grade string among the given problems, respecting their difficulty system.
|
||||||
|
*/
|
||||||
|
private fun highestGradeForProblems(problems: List<Problem>): String? {
|
||||||
|
if (problems.isEmpty()) return null
|
||||||
|
return problems.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }?.difficulty?.grade
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a comparable numeric rank for grades across supported systems.
|
||||||
|
*/
|
||||||
|
private fun gradeRank(system: DifficultySystem, grade: String): Double {
|
||||||
|
return when (system) {
|
||||||
|
DifficultySystem.V_SCALE -> {
|
||||||
|
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
|
||||||
|
}
|
||||||
|
DifficultySystem.FONT -> {
|
||||||
|
val list = DifficultySystem.FONT.getAvailableGrades()
|
||||||
|
val idx = list.indexOf(grade.uppercase())
|
||||||
|
if (idx >= 0) idx.toDouble() else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
|
||||||
|
}
|
||||||
|
DifficultySystem.YDS -> {
|
||||||
|
// Parse 5.X with optional letter a-d
|
||||||
|
val s = grade.lowercase()
|
||||||
|
if (!s.startsWith("5.")) return -1.0
|
||||||
|
val tail = s.removePrefix("5.")
|
||||||
|
val numberPart = tail.takeWhile { it.isDigit() || it == '.' }
|
||||||
|
val letterPart = tail.drop(numberPart.length).firstOrNull()
|
||||||
|
val base = numberPart.toDoubleOrNull() ?: return -1.0
|
||||||
|
val letterWeight = when (letterPart) {
|
||||||
|
'a' -> 0.0
|
||||||
|
'b' -> 0.1
|
||||||
|
'c' -> 0.2
|
||||||
|
'd' -> 0.3
|
||||||
|
else -> 0.0
|
||||||
|
}
|
||||||
|
base + letterWeight
|
||||||
|
}
|
||||||
|
DifficultySystem.CUSTOM -> {
|
||||||
|
grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() ?: -1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
110
app/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt
Normal file
110
app/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.atridad.openclimb.utils
|
package com.atridad.openclimb.utils
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
@@ -16,6 +15,7 @@ object ZipExportImportUtils {
|
|||||||
|
|
||||||
private const val DATA_JSON_FILENAME = "data.json"
|
private const val DATA_JSON_FILENAME = "data.json"
|
||||||
private const val IMAGES_DIR_NAME = "images"
|
private const val IMAGES_DIR_NAME = "images"
|
||||||
|
private const val METADATA_FILENAME = "metadata.txt"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ZIP file containing the JSON data and all referenced images
|
* Creates a ZIP file containing the JSON data and all referenced images
|
||||||
@@ -39,37 +39,67 @@ object ZipExportImportUtils {
|
|||||||
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
|
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
|
||||||
val zipFile = File(exportDir, "openclimb_export_$timestamp.zip")
|
val zipFile = File(exportDir, "openclimb_export_$timestamp.zip")
|
||||||
|
|
||||||
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
|
try {
|
||||||
// Add JSON data file
|
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
|
||||||
val json = Json { prettyPrint = true }
|
// Add metadata file first
|
||||||
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
|
val metadata = createMetadata(exportData, referencedImagePaths)
|
||||||
|
val metadataEntry = ZipEntry(METADATA_FILENAME)
|
||||||
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
|
zipOut.putNextEntry(metadataEntry)
|
||||||
zipOut.putNextEntry(jsonEntry)
|
zipOut.write(metadata.toByteArray())
|
||||||
zipOut.write(jsonString.toByteArray())
|
zipOut.closeEntry()
|
||||||
zipOut.closeEntry()
|
|
||||||
|
// Add JSON data file
|
||||||
// Add images
|
val json = Json {
|
||||||
referencedImagePaths.forEach { imagePath ->
|
prettyPrint = true
|
||||||
try {
|
ignoreUnknownKeys = true
|
||||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
|
||||||
if (imageFile.exists()) {
|
|
||||||
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
|
|
||||||
zipOut.putNextEntry(imageEntry)
|
|
||||||
|
|
||||||
FileInputStream(imageFile).use { imageInput ->
|
|
||||||
imageInput.copyTo(zipOut)
|
|
||||||
}
|
|
||||||
zipOut.closeEntry()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Log error but continue with other images
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
}
|
||||||
|
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
|
||||||
|
|
||||||
|
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
|
||||||
|
zipOut.putNextEntry(jsonEntry)
|
||||||
|
zipOut.write(jsonString.toByteArray())
|
||||||
|
zipOut.closeEntry()
|
||||||
|
|
||||||
|
// Add images with validation
|
||||||
|
var successfulImages = 0
|
||||||
|
referencedImagePaths.forEach { imagePath ->
|
||||||
|
try {
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
if (imageFile.exists() && imageFile.length() > 0) {
|
||||||
|
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
|
||||||
|
zipOut.putNextEntry(imageEntry)
|
||||||
|
|
||||||
|
FileInputStream(imageFile).use { imageInput ->
|
||||||
|
imageInput.copyTo(zipOut)
|
||||||
|
}
|
||||||
|
zipOut.closeEntry()
|
||||||
|
successfulImages++
|
||||||
|
} else {
|
||||||
|
android.util.Log.w("ZipExportImportUtils", "Image file not found or empty: $imagePath")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log export summary
|
||||||
|
android.util.Log.i("ZipExportImportUtils", "Export completed: ${successfulImages}/${referencedImagePaths.size} images included")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the created ZIP file
|
||||||
|
if (!zipFile.exists() || zipFile.length() == 0L) {
|
||||||
|
throw IOException("Failed to create ZIP file: file is empty or doesn't exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
return zipFile
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Clean up failed export
|
||||||
|
if (zipFile.exists()) {
|
||||||
|
zipFile.delete()
|
||||||
|
}
|
||||||
|
throw IOException("Failed to create export ZIP: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
return zipFile
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,37 +115,73 @@ object ZipExportImportUtils {
|
|||||||
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
|
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
|
||||||
referencedImagePaths: Set<String>
|
referencedImagePaths: Set<String>
|
||||||
) {
|
) {
|
||||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
try {
|
||||||
ZipOutputStream(outputStream).use { zipOut ->
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
// Add JSON data file
|
ZipOutputStream(outputStream).use { zipOut ->
|
||||||
val json = Json { prettyPrint = true }
|
// Add metadata file first
|
||||||
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
|
val metadata = createMetadata(exportData, referencedImagePaths)
|
||||||
|
val metadataEntry = ZipEntry(METADATA_FILENAME)
|
||||||
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
|
zipOut.putNextEntry(metadataEntry)
|
||||||
zipOut.putNextEntry(jsonEntry)
|
zipOut.write(metadata.toByteArray())
|
||||||
zipOut.write(jsonString.toByteArray())
|
zipOut.closeEntry()
|
||||||
zipOut.closeEntry()
|
|
||||||
|
// Add JSON data file
|
||||||
// Add images
|
val json = Json {
|
||||||
referencedImagePaths.forEach { imagePath ->
|
prettyPrint = true
|
||||||
try {
|
ignoreUnknownKeys = true
|
||||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
|
||||||
if (imageFile.exists()) {
|
|
||||||
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
|
|
||||||
zipOut.putNextEntry(imageEntry)
|
|
||||||
|
|
||||||
FileInputStream(imageFile).use { imageInput ->
|
|
||||||
imageInput.copyTo(zipOut)
|
|
||||||
}
|
|
||||||
zipOut.closeEntry()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Log error but continue with other images
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
}
|
||||||
|
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
|
||||||
|
|
||||||
|
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
|
||||||
|
zipOut.putNextEntry(jsonEntry)
|
||||||
|
zipOut.write(jsonString.toByteArray())
|
||||||
|
zipOut.closeEntry()
|
||||||
|
|
||||||
|
// Add images with validation
|
||||||
|
var successfulImages = 0
|
||||||
|
referencedImagePaths.forEach { imagePath ->
|
||||||
|
try {
|
||||||
|
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||||
|
if (imageFile.exists() && imageFile.length() > 0) {
|
||||||
|
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
|
||||||
|
zipOut.putNextEntry(imageEntry)
|
||||||
|
|
||||||
|
FileInputStream(imageFile).use { imageInput ->
|
||||||
|
imageInput.copyTo(zipOut)
|
||||||
|
}
|
||||||
|
zipOut.closeEntry()
|
||||||
|
successfulImages++
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.i("ZipExportImportUtils", "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included")
|
||||||
}
|
}
|
||||||
}
|
} ?: throw IOException("Could not open output stream")
|
||||||
} ?: throw IOException("Could not open output stream")
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IOException("Failed to create export ZIP to URI: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMetadata(
|
||||||
|
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
|
||||||
|
referencedImagePaths: Set<String>
|
||||||
|
): String {
|
||||||
|
return buildString {
|
||||||
|
appendLine("OpenClimb Export Metadata")
|
||||||
|
appendLine("=======================")
|
||||||
|
appendLine("Export Date: ${exportData.exportedAt}")
|
||||||
|
appendLine("Version: ${exportData.version}")
|
||||||
|
appendLine("Gyms: ${exportData.gyms.size}")
|
||||||
|
appendLine("Problems: ${exportData.problems.size}")
|
||||||
|
appendLine("Sessions: ${exportData.sessions.size}")
|
||||||
|
appendLine("Attempts: ${exportData.attempts.size}")
|
||||||
|
appendLine("Referenced Images: ${referencedImagePaths.size}")
|
||||||
|
appendLine("Format: ZIP with embedded JSON data and images")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,59 +200,91 @@ object ZipExportImportUtils {
|
|||||||
*/
|
*/
|
||||||
fun extractImportZip(context: Context, zipFile: File): ImportResult {
|
fun extractImportZip(context: Context, zipFile: File): ImportResult {
|
||||||
var jsonContent = ""
|
var jsonContent = ""
|
||||||
|
var metadataContent = ""
|
||||||
val importedImagePaths = mutableMapOf<String, String>()
|
val importedImagePaths = mutableMapOf<String, String>()
|
||||||
|
var foundRequiredFiles = mutableSetOf<String>()
|
||||||
|
|
||||||
ZipInputStream(FileInputStream(zipFile)).use { zipIn ->
|
try {
|
||||||
var entry = zipIn.nextEntry
|
ZipInputStream(FileInputStream(zipFile)).use { zipIn ->
|
||||||
|
var entry = zipIn.nextEntry
|
||||||
while (entry != null) {
|
|
||||||
when {
|
while (entry != null) {
|
||||||
entry.name == DATA_JSON_FILENAME -> {
|
when {
|
||||||
// Read JSON data
|
entry.name == METADATA_FILENAME -> {
|
||||||
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
|
// Read metadata for validation
|
||||||
|
metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
|
||||||
|
foundRequiredFiles.add("metadata")
|
||||||
|
android.util.Log.i("ZipExportImportUtils", "Found metadata: ${metadataContent.lines().take(3).joinToString()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.name == DATA_JSON_FILENAME -> {
|
||||||
|
// Read JSON data
|
||||||
|
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
|
||||||
|
foundRequiredFiles.add("data")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
|
||||||
|
// Extract image file
|
||||||
|
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create temporary file to hold the extracted image
|
||||||
|
val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir)
|
||||||
|
|
||||||
|
FileOutputStream(tempFile).use { output ->
|
||||||
|
zipIn.copyTo(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the extracted image
|
||||||
|
if (tempFile.exists() && tempFile.length() > 0) {
|
||||||
|
// Import the image to permanent storage
|
||||||
|
val newPath = ImageUtils.importImageFile(context, tempFile)
|
||||||
|
if (newPath != null) {
|
||||||
|
importedImagePaths[originalFilename] = newPath
|
||||||
|
android.util.Log.d("ZipExportImportUtils", "Successfully imported image: $originalFilename -> $newPath")
|
||||||
|
} else {
|
||||||
|
android.util.Log.w("ZipExportImportUtils", "Failed to import image: $originalFilename")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
android.util.Log.w("ZipExportImportUtils", "Extracted image is empty: $originalFilename")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file
|
||||||
|
tempFile.delete()
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ZipExportImportUtils", "Failed to process image $originalFilename: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
android.util.Log.d("ZipExportImportUtils", "Skipping ZIP entry: ${entry.name}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
|
zipIn.closeEntry()
|
||||||
// Extract image file
|
entry = zipIn.nextEntry
|
||||||
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
|
|
||||||
|
|
||||||
// Create temporary file to hold the extracted image
|
|
||||||
val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir)
|
|
||||||
|
|
||||||
FileOutputStream(tempFile).use { output ->
|
|
||||||
zipIn.copyTo(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import the image to permanent storage
|
|
||||||
val newPath = ImageUtils.importImageFile(context, tempFile)
|
|
||||||
if (newPath != null) {
|
|
||||||
importedImagePaths[originalFilename] = newPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up temp file
|
|
||||||
tempFile.delete()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
zipIn.closeEntry()
|
|
||||||
entry = zipIn.nextEntry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate that we found the required files
|
||||||
|
if (!foundRequiredFiles.contains("data")) {
|
||||||
|
throw IOException("Invalid ZIP file: data.json not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonContent.isBlank()) {
|
||||||
|
throw IOException("Invalid ZIP file: data.json is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.i("ZipExportImportUtils", "Import extraction completed: ${importedImagePaths.size} images processed")
|
||||||
|
|
||||||
|
return ImportResult(jsonContent, importedImagePaths)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IOException("Failed to extract import ZIP: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jsonContent.isEmpty()) {
|
|
||||||
throw IOException("No data.json file found in the ZIP archive")
|
|
||||||
}
|
|
||||||
|
|
||||||
return ImportResult(jsonContent, importedImagePaths)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to determine if a file is a ZIP file based on extension
|
|
||||||
*/
|
|
||||||
fun isZipFile(filename: String): Boolean {
|
|
||||||
return filename.lowercase().endsWith(".zip")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates image paths in a problem list after import
|
* Updates image paths in a problem list after import
|
||||||
* This function maps the old image paths to the new ones after import
|
* This function maps the old image paths to the new ones after import
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/src/main/res/drawable-night/ic_play_arrow_24.xml
Normal file
9
app/src/main/res/drawable-night/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="#81C784"
|
||||||
|
android:pathData="M8,5v14l11,-7z"/>
|
||||||
|
</vector>
|
||||||
9
app/src/main/res/drawable-night/ic_stop_24.xml
Normal file
9
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>
|
||||||
@@ -4,167 +4,8 @@
|
|||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
|
||||||
android:fillColor="#3DDC84"
|
<!-- Clean white background -->
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
<path android:fillColor="#FFFFFF"
|
||||||
<path
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
android:fillColor="#00000000"
|
</vector>
|
||||||
android:pathData="M9,0L9,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,0L19,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,0L29,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,30 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
<group
|
||||||
<gradient
|
android:scaleX="0.7"
|
||||||
android:endX="85.84757"
|
android:scaleY="0.7"
|
||||||
android:endY="92.4963"
|
android:translateX="16.2"
|
||||||
android:startX="42.9492"
|
android:translateY="20">
|
||||||
android:startY="49.59793"
|
|
||||||
android:type="linear">
|
<!-- Left mountain (yellow/amber) -->
|
||||||
<item
|
<path
|
||||||
android:color="#44000000"
|
android:fillColor="#FFC107"
|
||||||
android:offset="0.0" />
|
android:strokeColor="#1C1C1C"
|
||||||
<item
|
android:strokeWidth="3"
|
||||||
android:color="#00000000"
|
android:strokeLineJoin="round"
|
||||||
android:offset="1.0" />
|
android:pathData="M15,70 L35,25 L55,70 Z" />
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
<!-- Right mountain (red) -->
|
||||||
</path>
|
<path
|
||||||
<path
|
android:fillColor="#F44336"
|
||||||
android:fillColor="#FFFFFF"
|
android:strokeColor="#1C1C1C"
|
||||||
android:fillType="nonZero"
|
android:strokeWidth="3"
|
||||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
android:strokeLineJoin="round"
|
||||||
android:strokeWidth="1"
|
android:pathData="M40,70 L65,15 L90,70 Z" />
|
||||||
android:strokeColor="#00000000" />
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
32
app/src/main/res/drawable/ic_mountains.xml
Normal file
32
app/src/main/res/drawable/ic_mountains.xml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<!-- Left mountain (yellow/amber) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFC107"
|
||||||
|
android:pathData="M3,18 L8,9 L13,18 Z" />
|
||||||
|
|
||||||
|
<!-- Right mountain (red) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#F44336"
|
||||||
|
android:pathData="M11,18 L16,7 L21,18 Z" />
|
||||||
|
|
||||||
|
<!-- Black outlines -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="#1C1C1C"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M3,18 L8,9 L13,18" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="#1C1C1C"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M11,18 L16,7 L21,18" />
|
||||||
|
</vector>
|
||||||
9
app/src/main/res/drawable/ic_play_arrow_24.xml
Normal file
9
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
app/src/main/res/drawable/ic_stop_24.xml
Normal file
9
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
app/src/main/res/drawable/widget_background.xml
Normal file
15
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>
|
||||||
15
app/src/main/res/drawable/widget_stat_card_background.xml
Normal file
15
app/src/main/res/drawable/widget_stat_card_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_surface" />
|
||||||
|
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
|
||||||
|
<stroke
|
||||||
|
android:width="0.5dp"
|
||||||
|
android:color="@color/widget_outline" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</shape>
|
||||||
19
app/src/main/res/drawable/widget_status_background.xml
Normal file
19
app/src/main/res/drawable/widget_status_background.xml
Normal file
@@ -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
app/src/main/res/layout/widget_climb_stats.xml
Normal file
195
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>
|
||||||
15
app/src/main/res/values-night/colors.xml
Normal file
15
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>
|
||||||
@@ -7,4 +7,17 @@
|
|||||||
<color name="teal_700">#FF018786</color>
|
<color name="teal_700">#FF018786</color>
|
||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
</resources>
|
|
||||||
|
<!-- 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>
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">OpenClimb</string>
|
<string name="app_name">OpenClimb</string>
|
||||||
</resources>
|
<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>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
|
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
|
||||||
|
<style name="Theme.OpenClimb.Splash" parent="Theme.OpenClimb">
|
||||||
|
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||||
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
|
||||||
|
<item name="android:windowSplashScreenAnimationDuration">200</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
17
app/src/main/res/xml/widget_climb_stats_info.xml
Normal file
17
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" />
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,26 +1,34 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.9.1"
|
agp = "8.12.2"
|
||||||
kotlin = "2.0.21"
|
kotlin = "2.2.10"
|
||||||
coreKtx = "1.15.0"
|
coreKtx = "1.17.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
espressoCore = "3.7.0"
|
espressoCore = "3.7.0"
|
||||||
lifecycleRuntimeKtx = "2.9.2"
|
androidxTestCore = "1.7.0"
|
||||||
|
androidxTestExt = "1.3.0"
|
||||||
|
androidxTestRunner = "1.7.0"
|
||||||
|
androidxTestRules = "1.7.0"
|
||||||
|
lifecycleRuntimeKtx = "2.9.3"
|
||||||
activityCompose = "1.10.1"
|
activityCompose = "1.10.1"
|
||||||
composeBom = "2024.09.00"
|
composeBom = "2025.08.01"
|
||||||
room = "2.6.1"
|
room = "2.7.2"
|
||||||
navigation = "2.8.4"
|
navigation = "2.9.3"
|
||||||
viewmodel = "2.9.2"
|
viewmodel = "2.9.3"
|
||||||
kotlinxSerialization = "1.7.1"
|
kotlinxSerialization = "1.9.0"
|
||||||
kotlinxCoroutines = "1.9.0"
|
kotlinxCoroutines = "1.10.2"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
ksp = "2.0.21-1.0.25"
|
ksp = "2.2.10-2.0.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
|
||||||
|
androidx-test-ext = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
|
||||||
|
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
|
||||||
|
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
@@ -48,12 +56,15 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
|
|||||||
|
|
||||||
# Coroutines
|
# Coroutines
|
||||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
mockk = { group = "io.mockk", name = "mockk", version = "1.13.8" }
|
||||||
|
|
||||||
# Image Loading
|
# Image Loading
|
||||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
|
|
||||||
# Charts - MPAndroidChart for now, will be replaced with Vico when stable
|
|
||||||
mpandroidchart = { group = "com.github.PhilJay", name = "MPAndroidChart", version = "v3.1.0" }
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
|||||||
#Fri Aug 15 11:23:25 MDT 2025
|
#Fri Aug 15 11:23:25 MDT 2025
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
Reference in New Issue
Block a user