Compare commits
21 Commits
87195aabf1
...
0.4.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
4eef77bd3b
|
|||
|
2d957db948
|
|||
| 22bed6a961 | |||
|
b443c18a19
|
|||
|
89f1e350b3
|
|||
|
0f976f685f
|
|||
|
c07186a7df
|
|||
|
15a5e217a5
|
|||
|
b86ab591fe
|
|||
|
70c85d159e
|
|||
|
d6c5e937df
|
|||
|
829bbbff7a
|
|||
|
e1ebf412bd
|
|||
|
5c133b655e
|
|||
|
cc1edbc65c
|
|||
|
ca770b9db3
|
|||
|
7edb7c8191
|
|||
|
1ca6b33882
|
|||
|
bd6b5cc652
|
|||
|
6e16a30429
|
|||
|
66fdef78d9
|
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
release/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
|
||||
# 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.
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -1,11 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="gradleJvm" value="#JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
|
||||
7
.idea/misc.xml
generated
7
.idea/misc.xml
generated
@@ -1,10 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" />
|
||||
</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"
|
||||
}
|
||||
@@ -7,7 +7,7 @@ This is a FOSS Android app meant to help climbers track their sessions, routes/p
|
||||
You have two options:
|
||||
|
||||
1. Download the latest APK from the Released page
|
||||
2. Use <a href="">Obtainium</a>
|
||||
2. Use <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D">Obtainium</a>
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -8,21 +8,21 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.atridad.openclimb"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.atridad.openclimb"
|
||||
minSdk = 31
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
targetSdk = 36
|
||||
versionCode = 12
|
||||
versionName = "0.4.5"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
@@ -30,12 +30,20 @@ android {
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
// Ensure consistent JVM toolchain across all tasks
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
@@ -79,8 +87,14 @@ dependencies {
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
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(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
@@ -9,12 +9,10 @@ import java.time.LocalDateTime
|
||||
|
||||
@Serializable
|
||||
enum class AttemptResult {
|
||||
SUCCESS, // Completed the problem/route
|
||||
FALL, // Fell but made progress
|
||||
NO_PROGRESS, // Couldn't make meaningful progress
|
||||
FLASH, // Completed on first try
|
||||
REDPOINT, // Completed after previous attempts
|
||||
ONSIGHT // Completed on first try without prior knowledge
|
||||
SUCCESS,
|
||||
FALL,
|
||||
NO_PROGRESS,
|
||||
FLASH,
|
||||
}
|
||||
|
||||
@Entity(
|
||||
|
||||
@@ -31,10 +31,10 @@ data class ClimbSession(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val gymId: String,
|
||||
val date: String, // ISO date string
|
||||
val startTime: String? = null, // When session was started
|
||||
val endTime: String? = null, // When session was completed
|
||||
val duration: Long? = null, // Duration in minutes (calculated when completed)
|
||||
val date: String,
|
||||
val startTime: String? = null,
|
||||
val endTime: String? = null,
|
||||
val duration: Long? = null,
|
||||
val status: SessionStatus = SessionStatus.ACTIVE,
|
||||
val notes: String? = null,
|
||||
val createdAt: String,
|
||||
@@ -65,7 +65,7 @@ data class ClimbSession(
|
||||
val start = LocalDateTime.parse(startTime)
|
||||
val end = LocalDateTime.parse(endTime)
|
||||
java.time.Duration.between(start, end).toMinutes()
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
} else null
|
||||
|
||||
@@ -5,5 +5,13 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
enum class ClimbType {
|
||||
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
|
||||
enum class DifficultySystem {
|
||||
// Rope climbing systems
|
||||
YDS, // Yosemite Decimal System (5.1 - 5.15d)
|
||||
FRENCH, // French system (3 - 9c+)
|
||||
UIAA, // UIAA system (I - XII+)
|
||||
BRITISH, // British system (Mod - E11)
|
||||
// Bouldering
|
||||
V_SCALE, // V-Scale (VB - V17)
|
||||
FONT, // Fontainebleau (3 - 8C+)
|
||||
|
||||
// Bouldering systems
|
||||
V_SCALE, // V-Scale (VB - V17)
|
||||
FONT, // Fontainebleau (3 - 9A+)
|
||||
// Rope
|
||||
YDS, // Yosemite Decimal System (5.0 - 5.15d)
|
||||
|
||||
// Custom system for gyms that use their own colors/naming
|
||||
CUSTOM
|
||||
// Custom difficulty systems
|
||||
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
|
||||
data class DifficultyGrade(
|
||||
val system: DifficultySystem,
|
||||
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 location: String? = null,
|
||||
val supportedClimbTypes: List<ClimbType>,
|
||||
val difficultySystems: List<DifficultySystem>, // What systems this gym uses
|
||||
val customDifficultyGrades: List<String> = emptyList(), // For gyms using colors/custom names
|
||||
val difficultySystems: List<DifficultySystem>,
|
||||
val customDifficultyGrades: List<String> = emptyList(),
|
||||
val notes: String? = null,
|
||||
val createdAt: String, // ISO string format for serialization
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
) {
|
||||
companion object {
|
||||
|
||||
@@ -28,12 +28,12 @@ data class Problem(
|
||||
val description: String? = null,
|
||||
val climbType: ClimbType,
|
||||
val difficulty: DifficultyGrade,
|
||||
val setter: String? = null, // Route setter name
|
||||
val tags: List<String> = emptyList(), // e.g., "overhang", "slab", "crimpy"
|
||||
val location: String? = null, // Wall section, area in gym
|
||||
val imagePaths: List<String> = emptyList(), // Local file paths to photos
|
||||
val isActive: Boolean = true, // Whether the problem is still up
|
||||
val dateSet: String? = null, // When the problem was set
|
||||
val setter: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
val location: String? = null,
|
||||
val imagePaths: List<String> = emptyList(),
|
||||
val isActive: Boolean = true,
|
||||
val dateSet: String? = null,
|
||||
val notes: String? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
import com.atridad.openclimb.utils.ZipExportImportUtils
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -14,7 +13,7 @@ import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class ClimbRepository(
|
||||
private val database: OpenClimbDatabase,
|
||||
database: OpenClimbDatabase,
|
||||
private val context: Context
|
||||
) {
|
||||
private val gymDao = database.gymDao()
|
||||
@@ -40,7 +39,6 @@ class ClimbRepository(
|
||||
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
|
||||
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
|
||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
|
||||
fun getActiveProblems(): Flow<List<Problem>> = problemDao.getActiveProblems()
|
||||
suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem)
|
||||
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
|
||||
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
|
||||
@@ -50,17 +48,14 @@ class ClimbRepository(
|
||||
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
|
||||
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
|
||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = sessionDao.getSessionsByGym(gymId)
|
||||
fun getRecentSessions(limit: Int = 10): Flow<List<ClimbSession>> = sessionDao.getRecentSessions(limit)
|
||||
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
|
||||
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
|
||||
fun getSessionsByStatus(status: SessionStatus): Flow<List<ClimbSession>> = sessionDao.getSessionsByStatus(status)
|
||||
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
|
||||
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
|
||||
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
|
||||
|
||||
// Attempt operations
|
||||
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
|
||||
suspend fun getAttemptById(id: String): Attempt? = attemptDao.getAttemptById(id)
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = attemptDao.getAttemptsBySession(sessionId)
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = attemptDao.getAttemptsByProblem(problemId)
|
||||
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
|
||||
@@ -69,7 +64,7 @@ class ClimbRepository(
|
||||
|
||||
|
||||
|
||||
// JSON Export functionality
|
||||
// JSON Export
|
||||
suspend fun exportAllDataToJson(directory: File? = null): File {
|
||||
val exportDir = directory ?: File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "OpenClimb")
|
||||
if (!exportDir.exists()) {
|
||||
@@ -124,12 +119,12 @@ class ClimbRepository(
|
||||
val jsonContent = file.readText()
|
||||
val importData = json.decodeFromString<ClimbDataExport>(jsonContent)
|
||||
|
||||
// Import gyms (replace if exists due to primary key constraint)
|
||||
// Import gyms
|
||||
importData.gyms.forEach { gym ->
|
||||
try {
|
||||
gymDao.insertGym(gym)
|
||||
} catch (e: Exception) {
|
||||
// If insertion fails due to primary key conflict, update instead
|
||||
} catch (_: Exception) {
|
||||
// If insertion fails, update instead
|
||||
gymDao.updateGym(gym)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +133,7 @@ class ClimbRepository(
|
||||
importData.problems.forEach { problem ->
|
||||
try {
|
||||
problemDao.insertProblem(problem)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
problemDao.updateProblem(problem)
|
||||
}
|
||||
}
|
||||
@@ -147,7 +142,7 @@ class ClimbRepository(
|
||||
importData.sessions.forEach { session ->
|
||||
try {
|
||||
sessionDao.insertSession(session)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
sessionDao.updateSession(session)
|
||||
}
|
||||
}
|
||||
@@ -156,7 +151,7 @@ class ClimbRepository(
|
||||
importData.attempts.forEach { attempt ->
|
||||
try {
|
||||
attemptDao.insertAttempt(attempt)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
attemptDao.updateAttempt(attempt)
|
||||
}
|
||||
}
|
||||
@@ -166,7 +161,7 @@ class ClimbRepository(
|
||||
}
|
||||
}
|
||||
|
||||
// ZIP Export functionality with images
|
||||
// ZIP Export with images
|
||||
suspend fun exportAllDataToZip(directory: File? = null): File {
|
||||
val allGyms = gymDao.getAllGyms().first()
|
||||
val allProblems = problemDao.getAllProblems().first()
|
||||
@@ -206,7 +201,7 @@ class ClimbRepository(
|
||||
attempts = attempts
|
||||
)
|
||||
|
||||
// Collect all referenced image paths
|
||||
// Collect all image paths
|
||||
val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet()
|
||||
|
||||
ZipExportImportUtils.createExportZipToUri(
|
||||
@@ -228,12 +223,12 @@ class ClimbRepository(
|
||||
importResult.importedImagePaths
|
||||
)
|
||||
|
||||
// Import gyms (replace if exists due to primary key constraint)
|
||||
// Import gyms
|
||||
importData.gyms.forEach { gym ->
|
||||
try {
|
||||
gymDao.insertGym(gym)
|
||||
} catch (e: Exception) {
|
||||
// If insertion fails due to primary key conflict, update instead
|
||||
// If insertion fails update instead
|
||||
gymDao.updateGym(gym)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.atridad.openclimb.MainActivity
|
||||
@@ -16,7 +15,6 @@ import com.atridad.openclimb.data.repository.ClimbRepository
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class SessionTrackingService : Service() {
|
||||
@@ -40,9 +38,10 @@ class SessionTrackingService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
fun createStopIntent(context: Context): Intent {
|
||||
fun createStopIntent(context: Context, sessionId: String): Intent {
|
||||
return Intent(context, SessionTrackingService::class.java).apply {
|
||||
action = ACTION_STOP_SESSION
|
||||
putExtra(EXTRA_SESSION_ID, sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +64,21 @@ class SessionTrackingService : Service() {
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -76,9 +89,13 @@ class SessionTrackingService : Service() {
|
||||
private fun startSessionTracking(sessionId: String) {
|
||||
notificationJob?.cancel()
|
||||
notificationJob = serviceScope.launch {
|
||||
// Initial notification update
|
||||
updateNotification(sessionId)
|
||||
|
||||
// Then update every second
|
||||
while (isActive) {
|
||||
delay(1000L)
|
||||
updateNotification(sessionId)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,40 +121,46 @@ class SessionTrackingService : Service() {
|
||||
try {
|
||||
val start = LocalDateTime.parse(startTime)
|
||||
val now = LocalDateTime.now()
|
||||
val minutes = ChronoUnit.MINUTES.between(start, now)
|
||||
val hours = minutes / 60
|
||||
val remainingMinutes = minutes % 60
|
||||
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
|
||||
when {
|
||||
hours > 0 -> "${hours}h ${remainingMinutes}m"
|
||||
remainingMinutes > 0 -> "${remainingMinutes}m"
|
||||
else -> "< 1m"
|
||||
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||
else -> "${totalSeconds}s"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
"Active"
|
||||
}
|
||||
} ?: "Active"
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("OpenClimb Session Active")
|
||||
.setContentTitle("Climbing Session Active")
|
||||
.setContentText("${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts")
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setSmallIcon(R.drawable.ic_mountains)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setContentIntent(createOpenAppIntent())
|
||||
.addAction(
|
||||
R.drawable.ic_launcher_foreground,
|
||||
R.drawable.ic_mountains,
|
||||
"Open Session",
|
||||
createOpenAppIntent()
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_launcher_foreground,
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
"End Session",
|
||||
createStopIntent()
|
||||
createStopPendingIntent(sessionId)
|
||||
)
|
||||
.build()
|
||||
|
||||
// Force update the notification every second
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// Handle errors gracefully
|
||||
stopSessionTracking()
|
||||
}
|
||||
@@ -155,8 +178,8 @@ class SessionTrackingService : Service() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun createStopIntent(): PendingIntent {
|
||||
val intent = createStopIntent(this)
|
||||
private fun createStopPendingIntent(sessionId: String): PendingIntent {
|
||||
val intent = createStopIntent(this, sessionId)
|
||||
return PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
@@ -166,19 +189,17 @@ class SessionTrackingService : Service() {
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Session Tracking",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows active climbing session information"
|
||||
setShowBadge(false)
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Session Tracking",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows active climbing session information"
|
||||
setShowBadge(false)
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@@ -27,8 +27,6 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
|
||||
fun OpenClimbApp() {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = currentBackStackEntry?.destination?.route
|
||||
|
||||
val database = remember { OpenClimbDatabase.getDatabase(context) }
|
||||
val repository = remember { ClimbRepository(database, context) }
|
||||
@@ -87,9 +85,6 @@ fun OpenClimbApp() {
|
||||
viewModel = viewModel,
|
||||
onNavigateToSessionDetail = { sessionId ->
|
||||
navController.navigate(Screen.SessionDetail(sessionId))
|
||||
},
|
||||
onNavigateToAddSession = { gymId ->
|
||||
navController.navigate(Screen.AddEditSession(gymId = gymId))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -113,9 +108,6 @@ fun OpenClimbApp() {
|
||||
viewModel = viewModel,
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
},
|
||||
onNavigateToAddProblem = { gymId ->
|
||||
navController.navigate(Screen.AddEditProblem(gymId = gymId))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -141,9 +133,6 @@ fun OpenClimbApp() {
|
||||
viewModel = viewModel,
|
||||
onNavigateToGymDetail = { gymId ->
|
||||
navController.navigate(Screen.GymDetail(gymId))
|
||||
},
|
||||
onNavigateToAddGym = {
|
||||
navController.navigate(Screen.AddEditGym())
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -158,18 +147,17 @@ fun OpenClimbApp() {
|
||||
// Detail screens
|
||||
composable<Screen.SessionDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.SessionDetail>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
SessionDetailScreen(
|
||||
sessionId = args.sessionId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { sessionId ->
|
||||
navController.navigate(Screen.AddEditSession(sessionId = sessionId))
|
||||
}
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.ProblemDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
ProblemDetailScreen(
|
||||
problemId = args.problemId,
|
||||
viewModel = viewModel,
|
||||
@@ -182,6 +170,7 @@ fun OpenClimbApp() {
|
||||
|
||||
composable<Screen.GymDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.GymDetail>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
GymDetailScreen(
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
@@ -195,6 +184,7 @@ fun OpenClimbApp() {
|
||||
|
||||
composable<Screen.AddEditGym> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditGym>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AddEditGymScreen(
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
@@ -204,6 +194,7 @@ fun OpenClimbApp() {
|
||||
|
||||
composable<Screen.AddEditProblem> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AddEditProblemScreen(
|
||||
problemId = args.problemId,
|
||||
gymId = args.gymId,
|
||||
@@ -214,6 +205,7 @@ fun OpenClimbApp() {
|
||||
|
||||
composable<Screen.AddEditSession> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditSession>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AddEditSessionScreen(
|
||||
sessionId = args.sessionId,
|
||||
gymId = args.gymId,
|
||||
@@ -247,17 +239,15 @@ fun OpenClimbBottomNavigation(navController: NavHostController) {
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
navController.navigate(item.screen) {
|
||||
// Pop up to the start destination of the graph to
|
||||
// avoid building up a large stack of destinations
|
||||
// on the back stack as users select items
|
||||
popUpTo(Screen.Sessions) {
|
||||
saveState = true
|
||||
// Clear the entire back stack and go to the selected tab's root screen
|
||||
popUpTo(0) {
|
||||
inclusive = true
|
||||
}
|
||||
// Avoid multiple copies of the same destination when
|
||||
// reselecting the same item
|
||||
launchSingleTop = true
|
||||
// Restore state when reselecting a previously selected item
|
||||
restoreState = true
|
||||
// Don't restore state - always start fresh when switching tabs
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5,18 +5,17 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.data.model.ClimbSession
|
||||
import com.atridad.openclimb.data.model.Gym
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun ActiveSessionBanner(
|
||||
@@ -26,6 +25,16 @@ fun ActiveSessionBanner(
|
||||
onEndSession: () -> Unit
|
||||
) {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -69,7 +78,7 @@ fun ActiveSessionBanner(
|
||||
)
|
||||
|
||||
activeSession.startTime?.let { startTime ->
|
||||
val duration = calculateDuration(startTime)
|
||||
val duration = calculateDuration(startTime, currentTime)
|
||||
Text(
|
||||
text = duration,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -95,94 +104,20 @@ fun ActiveSessionBanner(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StartSessionButton(
|
||||
gyms: List<Gym>,
|
||||
onStartSession: (String) -> Unit
|
||||
) {
|
||||
var showGymSelection by remember { mutableStateOf(false) }
|
||||
|
||||
if (gyms.isEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "No gyms available",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Add a gym first to start a session",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { showGymSelection = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Start Session")
|
||||
}
|
||||
}
|
||||
|
||||
if (showGymSelection) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showGymSelection = false },
|
||||
title = { Text("Select Gym") },
|
||||
text = {
|
||||
Column {
|
||||
gyms.forEach { gym ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
onStartSession(gym.id)
|
||||
showGymSelection = false
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = gym.name,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showGymSelection = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateDuration(startTimeString: String): String {
|
||||
private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String {
|
||||
return try {
|
||||
val startTime = LocalDateTime.parse(startTimeString)
|
||||
val now = LocalDateTime.now()
|
||||
val minutes = ChronoUnit.MINUTES.between(startTime, now)
|
||||
val hours = minutes / 60
|
||||
val remainingMinutes = minutes % 60
|
||||
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
|
||||
when {
|
||||
hours > 0 -> "${hours}h ${remainingMinutes}m"
|
||||
remainingMinutes > 0 -> "${remainingMinutes}m"
|
||||
else -> "< 1m"
|
||||
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
|
||||
minutes > 0 -> "${minutes}m ${seconds}s"
|
||||
else -> "${totalSeconds}s"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
"Active"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ fun FullscreenImageViewer(
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
thumbnailListState.animateScrollToItem(
|
||||
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.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.atridad.openclimb.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -20,7 +19,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun ImagePicker(
|
||||
@@ -41,7 +39,7 @@ fun ImagePicker(
|
||||
val remainingSlots = maxImages - currentCount
|
||||
val urisToProcess = uris.take(remainingSlots)
|
||||
|
||||
// Process each selected image
|
||||
// Process images
|
||||
val newImagePaths = mutableListOf<String>()
|
||||
urisToProcess.forEach { uri ->
|
||||
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
||||
|
||||
@@ -5,10 +5,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -17,21 +16,12 @@ import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.ui.components.ImagePicker
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import kotlinx.coroutines.flow.first
|
||||
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)
|
||||
@Composable
|
||||
fun AddEditGymScreen(
|
||||
@@ -47,32 +37,56 @@ fun AddEditGymScreen(
|
||||
|
||||
val isEditing = gymId != null
|
||||
|
||||
// Calculate available difficulty systems based on selected climb types
|
||||
val availableDifficultySystems = if (selectedClimbTypes.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
selectedClimbTypes.flatMap { climbType ->
|
||||
DifficultySystem.getSystemsForClimbType(climbType)
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
// Reset selected difficulty systems when available systems change
|
||||
LaunchedEffect(availableDifficultySystems) {
|
||||
selectedDifficultySystems = selectedDifficultySystems.filter { it in availableDifficultySystems }.toSet()
|
||||
}
|
||||
|
||||
// Load existing gym data for editing
|
||||
LaunchedEffect(gymId) {
|
||||
if (gymId != null) {
|
||||
val existingGym = viewModel.getGymById(gymId).first()
|
||||
existingGym?.let { gym ->
|
||||
name = gym.name
|
||||
location = gym.location ?: ""
|
||||
notes = gym.notes ?: ""
|
||||
selectedClimbTypes = gym.supportedClimbTypes.toSet()
|
||||
selectedDifficultySystems = gym.difficultySystems.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(if (isEditing) "Edit Gym" else "Add Gym") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val gym = if (isEditing) {
|
||||
Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
|
||||
} else {
|
||||
Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
|
||||
}
|
||||
val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
|
||||
|
||||
if (isEditing) {
|
||||
viewModel.updateGym(gym)
|
||||
viewModel.updateGym(gym.copy(id = gymId))
|
||||
} else {
|
||||
viewModel.addGym(gym)
|
||||
}
|
||||
onNavigateBack()
|
||||
},
|
||||
enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty()
|
||||
enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty() && selectedDifficultySystems.isNotEmpty()
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
@@ -142,7 +156,7 @@ fun AddEditGymScreen(
|
||||
onCheckedChange = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() })
|
||||
Text(climbType.getDisplayName())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,29 +177,38 @@ fun AddEditGymScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
DifficultySystem.entries.forEach { system ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = system in selectedDifficultySystems,
|
||||
onClick = {
|
||||
selectedDifficultySystems = if (system in selectedDifficultySystems) {
|
||||
selectedDifficultySystems - system
|
||||
} else {
|
||||
selectedDifficultySystems + system
|
||||
}
|
||||
},
|
||||
role = Role.Checkbox
|
||||
if (selectedClimbTypes.isEmpty()) {
|
||||
Text(
|
||||
text = "Select climb types first to see available difficulty systems",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
} else {
|
||||
availableDifficultySystems.forEach { system ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = system in selectedDifficultySystems,
|
||||
onClick = {
|
||||
selectedDifficultySystems = if (system in selectedDifficultySystems) {
|
||||
selectedDifficultySystems - system
|
||||
} else {
|
||||
selectedDifficultySystems + system
|
||||
}
|
||||
},
|
||||
role = Role.Checkbox
|
||||
)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = system in selectedDifficultySystems,
|
||||
onCheckedChange = null
|
||||
)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = system in selectedDifficultySystems,
|
||||
onCheckedChange = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(system.name)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(system.getDisplayName())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +267,7 @@ fun AddEditProblemScreen(
|
||||
notes = p.notes ?: ""
|
||||
isActive = p.isActive
|
||||
imagePaths = p.imagePaths
|
||||
selectedGym = gyms.find { it.id == p.gymId }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,8 +278,39 @@ fun AddEditProblemScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val availableDifficultySystems = selectedGym?.difficultySystems ?: DifficultySystem.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(
|
||||
topBar = {
|
||||
@@ -263,7 +318,7 @@ fun AddEditProblemScreen(
|
||||
title = { Text(if (isEditing) "Edit Problem" else "Add Problem") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
@@ -293,7 +348,7 @@ fun AddEditProblemScreen(
|
||||
)
|
||||
|
||||
if (isEditing) {
|
||||
viewModel.updateProblem(problem.copy(id = problemId!!))
|
||||
viewModel.updateProblem(problem.copy(id = problemId))
|
||||
} else {
|
||||
viewModel.addProblem(problem)
|
||||
}
|
||||
@@ -437,7 +492,7 @@ fun AddEditProblemScreen(
|
||||
availableClimbTypes.forEach { climbType ->
|
||||
FilterChip(
|
||||
onClick = { selectedClimbType = climbType },
|
||||
label = { Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) },
|
||||
label = { Text(climbType.getDisplayName()) },
|
||||
selected = selectedClimbType == climbType
|
||||
)
|
||||
}
|
||||
@@ -476,7 +531,7 @@ fun AddEditProblemScreen(
|
||||
items(availableDifficultySystems) { system ->
|
||||
FilterChip(
|
||||
onClick = { selectedDifficultySystem = system },
|
||||
label = { Text(system.name) },
|
||||
label = { Text(system.getDisplayName()) },
|
||||
selected = selectedDifficultySystem == system
|
||||
)
|
||||
}
|
||||
@@ -484,23 +539,51 @@ fun AddEditProblemScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = difficultyGrade,
|
||||
onValueChange = { difficultyGrade = it },
|
||||
label = { Text("Grade *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
placeholder = {
|
||||
Text(when (selectedDifficultySystem) {
|
||||
DifficultySystem.V_SCALE -> "e.g., V0, V4, V10"
|
||||
DifficultySystem.FONT -> "e.g., 3, 6A+, 8B"
|
||||
DifficultySystem.YDS -> "e.g., 5.8, 5.12a"
|
||||
DifficultySystem.FRENCH -> "e.g., 6a, 7c+"
|
||||
DifficultySystem.CUSTOM -> "Custom grade"
|
||||
else -> "Enter grade"
|
||||
})
|
||||
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
|
||||
OutlinedTextField(
|
||||
value = difficultyGrade,
|
||||
onValueChange = { difficultyGrade = it },
|
||||
label = { Text("Grade *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
placeholder = { Text("Enter custom grade") }
|
||||
)
|
||||
} 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()
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
availableGrades.forEach { grade ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(grade) },
|
||||
onClick = {
|
||||
difficultyGrade = grade
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -552,7 +635,7 @@ fun AddEditProblemScreen(
|
||||
label = { Text("Tags (Optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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))
|
||||
@@ -605,17 +688,25 @@ fun AddEditSessionScreen(
|
||||
) {
|
||||
val isEditing = sessionId != null
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
val problems by viewModel.problems.collectAsState()
|
||||
|
||||
|
||||
// Session form state
|
||||
var selectedGym by remember { mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } }) }
|
||||
var sessionDate by remember { mutableStateOf(LocalDateTime.now().toLocalDate().toString()) }
|
||||
var duration by remember { mutableStateOf("") }
|
||||
var sessionNotes by remember { mutableStateOf("") }
|
||||
|
||||
// Attempt tracking state
|
||||
var attempts by remember { mutableStateOf(listOf<AttemptInput>()) }
|
||||
var showAddAttemptDialog by remember { mutableStateOf(false) }
|
||||
// Load existing session data for editing
|
||||
LaunchedEffect(sessionId) {
|
||||
if (sessionId != null) {
|
||||
val existingSession = viewModel.getSessionById(sessionId).first()
|
||||
existingSession?.let { session ->
|
||||
selectedGym = gyms.find { it.id == session.gymId }
|
||||
sessionDate = session.date.split("T")[0] // Extract date part
|
||||
duration = session.duration?.toString() ?: ""
|
||||
sessionNotes = session.notes ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(gymId, gyms) {
|
||||
if (gymId != null && selectedGym == null) {
|
||||
@@ -629,7 +720,7 @@ fun AddEditSessionScreen(
|
||||
title = { Text(if (isEditing) "Edit Session" else "Add Session") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
@@ -642,20 +733,9 @@ fun AddEditSessionScreen(
|
||||
)
|
||||
|
||||
if (isEditing) {
|
||||
viewModel.updateSession(session.copy(id = sessionId!!))
|
||||
viewModel.updateSession(session.copy(id = sessionId))
|
||||
} else {
|
||||
viewModel.addSession(session)
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -666,15 +746,6 @@ fun AddEditSessionScreen(
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (selectedGym != null) {
|
||||
FloatingActionButton(
|
||||
onClick = { showAddAttemptDialog = true }
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add Attempt")
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
@@ -770,286 +841,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,8 +6,10 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.R
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
|
||||
@Composable
|
||||
@@ -26,11 +28,23 @@ fun AnalyticsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Analytics",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "OpenClimb Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Analytics",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Overall Stats
|
||||
@@ -79,41 +93,6 @@ fun AnalyticsScreen(
|
||||
val recentSessions = sessions.take(5)
|
||||
RecentActivityCard(recentSessions = recentSessions.size)
|
||||
}
|
||||
|
||||
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Progress Charts",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Detailed charts and analytics coming soon!",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "📊",
|
||||
style = MaterialTheme.typography.displaySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.R
|
||||
import com.atridad.openclimb.data.model.Gym
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
|
||||
@@ -19,8 +18,7 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
@Composable
|
||||
fun GymsScreen(
|
||||
viewModel: ClimbViewModel,
|
||||
onNavigateToGymDetail: (String) -> Unit,
|
||||
onNavigateToAddGym: () -> Unit
|
||||
onNavigateToGymDetail: (String) -> Unit
|
||||
) {
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
|
||||
@@ -29,11 +27,23 @@ fun GymsScreen(
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Climbing Gyms",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "OpenClimb Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Climbing Gyms",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -95,7 +105,7 @@ fun GymCard(
|
||||
AssistChip(
|
||||
onClick = { },
|
||||
label = {
|
||||
Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() })
|
||||
Text(climbType.getDisplayName())
|
||||
},
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
@@ -105,7 +115,7 @@ fun GymCard(
|
||||
if (gym.difficultySystems.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.name }}",
|
||||
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -2,16 +2,18 @@ package com.atridad.openclimb.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.R
|
||||
import com.atridad.openclimb.data.model.ClimbType
|
||||
import com.atridad.openclimb.data.model.Gym
|
||||
import com.atridad.openclimb.data.model.Problem
|
||||
import com.atridad.openclimb.ui.components.FullscreenImageViewer
|
||||
import com.atridad.openclimb.ui.components.ImageDisplay
|
||||
@@ -21,38 +23,157 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
@Composable
|
||||
fun ProblemsScreen(
|
||||
viewModel: ClimbViewModel,
|
||||
onNavigateToProblemDetail: (String) -> Unit,
|
||||
onNavigateToAddProblem: (String?) -> Unit
|
||||
onNavigateToProblemDetail: (String) -> Unit
|
||||
) {
|
||||
val problems by viewModel.problems.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
var showImageViewer by remember { mutableStateOf(false) }
|
||||
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var selectedImageIndex by remember { 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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Problems & Routes",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "OpenClimb Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Problems & Routes",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
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(
|
||||
title = if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet",
|
||||
message = if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!",
|
||||
title = if (problems.isEmpty()) {
|
||||
if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet"
|
||||
} else {
|
||||
"No Problems Match Filters"
|
||||
},
|
||||
message = if (problems.isEmpty()) {
|
||||
if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!"
|
||||
} else {
|
||||
"Try adjusting your filters to see more problems."
|
||||
},
|
||||
onActionClick = { },
|
||||
actionText = ""
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(problems) { problem ->
|
||||
items(filteredProblems) { problem ->
|
||||
ProblemCard(
|
||||
problem = problem,
|
||||
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
|
||||
@@ -124,7 +245,7 @@ fun ProblemCard(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = problem.climbType.name.lowercase().replaceFirstChar { it.uppercase() },
|
||||
text = problem.climbType.getDisplayName(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -3,16 +3,20 @@ package com.atridad.openclimb.ui.screens
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.R
|
||||
import com.atridad.openclimb.data.model.ClimbSession
|
||||
import com.atridad.openclimb.data.model.SessionStatus
|
||||
import com.atridad.openclimb.ui.components.ActiveSessionBanner
|
||||
@@ -24,13 +28,13 @@ import java.time.format.DateTimeFormatter
|
||||
@Composable
|
||||
fun SessionsScreen(
|
||||
viewModel: ClimbViewModel,
|
||||
onNavigateToSessionDetail: (String) -> Unit,
|
||||
onNavigateToAddSession: (String?) -> Unit
|
||||
onNavigateToSessionDetail: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sessions by viewModel.sessions.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
val activeSession by viewModel.activeSession.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// Filter out active sessions from regular session list
|
||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||
@@ -45,16 +49,20 @@ fun SessionsScreen(
|
||||
) {
|
||||
Row(
|
||||
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 = "Climbing Sessions",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -135,7 +216,7 @@ fun SessionCard(
|
||||
|
||||
session.duration?.let { duration ->
|
||||
Text(
|
||||
text = "Duration: ${duration} minutes",
|
||||
text = "Duration: $duration minutes",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
@@ -197,7 +278,7 @@ private fun formatDate(dateString: String): String {
|
||||
return try {
|
||||
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
|
||||
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
dateString
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.atridad.openclimb.ui.screens
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import android.os.Environment
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -13,8 +12,10 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.atridad.openclimb.R
|
||||
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
import java.io.File
|
||||
|
||||
@@ -94,11 +95,23 @@ fun SettingsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_mountains),
|
||||
contentDescription = "OpenClimb Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Data Management Section
|
||||
@@ -256,9 +269,22 @@ fun SettingsScreen(
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Version") },
|
||||
supportingContent = { Text(appVersion ?: "Unknown") },
|
||||
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
|
||||
headlineContent = {
|
||||
Row(
|
||||
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 +297,8 @@ fun SettingsScreen(
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("About") },
|
||||
supportingContent = { Text("OpenClimb - Track your climbing progress") },
|
||||
headlineContent = { Text("Version") },
|
||||
supportingContent = { Text(appVersion ?: "Unknown") },
|
||||
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,13 +63,4 @@ val ClimbNeutralVariant30 = Color(0xFF484848)
|
||||
val ClimbNeutralVariant50 = Color(0xFF797979)
|
||||
val ClimbNeutralVariant60 = Color(0xFF939393)
|
||||
val ClimbNeutralVariant80 = Color(0xFFC7C7C7)
|
||||
val ClimbNeutralVariant90 = Color(0xFFE3E3E3)
|
||||
|
||||
// Legacy colors for backward compatibility
|
||||
val Purple80 = ClimbOrange80
|
||||
val PurpleGrey80 = ClimbGrey80
|
||||
val Pink80 = ClimbBlue80
|
||||
|
||||
val Purple40 = ClimbOrange40
|
||||
val PurpleGrey40 = ClimbGrey40
|
||||
val Pink40 = ClimbBlue40
|
||||
val ClimbNeutralVariant90 = Color(0xFFE3E3E3)
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.atridad.openclimb.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
@@ -10,7 +9,6 @@ import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
@@ -98,7 +96,7 @@ fun OpenClimbTheme(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
dynamicColor && true -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
@@ -110,8 +108,8 @@ fun OpenClimbTheme(
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,8 +175,8 @@ class ClimbViewModel(
|
||||
val completedSession = with(ClimbSession) { session.complete() }
|
||||
repository.updateSession(completedSession)
|
||||
|
||||
// Stop the tracking service
|
||||
val serviceIntent = SessionTrackingService.createStopIntent(context)
|
||||
// Stop the tracking service, passing the session id so service can finalize if needed
|
||||
val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
|
||||
context.startService(serviceIntent)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
@@ -186,32 +186,6 @@ class ClimbViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseSession(sessionId: String) {
|
||||
viewModelScope.launch {
|
||||
val session = repository.getSessionById(sessionId)
|
||||
if (session != null && session.status == SessionStatus.ACTIVE) {
|
||||
val pausedSession = session.copy(
|
||||
status = SessionStatus.PAUSED,
|
||||
updatedAt = java.time.LocalDateTime.now().toString()
|
||||
)
|
||||
repository.updateSession(pausedSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeSession(sessionId: String) {
|
||||
viewModelScope.launch {
|
||||
val session = repository.getSessionById(sessionId)
|
||||
if (session != null && session.status == SessionStatus.PAUSED) {
|
||||
val resumedSession = session.copy(
|
||||
status = SessionStatus.ACTIVE,
|
||||
updatedAt = java.time.LocalDateTime.now().toString()
|
||||
)
|
||||
repository.updateSession(resumedSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt operations
|
||||
fun addAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch {
|
||||
@@ -219,52 +193,24 @@ class ClimbViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch {
|
||||
repository.updateAttempt(attempt)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteAttempt(attempt)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch {
|
||||
repository.updateAttempt(attempt)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||
repository.getAttemptsBySession(sessionId)
|
||||
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
|
||||
repository.getAttemptsByProblem(problemId)
|
||||
|
||||
|
||||
|
||||
// Analytics operations
|
||||
// fun getProblemProgress(problemId: String): Flow<ProblemProgress?> =
|
||||
// repository.getProblemProgress(problemId)
|
||||
|
||||
// fun getSessionSummary(sessionId: String): Flow<SessionSummary?> =
|
||||
// repository.getSessionSummary(sessionId)
|
||||
|
||||
// Export operations
|
||||
fun exportData(context: Context, directory: File? = null) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
val exportFile = repository.exportAllDataToJson(directory)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "Data exported to: ${exportFile.absolutePath}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Export failed: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exportDataToUri(context: Context, uri: android.net.Uri) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -282,26 +228,7 @@ class ClimbViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ZIP Export operations with images
|
||||
fun exportDataToZip(context: Context, directory: File? = null) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
val exportFile = repository.exportAllDataToZip(directory)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "Data with images exported to: ${exportFile.absolutePath}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Export failed: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -358,10 +285,6 @@ class ClimbViewModel(
|
||||
_uiState.value = _uiState.value.copy(error = message)
|
||||
}
|
||||
|
||||
// Search operations
|
||||
fun searchGyms(query: String): Flow<List<Gym>> = repository.searchGyms(query)
|
||||
fun searchProblems(query: String): Flow<List<Problem>> = repository.searchProblems(query)
|
||||
|
||||
// Share operations
|
||||
suspend fun generateSessionShareCard(
|
||||
context: Context,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package com.atridad.openclimb.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import androidx.core.graphics.scale
|
||||
|
||||
object ImageUtils {
|
||||
|
||||
@@ -34,37 +35,96 @@ object ImageUtils {
|
||||
*/
|
||||
fun saveImageFromUri(context: Context, imageUri: Uri): String? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||
inputStream?.use { input ->
|
||||
// Decode and compress the image
|
||||
val originalBitmap = BitmapFactory.decodeStream(input)
|
||||
val compressedBitmap = compressImage(originalBitmap)
|
||||
|
||||
// Generate unique filename
|
||||
val filename = "${UUID.randomUUID()}.jpg"
|
||||
val imageFile = File(getImagesDirectory(context), filename)
|
||||
|
||||
// Save compressed image
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||
}
|
||||
|
||||
// Clean up bitmaps
|
||||
originalBitmap.recycle()
|
||||
compressedBitmap.recycle()
|
||||
|
||||
// Return relative path
|
||||
"$IMAGES_DIR/$filename"
|
||||
// Decode bitmap from a fresh stream to avoid mark/reset dependency
|
||||
val originalBitmap = context.contentResolver.openInputStream(imageUri)?.use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
} ?: return null
|
||||
|
||||
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||
val compressedBitmap = compressImage(orientedBitmap)
|
||||
|
||||
// Generate unique filename
|
||||
val filename = "${UUID.randomUUID()}.jpg"
|
||||
val imageFile = File(getImagesDirectory(context), filename)
|
||||
|
||||
// Save compressed image
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||
}
|
||||
|
||||
// Clean up bitmaps
|
||||
originalBitmap.recycle()
|
||||
if (orientedBitmap != originalBitmap) {
|
||||
orientedBitmap.recycle()
|
||||
}
|
||||
compressedBitmap.recycle()
|
||||
|
||||
// Return relative path
|
||||
"$IMAGES_DIR/$filename"
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
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
|
||||
*/
|
||||
@SuppressLint("UseKtx")
|
||||
private fun compressImage(original: Bitmap): Bitmap {
|
||||
val width = original.width
|
||||
val height = original.height
|
||||
@@ -79,7 +139,7 @@ object ImageUtils {
|
||||
return if (scaleFactor < 1f) {
|
||||
val newWidth = (width * scaleFactor).toInt()
|
||||
val newHeight = (height * scaleFactor).toInt()
|
||||
Bitmap.createScaledBitmap(original, newWidth, newHeight, true)
|
||||
original.scale(newWidth, newHeight)
|
||||
} else {
|
||||
original
|
||||
}
|
||||
@@ -110,30 +170,7 @@ object ImageUtils {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies an image file to export directory
|
||||
* @param context Android context
|
||||
* @param relativePath The relative path of the image
|
||||
* @param exportDir The directory to copy to
|
||||
* @return The filename in the export directory, null if failed
|
||||
*/
|
||||
fun copyImageForExport(context: Context, relativePath: String, exportDir: File): String? {
|
||||
return try {
|
||||
val sourceFile = getImageFile(context, relativePath)
|
||||
if (!sourceFile.exists()) return null
|
||||
|
||||
val filename = sourceFile.name
|
||||
val destFile = File(exportDir, filename)
|
||||
|
||||
sourceFile.copyTo(destFile, overwrite = true)
|
||||
filename
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Imports an image file from the import directory
|
||||
* @param context Android context
|
||||
|
||||
@@ -11,6 +11,8 @@ import java.io.FileOutputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.toColorInt
|
||||
|
||||
object SessionShareUtils {
|
||||
|
||||
@@ -22,7 +24,8 @@ object SessionShareUtils {
|
||||
val uniqueProblemsCompleted: Int,
|
||||
val averageGrade: String?,
|
||||
val sessionDuration: String,
|
||||
val topResult: AttemptResult?
|
||||
val topResult: AttemptResult?,
|
||||
val topGrade: String?
|
||||
)
|
||||
|
||||
fun calculateSessionStats(
|
||||
@@ -32,9 +35,7 @@ object SessionShareUtils {
|
||||
): SessionStats {
|
||||
val successfulResults = listOf(
|
||||
AttemptResult.SUCCESS,
|
||||
AttemptResult.FLASH,
|
||||
AttemptResult.REDPOINT,
|
||||
AttemptResult.ONSIGHT
|
||||
AttemptResult.FLASH
|
||||
)
|
||||
|
||||
val successfulAttempts = attempts.filter { it.result in successfulResults }
|
||||
@@ -42,22 +43,39 @@ object SessionShareUtils {
|
||||
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
|
||||
|
||||
val attemptedProblems = problems.filter { it.id in uniqueProblems }
|
||||
val averageGrade = if (attemptedProblems.isNotEmpty()) {
|
||||
// This is a simplified average - in reality you'd need proper grade conversion
|
||||
val gradeValues = attemptedProblems.mapNotNull { problem ->
|
||||
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
|
||||
}
|
||||
if (gradeValues.isNotEmpty()) {
|
||||
"V${gradeValues.average().roundToInt()}"
|
||||
} else null
|
||||
} else null
|
||||
|
||||
// Calculate separate averages for different climbing types and difficulty systems
|
||||
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
|
||||
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
|
||||
|
||||
val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder")
|
||||
val ropeAverage = calculateAverageGrade(ropeProblems, "Rope")
|
||||
|
||||
// Combine averages for display
|
||||
val averageGrade = when {
|
||||
boulderAverage != null && ropeAverage != null -> "$boulderAverage / $ropeAverage"
|
||||
boulderAverage != null -> boulderAverage
|
||||
ropeAverage != null -> ropeAverage
|
||||
else -> null
|
||||
}
|
||||
|
||||
// Determine highest achieved grade (only from completed problems: SUCCESS or FLASH)
|
||||
val completedProblems = problems.filter { it.id in uniqueCompletedProblems }
|
||||
val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER }
|
||||
val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE }
|
||||
val topBoulder = highestGradeForProblems(completedBoulder)
|
||||
val topRope = highestGradeForProblems(completedRope)
|
||||
val topGrade = when {
|
||||
topBoulder != null && topRope != null -> "$topBoulder / $topRope"
|
||||
topBoulder != null -> topBoulder
|
||||
topRope != null -> topRope
|
||||
else -> null
|
||||
}
|
||||
|
||||
val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
|
||||
val topResult = attempts.maxByOrNull {
|
||||
when (it.result) {
|
||||
AttemptResult.ONSIGHT -> 5
|
||||
AttemptResult.FLASH -> 4
|
||||
AttemptResult.REDPOINT -> 3
|
||||
AttemptResult.FLASH -> 3
|
||||
AttemptResult.SUCCESS -> 2
|
||||
AttemptResult.FALL -> 1
|
||||
else -> 0
|
||||
@@ -72,34 +90,81 @@ object SessionShareUtils {
|
||||
uniqueProblemsCompleted = uniqueCompletedProblems.size,
|
||||
averageGrade = averageGrade,
|
||||
sessionDuration = duration,
|
||||
topResult = topResult
|
||||
topResult = topResult,
|
||||
topGrade = topGrade
|
||||
)
|
||||
}
|
||||
|
||||
private fun calculateDuration(startTime: String?, endTime: String?): String {
|
||||
return try {
|
||||
if (startTime != null && endTime != null) {
|
||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||
val start = LocalDateTime.parse(startTime, formatter)
|
||||
val end = LocalDateTime.parse(endTime, formatter)
|
||||
val duration = java.time.Duration.between(start, end)
|
||||
|
||||
val hours = duration.toHours()
|
||||
val minutes = duration.toMinutes() % 60
|
||||
|
||||
when {
|
||||
hours > 0 -> "${hours}h ${minutes}m"
|
||||
minutes > 0 -> "${minutes}m"
|
||||
else -> "< 1m"
|
||||
/**
|
||||
* Calculate average grade for a specific set of problems, respecting their difficulty systems
|
||||
*/
|
||||
private fun calculateAverageGrade(problems: List<Problem>, climbingType: String): String? {
|
||||
if (problems.isEmpty()) return null
|
||||
|
||||
// Group problems by difficulty system
|
||||
val problemsBySystem = problems.groupBy { it.difficulty.system }
|
||||
|
||||
val averages = mutableListOf<String>()
|
||||
|
||||
problemsBySystem.forEach { (system, systemProblems) ->
|
||||
when (system) {
|
||||
DifficultySystem.V_SCALE -> {
|
||||
val gradeValues = systemProblems.mapNotNull { problem ->
|
||||
when {
|
||||
problem.difficulty.grade == "VB" -> 0
|
||||
else -> problem.difficulty.grade.removePrefix("V").toIntOrNull()
|
||||
}
|
||||
}
|
||||
if (gradeValues.isNotEmpty()) {
|
||||
val avg = gradeValues.average().roundToInt()
|
||||
averages.add(if (avg == 0) "VB" else "V$avg")
|
||||
}
|
||||
}
|
||||
DifficultySystem.FONT -> {
|
||||
val gradeValues = systemProblems.mapNotNull { problem ->
|
||||
// Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" -> 7)
|
||||
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
|
||||
}
|
||||
if (gradeValues.isNotEmpty()) {
|
||||
val avg = gradeValues.average().roundToInt()
|
||||
averages.add("$avg")
|
||||
}
|
||||
}
|
||||
DifficultySystem.YDS -> {
|
||||
val gradeValues = systemProblems.mapNotNull { problem ->
|
||||
// Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10)
|
||||
val grade = problem.difficulty.grade
|
||||
if (grade.startsWith("5.")) {
|
||||
grade.substring(2).toDoubleOrNull()
|
||||
} else null
|
||||
}
|
||||
if (gradeValues.isNotEmpty()) {
|
||||
val avg = gradeValues.average()
|
||||
averages.add("5.${String.format("%.1f", avg)}")
|
||||
}
|
||||
}
|
||||
DifficultySystem.CUSTOM -> {
|
||||
// For custom systems, try to extract numeric values
|
||||
val gradeValues = systemProblems.mapNotNull { problem ->
|
||||
problem.difficulty.grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull()
|
||||
}
|
||||
if (gradeValues.isNotEmpty()) {
|
||||
val avg = gradeValues.average()
|
||||
averages.add(String.format("%.1f", avg))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"Unknown"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
return if (averages.isNotEmpty()) {
|
||||
if (averages.size == 1) {
|
||||
averages.first()
|
||||
} else {
|
||||
averages.joinToString(" / ")
|
||||
}
|
||||
} else null
|
||||
}
|
||||
|
||||
|
||||
fun generateShareCard(
|
||||
context: Context,
|
||||
session: ClimbSession,
|
||||
@@ -107,17 +172,17 @@ object SessionShareUtils {
|
||||
stats: SessionStats
|
||||
): File? {
|
||||
return try {
|
||||
val width = 1080
|
||||
val height = 1350
|
||||
val width = 1242 // 3:4 aspect at higher resolution for better fit
|
||||
val height = 1656
|
||||
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val bitmap = createBitmap(width, height)
|
||||
val canvas = Canvas(bitmap)
|
||||
|
||||
val gradientDrawable = GradientDrawable(
|
||||
GradientDrawable.Orientation.TOP_BOTTOM,
|
||||
intArrayOf(
|
||||
Color.parseColor("#667eea"),
|
||||
Color.parseColor("#764ba2")
|
||||
"#667eea".toColorInt(),
|
||||
"#764ba2".toColorInt()
|
||||
)
|
||||
)
|
||||
gradientDrawable.setBounds(0, 0, width, height)
|
||||
@@ -133,7 +198,7 @@ object SessionShareUtils {
|
||||
}
|
||||
|
||||
val subtitlePaint = Paint().apply {
|
||||
color = Color.parseColor("#E8E8E8")
|
||||
color = "#E8E8E8".toColorInt()
|
||||
textSize = 48f
|
||||
typeface = Typeface.DEFAULT
|
||||
isAntiAlias = true
|
||||
@@ -141,7 +206,7 @@ object SessionShareUtils {
|
||||
}
|
||||
|
||||
val statLabelPaint = Paint().apply {
|
||||
color = Color.parseColor("#B8B8B8")
|
||||
color = "#B8B8B8".toColorInt()
|
||||
textSize = 36f
|
||||
typeface = Typeface.DEFAULT
|
||||
isAntiAlias = true
|
||||
@@ -157,12 +222,12 @@ object SessionShareUtils {
|
||||
}
|
||||
|
||||
val cardPaint = Paint().apply {
|
||||
color = Color.parseColor("#40FFFFFF")
|
||||
color = "#40FFFFFF".toColorInt()
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Draw content
|
||||
@@ -183,35 +248,52 @@ object SessionShareUtils {
|
||||
// Stats grid
|
||||
val statsStartY = yPosition
|
||||
val columnWidth = width / 2f
|
||||
val columnMaxTextWidth = columnWidth - 120f
|
||||
|
||||
// Left column stats
|
||||
var leftY = statsStartY
|
||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint)
|
||||
leftY += 140f
|
||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint)
|
||||
leftY += 140f
|
||||
drawStatItem(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint)
|
||||
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
leftY += 120f
|
||||
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
leftY += 120f
|
||||
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
|
||||
// Right column stats
|
||||
var rightY = statsStartY
|
||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint)
|
||||
rightY += 140f
|
||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint)
|
||||
rightY += 140f
|
||||
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
rightY += 120f
|
||||
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
rightY += 120f
|
||||
|
||||
stats.averageGrade?.let { grade ->
|
||||
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Avg Grade", grade, statLabelPaint, statValuePaint)
|
||||
var rightYAfter = rightY
|
||||
stats.topGrade?.let { grade ->
|
||||
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
rightYAfter += 120f
|
||||
}
|
||||
|
||||
// Grade range(s)
|
||||
val boulderRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.BOULDER })
|
||||
val ropeRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE })
|
||||
val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f
|
||||
if (boulderRange != null && ropeRange != null) {
|
||||
// Two evenly spaced items
|
||||
drawStatItemFitting(canvas, columnWidth / 2f, rangesY, "Boulder Range", boulderRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
drawStatItemFitting(canvas, width - columnWidth / 2f, rangesY, "Rope Range", ropeRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
|
||||
} else if (boulderRange != null || ropeRange != null) {
|
||||
// Single centered item
|
||||
val singleRange = boulderRange ?: ropeRange ?: ""
|
||||
drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f)
|
||||
}
|
||||
|
||||
// Success rate arc
|
||||
if (stats.totalAttempts > 0) {
|
||||
val successRate = (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100
|
||||
drawSuccessRateArc(canvas, width / 2f, height - 280f, successRate, statLabelPaint, statValuePaint)
|
||||
}
|
||||
val successRate = if (stats.totalAttempts > 0) {
|
||||
(stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100f
|
||||
} else 0f
|
||||
drawSuccessRateArc(canvas, width / 2f, height - 300f, successRate, statLabelPaint, statValuePaint)
|
||||
|
||||
// App branding
|
||||
val brandingPaint = Paint().apply {
|
||||
color = Color.parseColor("#80FFFFFF")
|
||||
color = "#80FFFFFF".toColorInt()
|
||||
textSize = 32f
|
||||
typeface = Typeface.DEFAULT
|
||||
isAntiAlias = true
|
||||
@@ -255,6 +337,41 @@ object SessionShareUtils {
|
||||
canvas.drawText(label, x, y + 50f, labelPaint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a stat item while fitting the value text to a max width by reducing text size if needed.
|
||||
*/
|
||||
private fun drawStatItemFitting(
|
||||
canvas: Canvas,
|
||||
x: Float,
|
||||
y: Float,
|
||||
label: String,
|
||||
value: String,
|
||||
labelPaint: Paint,
|
||||
valuePaint: Paint,
|
||||
maxTextWidth: Float
|
||||
) {
|
||||
val tempPaint = Paint(valuePaint)
|
||||
var textSize = tempPaint.textSize
|
||||
var textWidth = tempPaint.measureText(value)
|
||||
while (textWidth > maxTextWidth && textSize > 36f) {
|
||||
textSize -= 2f
|
||||
tempPaint.textSize = textSize
|
||||
textWidth = tempPaint.measureText(value)
|
||||
}
|
||||
canvas.drawText(value, x, y, tempPaint)
|
||||
canvas.drawText(label, x, y + 50f, labelPaint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a range string like "X - Y" for the given problems, based on their difficulty grades.
|
||||
*/
|
||||
private fun gradeRangeForProblems(problems: List<Problem>): String? {
|
||||
if (problems.isEmpty()) return null
|
||||
val grades = problems.map { it.difficulty }
|
||||
val sorted = grades.sortedWith { a, b -> a.compareTo(b) }
|
||||
return "${sorted.first().grade} - ${sorted.last().grade}"
|
||||
}
|
||||
|
||||
private fun drawSuccessRateArc(
|
||||
canvas: Canvas,
|
||||
centerX: Float,
|
||||
@@ -263,40 +380,43 @@ object SessionShareUtils {
|
||||
labelPaint: Paint,
|
||||
valuePaint: Paint
|
||||
) {
|
||||
val radius = 80f
|
||||
val strokeWidth = 16f
|
||||
|
||||
val radius = 70f
|
||||
val strokeWidth = 14f
|
||||
|
||||
// Background arc
|
||||
val bgPaint = Paint().apply {
|
||||
color = Color.parseColor("#40FFFFFF")
|
||||
color = "#30FFFFFF".toColorInt()
|
||||
style = Paint.Style.STROKE
|
||||
this.strokeWidth = strokeWidth
|
||||
isAntiAlias = true
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
|
||||
// Success arc
|
||||
val successPaint = Paint().apply {
|
||||
color = Color.parseColor("#4CAF50")
|
||||
color = "#4CAF50".toColorInt()
|
||||
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)
|
||||
canvas.drawText(percentText, centerX, centerY + 8f, valuePaint)
|
||||
|
||||
// Draw label below the arc (outside the ring) for better readability
|
||||
val belowLabelPaint = Paint(labelPaint)
|
||||
canvas.drawText("Success Rate", centerX, centerY + radius + 36f, belowLabelPaint)
|
||||
}
|
||||
|
||||
private fun formatSessionDate(dateString: String): String {
|
||||
@@ -305,7 +425,7 @@ object SessionShareUtils {
|
||||
val date = LocalDateTime.parse(dateString, formatter)
|
||||
val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy")
|
||||
date.format(displayFormatter)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
dateString.take(10)
|
||||
}
|
||||
}
|
||||
@@ -333,4 +453,48 @@ object SessionShareUtils {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.atridad.openclimb.utils
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
@@ -179,14 +178,7 @@ object ZipExportImportUtils {
|
||||
|
||||
return ImportResult(jsonContent, importedImagePaths)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to determine if a file is a ZIP file based on extension
|
||||
*/
|
||||
fun isZipFile(filename: String): Boolean {
|
||||
return filename.lowercase().endsWith(".zip")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates image paths in a problem list after import
|
||||
* This function maps the old image paths to the new ones after import
|
||||
|
||||
@@ -4,167 +4,8 @@
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
|
||||
<!-- Clean white background -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
</vector>
|
||||
@@ -1,30 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
|
||||
<group
|
||||
android:scaleX="0.7"
|
||||
android:scaleY="0.7"
|
||||
android:translateX="16.2"
|
||||
android:translateY="20">
|
||||
|
||||
<!-- Left mountain (yellow/amber) -->
|
||||
<path
|
||||
android:fillColor="#FFC107"
|
||||
android:strokeColor="#1C1C1C"
|
||||
android:strokeWidth="3"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M15,70 L35,25 L55,70 Z" />
|
||||
|
||||
<!-- Right mountain (red) -->
|
||||
<path
|
||||
android:fillColor="#F44336"
|
||||
android:strokeColor="#1C1C1C"
|
||||
android:strokeWidth="3"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M40,70 L65,15 L90,70 Z" />
|
||||
</group>
|
||||
</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>
|
||||
File diff suppressed because one or more lines are too long
@@ -1,10 +1,14 @@
|
||||
[versions]
|
||||
agp = "8.9.1"
|
||||
agp = "8.12.0"
|
||||
kotlin = "2.0.21"
|
||||
coreKtx = "1.15.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
androidxTestCore = "1.6.0"
|
||||
androidxTestExt = "1.2.0"
|
||||
androidxTestRunner = "1.6.0"
|
||||
androidxTestRules = "1.6.0"
|
||||
lifecycleRuntimeKtx = "2.9.2"
|
||||
activityCompose = "1.10.1"
|
||||
composeBom = "2024.09.00"
|
||||
@@ -21,6 +25,10 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref =
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
androidx-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
@@ -48,6 +56,10 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
|
||||
|
||||
# Coroutines
|
||||
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
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
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
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
Reference in New Issue
Block a user