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