diff --git a/.gradle/8.11.1/checksums/checksums.lock b/.gradle/8.11.1/checksums/checksums.lock new file mode 100644 index 0000000..db64845 Binary files /dev/null and b/.gradle/8.11.1/checksums/checksums.lock differ diff --git a/.gradle/8.11.1/executionHistory/executionHistory.bin b/.gradle/8.11.1/executionHistory/executionHistory.bin new file mode 100644 index 0000000..915418b Binary files /dev/null and b/.gradle/8.11.1/executionHistory/executionHistory.bin differ diff --git a/.gradle/8.11.1/executionHistory/executionHistory.lock b/.gradle/8.11.1/executionHistory/executionHistory.lock new file mode 100644 index 0000000..151ef54 Binary files /dev/null and b/.gradle/8.11.1/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.11.1/fileChanges/last-build.bin b/.gradle/8.11.1/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/8.11.1/fileChanges/last-build.bin differ diff --git a/.gradle/8.11.1/fileHashes/fileHashes.bin b/.gradle/8.11.1/fileHashes/fileHashes.bin new file mode 100644 index 0000000..1f17bcd Binary files /dev/null and b/.gradle/8.11.1/fileHashes/fileHashes.bin differ diff --git a/.gradle/8.11.1/fileHashes/fileHashes.lock b/.gradle/8.11.1/fileHashes/fileHashes.lock new file mode 100644 index 0000000..02d5b3c Binary files /dev/null and b/.gradle/8.11.1/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.11.1/fileHashes/resourceHashesCache.bin b/.gradle/8.11.1/fileHashes/resourceHashesCache.bin new file mode 100644 index 0000000..62595cb Binary files /dev/null and b/.gradle/8.11.1/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/8.11.1/gc.properties b/.gradle/8.11.1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..af02abd Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..a22a8b2 --- /dev/null +++ b/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Fri Aug 15 12:27:13 MDT 2025 +gradle.version=8.11.1 diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000..642137c Binary files /dev/null and b/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/.gradle/config.properties b/.gradle/config.properties new file mode 100644 index 0000000..48c08f1 --- /dev/null +++ b/.gradle/config.properties @@ -0,0 +1,2 @@ +#Fri Aug 15 12:29:02 MDT 2025 +java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe new file mode 100644 index 0000000..5085417 Binary files /dev/null and b/.gradle/file-system.probe differ diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000..9aaec77 --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,835 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..97f0a8e --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f0c6ad0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..c224ad5 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..74dd639 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.kotlin/errors/errors-1755282386436.log b/.kotlin/errors/errors-1755282386436.log new file mode 100644 index 0000000..adc2b78 --- /dev/null +++ b/.kotlin/errors/errors-1755282386436.log @@ -0,0 +1,87 @@ +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.(PagedFileStorage.java:72) + at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.(ResizeableMappedFile.java:55) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.(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.(PersistentMapImpl.java:165) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(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.(PersistentHashMap.java:45) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(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.(PagedFileStorage.java:72) + at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.(ResizeableMappedFile.java:55) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.(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.(PersistentMapImpl.java:165) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(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.(PersistentHashMap.java:45) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(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 + + diff --git a/.kotlin/errors/errors-1755282407075.log b/.kotlin/errors/errors-1755282407075.log new file mode 100644 index 0000000..adc2b78 --- /dev/null +++ b/.kotlin/errors/errors-1755282407075.log @@ -0,0 +1,87 @@ +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.(PagedFileStorage.java:72) + at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.(ResizeableMappedFile.java:55) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.(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.(PersistentMapImpl.java:165) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(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.(PersistentHashMap.java:45) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(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.(PagedFileStorage.java:72) + at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.(ResizeableMappedFile.java:55) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.(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.(PersistentMapImpl.java:165) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.(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.(PersistentHashMap.java:45) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.(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 + + diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0e14d8e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "disabled" +} \ No newline at end of file diff --git a/README.md b/README.md index 6d0a9b0..8e2ed22 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ # OpenClimb +This is a FOSS Android app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support. + +## Download + +You have two options: + +1. Download the latest APK from the Released page +2. Use Obtainium + +## Requirements + +- Android 15+ + +## Contribution + +As this is on my private git this will be difficult to do easily. Get in touch and I can figure something out. \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..b50bf0e --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,88 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.atridad.openclimb" + compileSdk = 35 + + defaultConfig { + applicationId = "com.atridad.openclimb" + minSdk = 31 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + // Core Android libraries + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + // Compose BOM and UI + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + + // Room Database + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + // Navigation + implementation(libs.androidx.navigation.compose) + + // ViewModel + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Serialization + implementation(libs.kotlinx.serialization.json) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // Image Loading + implementation(libs.coil.compose) + + // Charts - Placeholder for future implementation + // Charts will be implemented with a stable library in future versions + + // Testing + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/atridad/openclimb/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/atridad/openclimb/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..e58e5a4 --- /dev/null +++ b/app/src/androidTest/java/com/atridad/openclimb/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.atridad.openclimb + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.atridad.openclimb", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a24d0ac --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/atridad/openclimb/MainActivity.kt b/app/src/main/java/com/atridad/openclimb/MainActivity.kt new file mode 100644 index 0000000..7f3a014 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/MainActivity.kt @@ -0,0 +1,27 @@ +package com.atridad.openclimb + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import com.atridad.openclimb.ui.OpenClimbApp +import com.atridad.openclimb.ui.theme.OpenClimbTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + OpenClimbTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + OpenClimbApp() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/atridad/openclimb/data/database/Converters.kt b/app/src/main/java/com/atridad/openclimb/data/database/Converters.kt new file mode 100644 index 0000000..8ab3cba --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/database/Converters.kt @@ -0,0 +1,80 @@ +package com.atridad.openclimb.data.database + +import androidx.room.TypeConverter +import com.atridad.openclimb.data.model.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class Converters { + + @TypeConverter + fun fromClimbTypeList(value: List): String { + return Json.encodeToString(value) + } + + @TypeConverter + fun toClimbTypeList(value: String): List { + return Json.decodeFromString(value) + } + + @TypeConverter + fun fromDifficultySystemList(value: List): String { + return Json.encodeToString(value) + } + + @TypeConverter + fun toDifficultySystemList(value: String): List { + return Json.decodeFromString(value) + } + + @TypeConverter + fun fromStringList(value: List): String { + return Json.encodeToString(value) + } + + @TypeConverter + fun toStringList(value: String): List { + return Json.decodeFromString(value) + } + + @TypeConverter + fun fromDifficultyGrade(value: DifficultyGrade): String { + return Json.encodeToString(value) + } + + @TypeConverter + fun toDifficultyGrade(value: String): DifficultyGrade { + return Json.decodeFromString(value) + } + + @TypeConverter + fun fromClimbType(value: ClimbType): String { + return value.name + } + + @TypeConverter + fun toClimbType(value: String): ClimbType { + return ClimbType.valueOf(value) + } + + @TypeConverter + fun fromAttemptResult(value: AttemptResult): String { + return value.name + } + + @TypeConverter + fun toAttemptResult(value: String): AttemptResult { + return AttemptResult.valueOf(value) + } + + @TypeConverter + fun fromSessionStatus(value: SessionStatus): String { + return value.name + } + + @TypeConverter + fun toSessionStatus(value: String): SessionStatus { + return SessionStatus.valueOf(value) + } + +} diff --git a/app/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt b/app/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt new file mode 100644 index 0000000..c53c35d --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt @@ -0,0 +1,82 @@ +package com.atridad.openclimb.data.database + +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import android.content.Context +import com.atridad.openclimb.data.database.dao.* +import com.atridad.openclimb.data.model.* + +@Database( + entities = [ + Gym::class, + Problem::class, + ClimbSession::class, + Attempt::class + ], + version = 5, + exportSchema = false +) +@TypeConverters(Converters::class) +abstract class OpenClimbDatabase : RoomDatabase() { + + abstract fun gymDao(): GymDao + abstract fun problemDao(): ProblemDao + abstract fun climbSessionDao(): ClimbSessionDao + abstract fun attemptDao(): AttemptDao + + companion object { + @Volatile + private var INSTANCE: OpenClimbDatabase? = null + + val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) { + val cursor = database.query("PRAGMA table_info(climb_sessions)") + val existingColumns = mutableSetOf() + + while (cursor.moveToNext()) { + val columnName = cursor.getString(cursor.getColumnIndexOrThrow("name")) + existingColumns.add(columnName) + } + cursor.close() + + if (!existingColumns.contains("startTime")) { + database.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT") + } + if (!existingColumns.contains("endTime")) { + database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT") + } + if (!existingColumns.contains("status")) { + database.execSQL("ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'") + } + + database.execSQL("UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL") + database.execSQL("UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''") + } + } + + val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(database: SupportSQLiteDatabase) { + } + } + + fun getDatabase(context: Context): OpenClimbDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + OpenClimbDatabase::class.java, + "openclimb_database" + ) + .addMigrations(MIGRATION_4_5, MIGRATION_5_6) + .enableMultiInstanceInvalidation() + .fallbackToDestructiveMigration() + .build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt b/app/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt new file mode 100644 index 0000000..50792a0 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt @@ -0,0 +1,73 @@ +package com.atridad.openclimb.data.database.dao + +import androidx.room.* +import com.atridad.openclimb.data.model.Attempt +import com.atridad.openclimb.data.model.AttemptResult +import kotlinx.coroutines.flow.Flow + +@Dao +interface AttemptDao { + + @Query("SELECT * FROM attempts ORDER BY timestamp DESC") + fun getAllAttempts(): Flow> + + @Query("SELECT * FROM attempts WHERE id = :id") + suspend fun getAttemptById(id: String): Attempt? + + @Query("SELECT * FROM attempts WHERE sessionId = :sessionId ORDER BY timestamp ASC") + fun getAttemptsBySession(sessionId: String): Flow> + + @Query("SELECT * FROM attempts WHERE problemId = :problemId ORDER BY timestamp DESC") + fun getAttemptsByProblem(problemId: String): Flow> + + @Query("SELECT * FROM attempts WHERE sessionId = :sessionId AND problemId = :problemId ORDER BY timestamp ASC") + fun getAttemptsBySessionAndProblem(sessionId: String, problemId: String): Flow> + + @Query("SELECT * FROM attempts WHERE result = :result ORDER BY timestamp DESC") + fun getAttemptsByResult(result: AttemptResult): Flow> + + @Query("SELECT * FROM attempts WHERE timestamp BETWEEN :startDate AND :endDate ORDER BY timestamp DESC") + fun getAttemptsInDateRange(startDate: String, endDate: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAttempt(attempt: Attempt) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAttempts(attempts: List) + + @Update + suspend fun updateAttempt(attempt: Attempt) + + @Delete + suspend fun deleteAttempt(attempt: Attempt) + + @Query("DELETE FROM attempts WHERE id = :id") + suspend fun deleteAttemptById(id: String) + + @Query("DELETE FROM attempts WHERE sessionId = :sessionId") + suspend fun deleteAttemptsBySession(sessionId: String) + + @Query("DELETE FROM attempts WHERE problemId = :problemId") + suspend fun deleteAttemptsByProblem(problemId: String) + + @Query("SELECT COUNT(*) FROM attempts") + suspend fun getAttemptsCount(): Int + + @Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId") + suspend fun getAttemptsCountBySession(sessionId: String): Int + + @Query("SELECT COUNT(*) FROM attempts WHERE problemId = :problemId") + suspend fun getAttemptsCountByProblem(problemId: String): Int + + @Query("SELECT COUNT(*) FROM attempts WHERE result = :result") + suspend fun getAttemptsCountByResult(result: AttemptResult): Int + + @Query("SELECT COUNT(*) FROM attempts WHERE problemId = :problemId AND result IN ('SUCCESS', 'FLASH', 'REDPOINT', 'ONSIGHT')") + suspend fun getSuccessfulAttemptsCountByProblem(problemId: String): Int + + @Query("SELECT * FROM attempts WHERE problemId = :problemId AND result IN ('SUCCESS', 'FLASH', 'REDPOINT', 'ONSIGHT') ORDER BY timestamp ASC LIMIT 1") + suspend fun getFirstSuccessfulAttempt(problemId: String): Attempt? + + @Query("SELECT * FROM attempts WHERE problemId = :problemId ORDER BY timestamp DESC LIMIT 1") + suspend fun getLatestAttemptForProblem(problemId: String): Attempt? +} diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt b/app/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt new file mode 100644 index 0000000..7f13b19 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt @@ -0,0 +1,64 @@ +package com.atridad.openclimb.data.database.dao + +import androidx.room.* +import com.atridad.openclimb.data.model.ClimbSession +import com.atridad.openclimb.data.model.SessionStatus +import kotlinx.coroutines.flow.Flow + +@Dao +interface ClimbSessionDao { + + @Query("SELECT * FROM climb_sessions ORDER BY date DESC") + fun getAllSessions(): Flow> + + @Query("SELECT * FROM climb_sessions WHERE id = :id") + suspend fun getSessionById(id: String): ClimbSession? + + @Query("SELECT * FROM climb_sessions WHERE gymId = :gymId ORDER BY date DESC") + fun getSessionsByGym(gymId: String): Flow> + + @Query("SELECT * FROM climb_sessions WHERE date = :date ORDER BY createdAt DESC") + fun getSessionsByDate(date: String): Flow> + + @Query("SELECT * FROM climb_sessions WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC") + fun getSessionsInDateRange(startDate: String, endDate: String): Flow> + + @Query("SELECT * FROM climb_sessions ORDER BY date DESC LIMIT :limit") + fun getRecentSessions(limit: Int = 10): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSession(session: ClimbSession) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSessions(sessions: List) + + @Update + suspend fun updateSession(session: ClimbSession) + + @Delete + suspend fun deleteSession(session: ClimbSession) + + @Query("DELETE FROM climb_sessions WHERE id = :id") + suspend fun deleteSessionById(id: String) + + @Query("SELECT COUNT(*) FROM climb_sessions") + suspend fun getSessionsCount(): Int + + @Query("SELECT COUNT(*) FROM climb_sessions WHERE gymId = :gymId") + suspend fun getSessionsCountByGym(gymId: String): Int + + @Query("SELECT COUNT(*) FROM climb_sessions WHERE date BETWEEN :startDate AND :endDate") + suspend fun getSessionsCountInDateRange(startDate: String, endDate: String): Int + + @Query("SELECT DISTINCT date FROM climb_sessions ORDER BY date DESC") + suspend fun getUniqueDates(): List + + @Query("SELECT * FROM climb_sessions WHERE status = :status ORDER BY date DESC") + fun getSessionsByStatus(status: SessionStatus): Flow> + + @Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1") + suspend fun getActiveSession(): ClimbSession? + + @Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1") + fun getActiveSessionFlow(): Flow +} diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt b/app/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt new file mode 100644 index 0000000..4e2a475 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt @@ -0,0 +1,40 @@ +package com.atridad.openclimb.data.database.dao + +import androidx.room.* +import com.atridad.openclimb.data.model.ClimbType +import com.atridad.openclimb.data.model.Gym +import kotlinx.coroutines.flow.Flow + +@Dao +interface GymDao { + + @Query("SELECT * FROM gyms ORDER BY name ASC") + fun getAllGyms(): Flow> + + @Query("SELECT * FROM gyms WHERE id = :id") + suspend fun getGymById(id: String): Gym? + + @Query("SELECT * FROM gyms WHERE :climbType IN (supportedClimbTypes)") + fun getGymsByClimbType(climbType: ClimbType): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGym(gym: Gym) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGyms(gyms: List) + + @Update + suspend fun updateGym(gym: Gym) + + @Delete + suspend fun deleteGym(gym: Gym) + + @Query("DELETE FROM gyms WHERE id = :id") + suspend fun deleteGymById(id: String) + + @Query("SELECT COUNT(*) FROM gyms") + suspend fun getGymsCount(): Int + + @Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'") + fun searchGyms(searchQuery: String): Flow> +} diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt b/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt new file mode 100644 index 0000000..851dd0b --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt @@ -0,0 +1,62 @@ +package com.atridad.openclimb.data.database.dao + +import androidx.room.* +import com.atridad.openclimb.data.model.ClimbType +import com.atridad.openclimb.data.model.Problem +import kotlinx.coroutines.flow.Flow + +@Dao +interface ProblemDao { + + @Query("SELECT * FROM problems ORDER BY updatedAt DESC") + fun getAllProblems(): Flow> + + @Query("SELECT * FROM problems WHERE id = :id") + suspend fun getProblemById(id: String): Problem? + + @Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC") + fun getProblemsByGym(gymId: String): Flow> + + @Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC") + fun getProblemsByClimbType(climbType: ClimbType): Flow> + + @Query("SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC") + fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow> + + @Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC") + fun getActiveProblems(): Flow> + + @Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC") + fun getActiveProblemsByGym(gymId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertProblem(problem: Problem) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertProblems(problems: List) + + @Update + suspend fun updateProblem(problem: Problem) + + @Delete + suspend fun deleteProblem(problem: Problem) + + @Query("DELETE FROM problems WHERE id = :id") + suspend fun deleteProblemById(id: String) + + @Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId") + suspend fun getProblemsCountByGym(gymId: String): Int + + @Query("SELECT COUNT(*) FROM problems WHERE isActive = 1") + suspend fun getActiveProblemsCount(): Int + + @Query(""" + SELECT * FROM problems + WHERE (name LIKE '%' || :searchQuery || '%' + OR description LIKE '%' || :searchQuery || '%' + OR location LIKE '%' || :searchQuery || '%' + OR setter LIKE '%' || :searchQuery || '%') + ORDER BY updatedAt DESC + """) + fun searchProblems(searchQuery: String): Flow> +} diff --git a/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt b/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt new file mode 100644 index 0000000..9eeddd5 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt @@ -0,0 +1,81 @@ +package com.atridad.openclimb.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Serializable +enum class AttemptResult { + SUCCESS, // Completed the problem/route + FALL, // Fell but made progress + NO_PROGRESS, // Couldn't make meaningful progress + FLASH, // Completed on first try + REDPOINT, // Completed after previous attempts + ONSIGHT // Completed on first try without prior knowledge +} + +@Entity( + tableName = "attempts", + foreignKeys = [ + ForeignKey( + entity = ClimbSession::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = Problem::class, + parentColumns = ["id"], + childColumns = ["problemId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index(value = ["sessionId"]), + Index(value = ["problemId"]) + ] +) +@Serializable +data class Attempt( + @PrimaryKey + val id: String, + val sessionId: String, + val problemId: String, + val result: AttemptResult, + val highestHold: String? = null, // Description of the highest hold reached + val notes: String? = null, + val duration: Long? = null, // Attempt duration in seconds + val restTime: Long? = null, // Rest time before this attempt in seconds + val timestamp: String, // When this attempt was made + val createdAt: String +) { + companion object { + fun create( + sessionId: String, + problemId: String, + result: AttemptResult, + highestHold: String? = null, + notes: String? = null, + duration: Long? = null, + restTime: Long? = null, + timestamp: String = LocalDateTime.now().toString() + ): Attempt { + val now = LocalDateTime.now().toString() + return Attempt( + id = java.util.UUID.randomUUID().toString(), + sessionId = sessionId, + problemId = problemId, + result = result, + highestHold = highestHold, + notes = notes, + duration = duration, + restTime = restTime, + timestamp = timestamp, + createdAt = now + ) + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt b/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt new file mode 100644 index 0000000..7feb45a --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt @@ -0,0 +1,81 @@ +package com.atridad.openclimb.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Serializable +enum class SessionStatus { + ACTIVE, + COMPLETED, + PAUSED +} + +@Entity( + tableName = "climb_sessions", + foreignKeys = [ + ForeignKey( + entity = Gym::class, + parentColumns = ["id"], + childColumns = ["gymId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index(value = ["gymId"])] +) +@Serializable +data class ClimbSession( + @PrimaryKey + val id: String, + val gymId: String, + val date: String, // ISO date string + val startTime: String? = null, // When session was started + val endTime: String? = null, // When session was completed + val duration: Long? = null, // Duration in minutes (calculated when completed) + val status: SessionStatus = SessionStatus.ACTIVE, + val notes: String? = null, + val createdAt: String, + val updatedAt: String +) { + companion object { + fun create( + gymId: String, + notes: String? = null + ): ClimbSession { + val now = LocalDateTime.now().toString() + return ClimbSession( + id = java.util.UUID.randomUUID().toString(), + gymId = gymId, + date = now, + startTime = now, + status = SessionStatus.ACTIVE, + notes = notes, + createdAt = now, + updatedAt = now + ) + } + + fun ClimbSession.complete(): ClimbSession { + val endTime = LocalDateTime.now().toString() + val durationMinutes = if (startTime != null) { + try { + val start = LocalDateTime.parse(startTime) + val end = LocalDateTime.parse(endTime) + java.time.Duration.between(start, end).toMinutes() + } catch (e: Exception) { + null + } + } else null + + return this.copy( + endTime = endTime, + duration = durationMinutes, + status = SessionStatus.COMPLETED, + updatedAt = LocalDateTime.now().toString() + ) + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt b/app/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt new file mode 100644 index 0000000..47813c1 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt @@ -0,0 +1,9 @@ +package com.atridad.openclimb.data.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class ClimbType { + ROPE, + BOULDER +} diff --git a/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt b/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt new file mode 100644 index 0000000..f7fb35d --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt @@ -0,0 +1,26 @@ +package com.atridad.openclimb.data.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class DifficultySystem { + // Rope climbing systems + YDS, // Yosemite Decimal System (5.1 - 5.15d) + FRENCH, // French system (3 - 9c+) + UIAA, // UIAA system (I - XII+) + BRITISH, // British system (Mod - E11) + + // Bouldering systems + V_SCALE, // V-Scale (VB - V17) + FONT, // Fontainebleau (3 - 9A+) + + // Custom system for gyms that use their own colors/naming + CUSTOM +} + +@Serializable +data class DifficultyGrade( + val system: DifficultySystem, + val grade: String, + val numericValue: Int // For comparison and analytics +) diff --git a/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt b/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt new file mode 100644 index 0000000..e99b601 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt @@ -0,0 +1,45 @@ +package com.atridad.openclimb.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Entity(tableName = "gyms") +@Serializable +data class Gym( + @PrimaryKey + val id: String, + val name: String, + val location: String? = null, + val supportedClimbTypes: List, + val difficultySystems: List, // What systems this gym uses + val customDifficultyGrades: List = emptyList(), // For gyms using colors/custom names + val notes: String? = null, + val createdAt: String, // ISO string format for serialization + val updatedAt: String +) { + companion object { + fun create( + name: String, + location: String? = null, + supportedClimbTypes: List, + difficultySystems: List, + customDifficultyGrades: List = emptyList(), + notes: String? = null + ): Gym { + val now = LocalDateTime.now().toString() + return Gym( + id = java.util.UUID.randomUUID().toString(), + name = name, + location = location, + supportedClimbTypes = supportedClimbTypes, + difficultySystems = difficultySystems, + customDifficultyGrades = customDifficultyGrades, + notes = notes, + createdAt = now, + updatedAt = now + ) + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt b/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt new file mode 100644 index 0000000..1d465dc --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt @@ -0,0 +1,75 @@ +package com.atridad.openclimb.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Entity( + tableName = "problems", + foreignKeys = [ + ForeignKey( + entity = Gym::class, + parentColumns = ["id"], + childColumns = ["gymId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index(value = ["gymId"])] +) +@Serializable +data class Problem( + @PrimaryKey + val id: String, + val gymId: String, + val name: String? = null, + val description: String? = null, + val climbType: ClimbType, + val difficulty: DifficultyGrade, + val setter: String? = null, // Route setter name + val tags: List = emptyList(), // e.g., "overhang", "slab", "crimpy" + val location: String? = null, // Wall section, area in gym + val imagePaths: List = emptyList(), // Local file paths to photos + val isActive: Boolean = true, // Whether the problem is still up + val dateSet: String? = null, // When the problem was set + val notes: String? = null, + val createdAt: String, + val updatedAt: String +) { + companion object { + fun create( + gymId: String, + name: String? = null, + description: String? = null, + climbType: ClimbType, + difficulty: DifficultyGrade, + setter: String? = null, + tags: List = emptyList(), + location: String? = null, + imagePaths: List = emptyList(), + dateSet: String? = null, + notes: String? = null + ): Problem { + val now = LocalDateTime.now().toString() + return Problem( + id = java.util.UUID.randomUUID().toString(), + gymId = gymId, + name = name, + description = description, + climbType = climbType, + difficulty = difficulty, + setter = setter, + tags = tags, + location = location, + imagePaths = imagePaths, + isActive = true, + dateSet = dateSet, + notes = notes, + createdAt = now, + updatedAt = now + ) + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/data/model/Progress.kt b/app/src/main/java/com/atridad/openclimb/data/model/Progress.kt new file mode 100644 index 0000000..ae0e2c5 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/model/Progress.kt @@ -0,0 +1,50 @@ +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, + 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" +) diff --git a/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt b/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt new file mode 100644 index 0000000..b399086 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt @@ -0,0 +1,281 @@ +package com.atridad.openclimb.data.repository + +import android.content.Context +import android.os.Environment +import com.atridad.openclimb.data.database.OpenClimbDatabase +import com.atridad.openclimb.data.model.* +import com.atridad.openclimb.utils.ImageUtils +import com.atridad.openclimb.utils.ZipExportImportUtils +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.time.LocalDateTime + +class ClimbRepository( + private val database: OpenClimbDatabase, + private val context: Context +) { + private val gymDao = database.gymDao() + private val problemDao = database.problemDao() + private val sessionDao = database.climbSessionDao() + private val attemptDao = database.attemptDao() + + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + // Gym operations + fun getAllGyms(): Flow> = gymDao.getAllGyms() + suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id) + suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym) + suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym) + suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym) + fun searchGyms(query: String): Flow> = gymDao.searchGyms(query) + + // Problem operations + fun getAllProblems(): Flow> = problemDao.getAllProblems() + suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) + fun getProblemsByGym(gymId: String): Flow> = problemDao.getProblemsByGym(gymId) + fun getActiveProblems(): Flow> = problemDao.getActiveProblems() + suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem) + suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) + suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) + fun searchProblems(query: String): Flow> = problemDao.searchProblems(query) + + // Session operations + fun getAllSessions(): Flow> = sessionDao.getAllSessions() + suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) + fun getSessionsByGym(gymId: String): Flow> = sessionDao.getSessionsByGym(gymId) + fun getRecentSessions(limit: Int = 10): Flow> = sessionDao.getRecentSessions(limit) + suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() + fun getActiveSessionFlow(): Flow = sessionDao.getActiveSessionFlow() + fun getSessionsByStatus(status: SessionStatus): Flow> = sessionDao.getSessionsByStatus(status) + suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session) + suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session) + suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session) + + // Attempt operations + fun getAllAttempts(): Flow> = attemptDao.getAllAttempts() + suspend fun getAttemptById(id: String): Attempt? = attemptDao.getAttemptById(id) + fun getAttemptsBySession(sessionId: String): Flow> = attemptDao.getAttemptsBySession(sessionId) + fun getAttemptsByProblem(problemId: String): Flow> = attemptDao.getAttemptsByProblem(problemId) + suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt) + suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt) + suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt) + + + + // JSON Export functionality + suspend fun exportAllDataToJson(directory: File? = null): File { + val exportDir = directory ?: File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "OpenClimb") + if (!exportDir.exists()) { + exportDir.mkdirs() + } + + val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-") + val exportFile = File(exportDir, "openclimb_export_$timestamp.json") + + val allGyms = gymDao.getAllGyms().first() + val allProblems = problemDao.getAllProblems().first() + val allSessions = sessionDao.getAllSessions().first() + val allAttempts = attemptDao.getAllAttempts().first() + + val exportData = ClimbDataExport( + exportedAt = LocalDateTime.now().toString(), + gyms = allGyms, + problems = allProblems, + sessions = allSessions, + attempts = allAttempts + ) + + val jsonString = json.encodeToString(exportData) + exportFile.writeText(jsonString) + + return exportFile + } + + suspend fun exportAllDataToUri(context: Context, uri: android.net.Uri) { + val gyms = gymDao.getAllGyms().first() + val problems = problemDao.getAllProblems().first() + val sessions = sessionDao.getAllSessions().first() + val attempts = attemptDao.getAllAttempts().first() + + val exportData = ClimbDataExport( + exportedAt = LocalDateTime.now().toString(), + gyms = gyms, + problems = problems, + sessions = sessions, + attempts = attempts + ) + + val jsonString = json.encodeToString(exportData) + + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(jsonString.toByteArray()) + } ?: throw Exception("Could not open output stream") + } + + suspend fun importDataFromJson(file: File) { + try { + val jsonContent = file.readText() + val importData = json.decodeFromString(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 { + 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 + ) + + // Collect all referenced image paths + val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() + + return ZipExportImportUtils.createExportZip( + context = context, + exportData = exportData, + referencedImagePaths = referencedImagePaths, + directory = directory + ) + } + + suspend fun exportAllDataToZipUri(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 + ) + + // Collect all referenced image paths + val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet() + + ZipExportImportUtils.createExportZipToUri( + context = context, + uri = uri, + exportData = exportData, + referencedImagePaths = referencedImagePaths + ) + } + + suspend fun importDataFromZip(file: File) { + try { + val importResult = ZipExportImportUtils.extractImportZip(context, file) + val importData = json.decodeFromString(importResult.jsonContent) + + // Update problem image paths with the new imported paths + val updatedProblems = ZipExportImportUtils.updateProblemImagePaths( + importData.problems, + 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 -> + 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}") + } + } +} + +@kotlinx.serialization.Serializable +data class ClimbDataExport( + val exportedAt: String, + val gyms: List, + val problems: List, + val sessions: List, + val attempts: List +) \ No newline at end of file diff --git a/app/src/main/java/com/atridad/openclimb/navigation/BottomNavigationItem.kt b/app/src/main/java/com/atridad/openclimb/navigation/BottomNavigationItem.kt new file mode 100644 index 0000000..bd22164 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/navigation/BottomNavigationItem.kt @@ -0,0 +1,39 @@ +package com.atridad.openclimb.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.ui.graphics.vector.ImageVector + +data class BottomNavigationItem( + val screen: Screen, + val icon: ImageVector, + val label: String +) + +val bottomNavigationItems = listOf( + BottomNavigationItem( + screen = Screen.Sessions, + icon = Icons.Default.PlayArrow, + label = "Sessions" + ), + BottomNavigationItem( + screen = Screen.Problems, + icon = Icons.Default.Star, + label = "Problems" + ), + BottomNavigationItem( + screen = Screen.Analytics, + icon = Icons.Default.Info, + label = "Analytics" + ), + BottomNavigationItem( + screen = Screen.Gyms, + icon = Icons.Default.LocationOn, + label = "Gyms" + ), + BottomNavigationItem( + screen = Screen.Settings, + icon = Icons.Default.Settings, + label = "Settings" + ) +) diff --git a/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt b/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt new file mode 100644 index 0000000..1a5ef77 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt @@ -0,0 +1,42 @@ +package com.atridad.openclimb.navigation + +import kotlinx.serialization.Serializable + +@Serializable +sealed class Screen { + @Serializable + data object Sessions : Screen() + + @Serializable + data object Problems : Screen() + + @Serializable + data object Analytics : Screen() + + @Serializable + data object Gyms : Screen() + + @Serializable + data object Settings : Screen() + + // Detail screens + @Serializable + data class SessionDetail(val sessionId: String) : Screen() + + @Serializable + data class ProblemDetail(val problemId: String) : Screen() + + @Serializable + data class GymDetail(val gymId: String) : Screen() + + @Serializable + data class AddEditGym(val gymId: String? = null) : Screen() + + @Serializable + data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen() + + @Serializable + data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen() + + +} diff --git a/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt b/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt new file mode 100644 index 0000000..6db8fa2 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt @@ -0,0 +1,189 @@ +package com.atridad.openclimb.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.atridad.openclimb.MainActivity +import com.atridad.openclimb.R +import com.atridad.openclimb.data.database.OpenClimbDatabase +import com.atridad.openclimb.data.repository.ClimbRepository +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.firstOrNull +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +class SessionTrackingService : Service() { + + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var notificationJob: Job? = null + + private lateinit var repository: ClimbRepository + + companion object { + const val NOTIFICATION_ID = 1001 + const val CHANNEL_ID = "session_tracking_channel" + const val ACTION_START_SESSION = "start_session" + const val ACTION_STOP_SESSION = "stop_session" + const val EXTRA_SESSION_ID = "session_id" + + fun createStartIntent(context: Context, sessionId: String): Intent { + return Intent(context, SessionTrackingService::class.java).apply { + action = ACTION_START_SESSION + putExtra(EXTRA_SESSION_ID, sessionId) + } + } + + fun createStopIntent(context: Context): Intent { + return Intent(context, SessionTrackingService::class.java).apply { + action = ACTION_STOP_SESSION + } + } + } + + override fun onCreate() { + super.onCreate() + + val database = OpenClimbDatabase.getDatabase(this) + repository = ClimbRepository(database, this) + + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START_SESSION -> { + val sessionId = intent.getStringExtra(EXTRA_SESSION_ID) + if (sessionId != null) { + startSessionTracking(sessionId) + } + } + ACTION_STOP_SESSION -> { + stopSessionTracking() + } + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startSessionTracking(sessionId: String) { + notificationJob?.cancel() + notificationJob = serviceScope.launch { + while (isActive) { + updateNotification(sessionId) + delay(1000) + } + } + } + + private fun stopSessionTracking() { + notificationJob?.cancel() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private suspend fun updateNotification(sessionId: String) { + try { + val session = repository.getSessionById(sessionId) + if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) { + stopSessionTracking() + return + } + + val gym = repository.getGymById(session.gymId) + val attempts = repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList() + + val duration = session.startTime?.let { startTime -> + try { + val start = LocalDateTime.parse(startTime) + val now = LocalDateTime.now() + val minutes = ChronoUnit.MINUTES.between(start, now) + val hours = minutes / 60 + val remainingMinutes = minutes % 60 + + when { + hours > 0 -> "${hours}h ${remainingMinutes}m" + remainingMinutes > 0 -> "${remainingMinutes}m" + else -> "< 1m" + } + } catch (e: Exception) { + "Active" + } + } ?: "Active" + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("OpenClimb Session Active") + .setContentText("${gym?.name ?: "Gym"} • $duration • ${attempts.size} attempts") + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setOngoing(true) + .setContentIntent(createOpenAppIntent()) + .addAction( + R.drawable.ic_launcher_foreground, + "Open Session", + createOpenAppIntent() + ) + .addAction( + R.drawable.ic_launcher_foreground, + "End Session", + createStopIntent() + ) + .build() + + startForeground(NOTIFICATION_ID, notification) + } catch (e: Exception) { + // Handle errors gracefully + stopSessionTracking() + } + } + + private fun createOpenAppIntent(): PendingIntent { + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + return PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun createStopIntent(): PendingIntent { + val intent = createStopIntent(this) + return PendingIntent.getService( + this, + 1, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Session Tracking", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows active climbing session information" + setShowBadge(false) + } + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + override fun onDestroy() { + super.onDestroy() + notificationJob?.cancel() + serviceScope.cancel() + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt b/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt new file mode 100644 index 0000000..4dcfd98 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt @@ -0,0 +1,274 @@ +package com.atridad.openclimb.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import com.atridad.openclimb.data.database.OpenClimbDatabase +import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.navigation.Screen +import com.atridad.openclimb.navigation.bottomNavigationItems +import com.atridad.openclimb.ui.screens.* +import com.atridad.openclimb.ui.viewmodel.ClimbViewModel +import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OpenClimbApp() { + val navController = rememberNavController() + val context = LocalContext.current + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = currentBackStackEntry?.destination?.route + + val database = remember { OpenClimbDatabase.getDatabase(context) } + val repository = remember { ClimbRepository(database, context) } + val viewModel: ClimbViewModel = viewModel( + factory = ClimbViewModelFactory(repository) + ) + + // FAB configuration + var fabConfig by remember { mutableStateOf(null) } + + Scaffold( + bottomBar = { + OpenClimbBottomNavigation(navController = navController) + }, + floatingActionButton = { + fabConfig?.let { config -> + FloatingActionButton( + onClick = config.onClick, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon( + imageVector = config.icon, + contentDescription = config.contentDescription + ) + } + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Screen.Sessions, + modifier = Modifier.padding(innerPadding) + ) { + // Main screens + composable { + val gyms by viewModel.gyms.collectAsState() + val activeSession by viewModel.activeSession.collectAsState() + LaunchedEffect(gyms, activeSession) { + fabConfig = if (gyms.isNotEmpty() && activeSession == null) { + FabConfig( + icon = Icons.Default.Add, + contentDescription = "Start Session", + onClick = { + if (gyms.size == 1) { + viewModel.startSession(context, gyms.first().id) + } else { + navController.navigate(Screen.AddEditSession()) + } + } + ) + } else { + null + } + } + SessionsScreen( + viewModel = viewModel, + onNavigateToSessionDetail = { sessionId -> + navController.navigate(Screen.SessionDetail(sessionId)) + }, + onNavigateToAddSession = { gymId -> + navController.navigate(Screen.AddEditSession(gymId = gymId)) + } + ) + } + + composable { + val gyms by viewModel.gyms.collectAsState() + LaunchedEffect(gyms) { + fabConfig = if (gyms.isNotEmpty()) { + FabConfig( + icon = Icons.Default.Add, + contentDescription = "Add Problem", + onClick = { + navController.navigate(Screen.AddEditProblem()) + } + ) + } else { + null + } + } + ProblemsScreen( + viewModel = viewModel, + onNavigateToProblemDetail = { problemId -> + navController.navigate(Screen.ProblemDetail(problemId)) + }, + onNavigateToAddProblem = { gymId -> + navController.navigate(Screen.AddEditProblem(gymId = gymId)) + } + ) + } + + composable { + LaunchedEffect(Unit) { + fabConfig = null // No FAB for analytics + } + AnalyticsScreen(viewModel = viewModel) + } + + composable { + LaunchedEffect(Unit) { + fabConfig = FabConfig( + icon = Icons.Default.Add, + contentDescription = "Add Gym", + onClick = { + navController.navigate(Screen.AddEditGym()) + } + ) + } + GymsScreen( + viewModel = viewModel, + onNavigateToGymDetail = { gymId -> + navController.navigate(Screen.GymDetail(gymId)) + }, + onNavigateToAddGym = { + navController.navigate(Screen.AddEditGym()) + } + ) + } + + composable { + LaunchedEffect(Unit) { + fabConfig = null // No FAB for settings + } + SettingsScreen(viewModel = viewModel) + } + + // Detail screens + composable { backStackEntry -> + val args = backStackEntry.toRoute() + SessionDetailScreen( + sessionId = args.sessionId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() }, + onNavigateToEdit = { sessionId -> + navController.navigate(Screen.AddEditSession(sessionId = sessionId)) + } + ) + } + + composable { backStackEntry -> + val args = backStackEntry.toRoute() + ProblemDetailScreen( + problemId = args.problemId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() }, + onNavigateToEdit = { problemId -> + navController.navigate(Screen.AddEditProblem(problemId = problemId)) + } + ) + } + + composable { backStackEntry -> + val args = backStackEntry.toRoute() + GymDetailScreen( + gymId = args.gymId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() }, + onNavigateToEdit = { gymId -> + navController.navigate(Screen.AddEditGym(gymId = gymId)) + } + ) + } + + + composable { backStackEntry -> + val args = backStackEntry.toRoute() + AddEditGymScreen( + gymId = args.gymId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() } + ) + } + + composable { backStackEntry -> + val args = backStackEntry.toRoute() + AddEditProblemScreen( + problemId = args.problemId, + gymId = args.gymId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() } + ) + } + + composable { backStackEntry -> + val args = backStackEntry.toRoute() + AddEditSessionScreen( + sessionId = args.sessionId, + gymId = args.gymId, + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() } + ) + } + } + } +} + +@Composable +fun OpenClimbBottomNavigation(navController: NavHostController) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + NavigationBar { + bottomNavigationItems.forEach { item -> + val isSelected = when (item.screen) { + is Screen.Sessions -> currentRoute?.contains("Session") == true + is Screen.Problems -> currentRoute?.contains("Problem") == true + is Screen.Gyms -> currentRoute?.contains("Gym") == true + is Screen.Analytics -> currentRoute?.contains("Analytics") == true + is Screen.Settings -> currentRoute?.contains("Settings") == true + else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true + } + + NavigationBarItem( + icon = { Icon(item.icon, contentDescription = item.label) }, + label = { Text(item.label) }, + selected = isSelected, + onClick = { + navController.navigate(item.screen) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(Screen.Sessions) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + } + ) + } + } +} + +data class FabConfig( + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val contentDescription: String, + val onClick: () -> Unit +) + + diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt b/app/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt new file mode 100644 index 0000000..7f2fb99 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt @@ -0,0 +1,188 @@ +package com.atridad.openclimb.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.atridad.openclimb.data.model.ClimbSession +import com.atridad.openclimb.data.model.Gym +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@Composable +fun ActiveSessionBanner( + activeSession: ClimbSession?, + gym: Gym?, + onSessionClick: () -> Unit, + onEndSession: () -> Unit +) { + if (activeSession != null) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onSessionClick() }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Active session", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Active Session", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = gym?.name ?: "Unknown Gym", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + + activeSession.startTime?.let { startTime -> + val duration = calculateDuration(startTime) + Text( + text = duration, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + } + } + + IconButton( + onClick = onEndSession, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Icon( + Icons.Default.Close, + contentDescription = "End session" + ) + } + } + } + } +} + +@Composable +fun StartSessionButton( + gyms: List, + 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 { + val startTime = LocalDateTime.parse(startTimeString) + val now = LocalDateTime.now() + val minutes = ChronoUnit.MINUTES.between(startTime, now) + val hours = minutes / 60 + val remainingMinutes = minutes % 60 + + when { + hours > 0 -> "${hours}h ${remainingMinutes}m" + remainingMinutes > 0 -> "${remainingMinutes}m" + else -> "< 1m" + } + } catch (e: Exception) { + "Active" + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt b/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt new file mode 100644 index 0000000..c832536 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/components/FullscreenImageViewer.kt @@ -0,0 +1,209 @@ +package com.atridad.openclimb.ui.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import com.atridad.openclimb.utils.ImageUtils +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FullscreenImageViewer( + imagePaths: List, + initialIndex: Int = 0, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val pagerState = rememberPagerState( + initialPage = initialIndex, + pageCount = { imagePaths.size } + ) + val thumbnailListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + // Auto-scroll thumbnail list to center current image + LaunchedEffect(pagerState.currentPage) { + thumbnailListState.animateScrollToItem( + index = pagerState.currentPage, + scrollOffset = -200 // Center the item + ) + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + // Main image pager + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + ZoomableImage( + imagePath = imagePaths[page], + modifier = Modifier.fillMaxSize() + ) + } + + // Close button + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + .background( + Color.Black.copy(alpha = 0.5f), + CircleShape + ) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + tint = Color.White + ) + } + + // Image counter + if (imagePaths.size > 1) { + Card( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = Color.Black.copy(alpha = 0.7f) + ) + ) { + Text( + text = "${pagerState.currentPage + 1} / ${imagePaths.size}", + color = Color.White, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + + // Thumbnail strip (if multiple images) + if (imagePaths.size > 1) { + Card( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = Color.Black.copy(alpha = 0.7f) + ) + ) { + LazyRow( + state = thumbnailListState, + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 8.dp) + ) { + itemsIndexed(imagePaths) { index, imagePath -> + val imageFile = ImageUtils.getImageFile(context, imagePath) + val isSelected = index == pagerState.currentPage + + AsyncImage( + model = imageFile, + contentDescription = "Thumbnail ${index + 1}", + modifier = Modifier + .size(60.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + } + .then( + if (isSelected) { + Modifier.background( + Color.White.copy(alpha = 0.3f), + RoundedCornerShape(8.dp) + ) + } else Modifier + ), + contentScale = ContentScale.Crop + ) + } + } + } + } + } + } +} + +@Composable +private fun ZoomableImage( + imagePath: String, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val imageFile = ImageUtils.getImageFile(context, imagePath) + + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + + Box( + modifier = modifier + .pointerInput(Unit) { + detectTransformGestures( + onGesture = { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(0.5f, 5f) + + val maxOffsetX = (size.width * (scale - 1)) / 2 + val maxOffsetY = (size.height * (scale - 1)) / 2 + + offsetX = (offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX) + offsetY = (offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY) + } + ) + }, + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = imageFile, + contentDescription = "Full screen image", + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY + ), + contentScale = ContentScale.Fit + ) + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt b/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt new file mode 100644 index 0000000..246b913 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/components/ImageDisplay.kt @@ -0,0 +1,76 @@ +package com.atridad.openclimb.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.atridad.openclimb.utils.ImageUtils + +@Composable +fun ImageDisplay( + imagePaths: List, + modifier: Modifier = Modifier, + imageSize: Int = 120, + onImageClick: ((Int) -> Unit)? = null +) { + val context = LocalContext.current + + if (imagePaths.isNotEmpty()) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(imagePaths) { index, imagePath -> + val imageFile = ImageUtils.getImageFile(context, imagePath) + + AsyncImage( + model = imageFile, + contentDescription = "Problem photo", + modifier = Modifier + .size(imageSize.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(enabled = onImageClick != null) { + onImageClick?.invoke(index) + }, + contentScale = ContentScale.Crop + ) + } + } + } +} + +@Composable +fun ImageDisplaySection( + imagePaths: List, + modifier: Modifier = Modifier, + title: String = "Photos", + onImageClick: ((Int) -> Unit)? = null +) { + if (imagePaths.isNotEmpty()) { + Column(modifier = modifier) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + ImageDisplay( + imagePaths = imagePaths, + imageSize = 120, + onImageClick = onImageClick + ) + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt b/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt new file mode 100644 index 0000000..61b7152 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/components/ImagePicker.kt @@ -0,0 +1,184 @@ +package com.atridad.openclimb.ui.components + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.atridad.openclimb.utils.ImageUtils +import java.io.File + +@Composable +fun ImagePicker( + imageUris: List, + onImagesChanged: (List) -> Unit, + modifier: Modifier = Modifier, + maxImages: Int = 5 +) { + val context = LocalContext.current + var tempImageUris by remember { mutableStateOf(imageUris) } + + // Image picker launcher + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris -> + if (uris.isNotEmpty()) { + val currentCount = tempImageUris.size + val remainingSlots = maxImages - currentCount + val urisToProcess = uris.take(remainingSlots) + + // Process each selected image + val newImagePaths = mutableListOf() + urisToProcess.forEach { uri -> + val imagePath = ImageUtils.saveImageFromUri(context, uri) + if (imagePath != null) { + newImagePaths.add(imagePath) + } + } + + if (newImagePaths.isNotEmpty()) { + val updatedUris = tempImageUris + newImagePaths + tempImageUris = updatedUris + onImagesChanged(updatedUris) + } + } + } + + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Photos (${tempImageUris.size}/$maxImages)", + style = MaterialTheme.typography.titleMedium + ) + + if (tempImageUris.size < maxImages) { + TextButton( + onClick = { + imagePickerLauncher.launch("image/*") + } + ) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Add Photos") + } + } + } + + if (tempImageUris.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(tempImageUris) { imagePath -> + ImageItem( + imagePath = imagePath, + onRemove = { + val updatedUris = tempImageUris.filter { it != imagePath } + tempImageUris = updatedUris + onImagesChanged(updatedUris) + + // Delete the image file + ImageUtils.deleteImage(context, imagePath) + } + ) + } + } + } else { + Spacer(modifier = Modifier.height(8.dp)) + Card( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Add photos of this problem", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + +@Composable +private fun ImageItem( + imagePath: String, + onRemove: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val imageFile = ImageUtils.getImageFile(context, imagePath) + + Box( + modifier = modifier.size(80.dp) + ) { + AsyncImage( + model = imageFile, + contentDescription = "Problem photo", + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + + IconButton( + onClick = onRemove, + modifier = Modifier + .align(Alignment.TopEnd) + .size(24.dp) + ) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Remove photo", + modifier = Modifier + .fillMaxSize() + .padding(2.dp), + tint = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt new file mode 100644 index 0000000..c629703 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt @@ -0,0 +1,1055 @@ +package com.atridad.openclimb.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.atridad.openclimb.data.model.* +import com.atridad.openclimb.ui.components.ImagePicker +import com.atridad.openclimb.ui.viewmodel.ClimbViewModel +import kotlinx.coroutines.flow.first +import java.time.LocalDateTime + +// Data class for attempt input +data class AttemptInput( + val problemId: String, + val result: AttemptResult, + val highestHold: String = "", + val notes: String = "" +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddEditGymScreen( + gymId: String?, + viewModel: ClimbViewModel, + onNavigateBack: () -> Unit +) { + var name by remember { mutableStateOf("") } + var location by remember { mutableStateOf("") } + var notes by remember { mutableStateOf("") } + var selectedClimbTypes by remember { mutableStateOf(setOf()) } + var selectedDifficultySystems by remember { mutableStateOf(setOf()) } + + val isEditing = gymId != null + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (isEditing) "Edit Gym" else "Add Gym") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + TextButton( + onClick = { + val gym = if (isEditing) { + Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes) + } else { + Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes) + } + + if (isEditing) { + viewModel.updateGym(gym) + } else { + viewModel.addGym(gym) + } + onNavigateBack() + }, + enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty() + ) { + Text("Save") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Name field + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Gym Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Location field + OutlinedTextField( + value = location, + onValueChange = { location = it }, + label = { Text("Location (Optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Climb Types + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Supported Climb Types", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + ClimbType.entries.forEach { climbType -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = climbType in selectedClimbTypes, + onClick = { + selectedClimbTypes = if (climbType in selectedClimbTypes) { + selectedClimbTypes - climbType + } else { + selectedClimbTypes + climbType + } + }, + role = Role.Checkbox + ) + ) { + Checkbox( + checked = climbType in selectedClimbTypes, + onCheckedChange = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) + } + } + } + } + + // Difficulty Systems + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Difficulty Systems", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + DifficultySystem.entries.forEach { system -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = system in selectedDifficultySystems, + onClick = { + selectedDifficultySystems = if (system in selectedDifficultySystems) { + selectedDifficultySystems - system + } else { + selectedDifficultySystems + system + } + }, + role = Role.Checkbox + ) + ) { + Checkbox( + checked = system in selectedDifficultySystems, + onCheckedChange = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(system.name) + } + } + } + } + + // Notes field + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes (Optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3 + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddEditProblemScreen( + problemId: String?, + gymId: String?, + viewModel: ClimbViewModel, + onNavigateBack: () -> Unit +) { + val isEditing = problemId != null + val gyms by viewModel.gyms.collectAsState() + + // Problem form state + var selectedGym by remember { mutableStateOf(gymId?.let { id -> gyms.find { it.id == id } }) } + var problemName by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) } + var selectedDifficultySystem by remember { mutableStateOf(DifficultySystem.V_SCALE) } + var difficultyGrade by remember { mutableStateOf("") } + var setter by remember { mutableStateOf("") } + var location by remember { mutableStateOf("") } + var tags by remember { mutableStateOf("") } + var notes by remember { mutableStateOf("") } + var isActive by remember { mutableStateOf(true) } + var imagePaths by remember { mutableStateOf>(emptyList()) } + + // Load existing problem data for editing + LaunchedEffect(problemId) { + if (problemId != null) { + val existingProblem = viewModel.getProblemById(problemId).first() + existingProblem?.let { p -> + problemName = p.name ?: "" + description = p.description ?: "" + selectedClimbType = p.climbType + selectedDifficultySystem = p.difficulty.system + difficultyGrade = p.difficulty.grade + setter = p.setter ?: "" + location = p.location ?: "" + tags = p.tags.joinToString(", ") + notes = p.notes ?: "" + isActive = p.isActive + imagePaths = p.imagePaths + } + } + } + + LaunchedEffect(gymId, gyms) { + if (gymId != null && selectedGym == null) { + selectedGym = gyms.find { it.id == gymId } + } + } + + val availableDifficultySystems = selectedGym?.difficultySystems ?: DifficultySystem.entries.toList() + val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (isEditing) "Edit Problem" else "Add Problem") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + TextButton( + onClick = { + selectedGym?.let { gym -> + val difficulty = DifficultyGrade( + system = selectedDifficultySystem, + grade = difficultyGrade, + numericValue = when (selectedDifficultySystem) { + DifficultySystem.V_SCALE -> difficultyGrade.removePrefix("V").toIntOrNull() ?: 0 + else -> difficultyGrade.hashCode() % 100 // Simple mapping for other systems + } + ) + + val problem = Problem.create( + gymId = gym.id, + name = problemName.ifBlank { null }, + description = description.ifBlank { null }, + climbType = selectedClimbType, + difficulty = difficulty, + setter = setter.ifBlank { null }, + tags = tags.split(",").map { it.trim() }.filter { it.isNotBlank() }, + location = location.ifBlank { null }, + imagePaths = imagePaths, + notes = notes.ifBlank { null } + ) + + if (isEditing) { + viewModel.updateProblem(problem.copy(id = problemId!!)) + } else { + viewModel.addProblem(problem) + } + onNavigateBack() + } + }, + enabled = selectedGym != null && difficultyGrade.isNotBlank() + ) { + Text("Save") + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Gym Selection + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Select Gym", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (gyms.isEmpty()) { + Text( + text = "No gyms available. Add a gym first.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } else { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(gyms) { gym -> + FilterChip( + onClick = { selectedGym = gym }, + label = { Text(gym.name) }, + selected = selectedGym?.id == gym.id + ) + } + } + } + } + } + } + + // Basic Problem Info + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Problem Details", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = problemName, + onValueChange = { problemName = it }, + label = { Text("Problem Name (Optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("e.g., 'The Overhang Monster', 'Yellow V4'") } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description (Optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + placeholder = { Text("Describe the problem, holds, style, etc.") } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = setter, + onValueChange = { setter = it }, + label = { Text("Route Setter (Optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = location, + onValueChange = { location = it }, + label = { Text("Location (Optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("e.g., 'Cave area', 'Wall 3', 'Right side'") } + ) + } + } + } + + // Climb Type + if (selectedGym != null) { + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Climb Type", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + availableClimbTypes.forEach { climbType -> + FilterChip( + onClick = { selectedClimbType = climbType }, + label = { Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) }, + selected = selectedClimbType == climbType + ) + } + } + } + } + } + } + + // Difficulty + if (selectedGym != null) { + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Difficulty", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Difficulty System", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(availableDifficultySystems) { system -> + FilterChip( + onClick = { selectedDifficultySystem = system }, + label = { Text(system.name) }, + selected = selectedDifficultySystem == system + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = difficultyGrade, + onValueChange = { difficultyGrade = it }, + label = { Text("Grade *") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { + Text(when (selectedDifficultySystem) { + DifficultySystem.V_SCALE -> "e.g., V0, V4, V10" + DifficultySystem.FONT -> "e.g., 3, 6A+, 8B" + DifficultySystem.YDS -> "e.g., 5.8, 5.12a" + DifficultySystem.FRENCH -> "e.g., 6a, 7c+" + DifficultySystem.CUSTOM -> "Custom grade" + else -> "Enter grade" + }) + } + ) + } + } + } + } + + // Images Section + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Photos", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + ImagePicker( + imageUris = imagePaths, + onImagesChanged = { imagePaths = it }, + maxImages = 5 + ) + } + } + } + + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Additional Info", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = tags, + onValueChange = { tags = it }, + label = { Text("Tags (Optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("e.g., overhang, crimpy, dynamic (comma-separated)") } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes (Optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + placeholder = { Text("Any additional notes about this problem") } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isActive, + onClick = { isActive = !isActive }, + role = Role.Checkbox + ) + ) { + Checkbox( + checked = isActive, + onCheckedChange = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Problem is currently active", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddEditSessionScreen( + sessionId: String?, + gymId: String?, + viewModel: ClimbViewModel, + onNavigateBack: () -> Unit +) { + val isEditing = sessionId != null + val gyms by viewModel.gyms.collectAsState() + val problems by viewModel.problems.collectAsState() + + // Session form state + var selectedGym by remember { mutableStateOf(gymId?.let { id -> gyms.find { it.id == id } }) } + var sessionDate by remember { mutableStateOf(LocalDateTime.now().toLocalDate().toString()) } + var duration by remember { mutableStateOf("") } + var sessionNotes by remember { mutableStateOf("") } + + // Attempt tracking state + var attempts by remember { mutableStateOf(listOf()) } + var showAddAttemptDialog by remember { mutableStateOf(false) } + + LaunchedEffect(gymId, gyms) { + if (gymId != null && selectedGym == null) { + selectedGym = gyms.find { it.id == gymId } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (isEditing) "Edit Session" else "Add Session") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + TextButton( + onClick = { + selectedGym?.let { gym -> + val session = ClimbSession.create( + gymId = gym.id, + notes = sessionNotes.ifBlank { null } + ) + + if (isEditing) { + viewModel.updateSession(session.copy(id = sessionId!!)) + } else { + viewModel.addSession(session) + + attempts.forEach { attemptInput -> + val attempt = Attempt.create( + sessionId = session.id, + problemId = attemptInput.problemId, + result = attemptInput.result, + highestHold = attemptInput.highestHold.ifBlank { null }, + notes = attemptInput.notes.ifBlank { null } + ) + viewModel.addAttempt(attempt) + } + } + onNavigateBack() + } + }, + enabled = selectedGym != null + ) { + Text("Save") + } + } + ) + }, + floatingActionButton = { + if (selectedGym != null) { + FloatingActionButton( + onClick = { showAddAttemptDialog = true } + ) { + Icon(Icons.Default.Add, contentDescription = "Add Attempt") + } + } + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Gym Selection + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Select Gym", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (gyms.isEmpty()) { + Text( + text = "No gyms available. Add a gym first.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } else { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(gyms) { gym -> + FilterChip( + onClick = { selectedGym = gym }, + label = { Text(gym.name) }, + selected = selectedGym?.id == gym.id + ) + } + } + } + } + } + } + + // Session Details + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Session Details", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = sessionDate, + onValueChange = { sessionDate = it }, + label = { Text("Date (YYYY-MM-DD)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = duration, + onValueChange = { duration = it }, + label = { Text("Duration (minutes)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = sessionNotes, + onValueChange = { sessionNotes = it }, + label = { Text("Session Notes (Optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3 + ) + } + } + } + + // 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, + onDismiss: () -> Unit, + onAddAttempt: (AttemptInput) -> Unit +) { + var selectedProblem by remember { mutableStateOf(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") + } + } + } + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt new file mode 100644 index 0000000..72fb8e7 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt @@ -0,0 +1,276 @@ +package com.atridad.openclimb.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.atridad.openclimb.ui.viewmodel.ClimbViewModel + +@Composable +fun AnalyticsScreen( + viewModel: ClimbViewModel +) { + val sessions by viewModel.sessions.collectAsState() + val problems by viewModel.problems.collectAsState() + val attempts by viewModel.attempts.collectAsState() + val gyms by viewModel.gyms.collectAsState() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Text( + text = "Analytics", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + + // Overall Stats + item { + OverallStatsCard( + totalSessions = sessions.size, + totalProblems = problems.size, + totalAttempts = attempts.size, + totalGyms = gyms.size + ) + } + + // Success Rate + item { + val successfulAttempts = attempts.count { + it.result.name in listOf("SUCCESS", "FLASH", "REDPOINT", "ONSIGHT") + } + val successRate = if (attempts.isNotEmpty()) { + (successfulAttempts.toDouble() / attempts.size * 100).toInt() + } else 0 + + SuccessRateCard( + successRate = successRate, + successfulAttempts = successfulAttempts, + totalAttempts = attempts.size + ) + } + + // Favorite Gym + item { + val favoriteGym = sessions + .groupBy { it.gymId } + .maxByOrNull { it.value.size } + ?.let { (gymId, sessions) -> + gyms.find { it.id == gymId }?.name to sessions.size + } + + FavoriteGymCard( + gymName = favoriteGym?.first ?: "No sessions yet", + sessionCount = favoriteGym?.second ?: 0 + ) + } + + // Recent Activity + item { + val recentSessions = sessions.take(5) + RecentActivityCard(recentSessions = recentSessions.size) + } + + + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Progress Charts", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Detailed charts and analytics coming soon!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "📊", + style = MaterialTheme.typography.displaySmall + ) + } + } + } + } +} + +@Composable +fun OverallStatsCard( + totalSessions: Int, + totalProblems: Int, + totalAttempts: Int, + totalGyms: Int +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Overall Stats", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem(label = "Sessions", value = totalSessions.toString()) + StatItem(label = "Problems", value = totalProblems.toString()) + StatItem(label = "Attempts", value = totalAttempts.toString()) + StatItem(label = "Gyms", value = totalGyms.toString()) + } + } + } +} + + + +@Composable +fun SuccessRateCard( + successRate: Int, + successfulAttempts: Int, + totalAttempts: Int +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Success Rate", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "$successRate%", + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Column(horizontalAlignment = Alignment.End) { + Text( + text = "$successfulAttempts successful", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "out of $totalAttempts attempts", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +fun FavoriteGymCard( + gymName: String, + sessionCount: Int +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Favorite Gym", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = gymName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Medium + ) + + if (sessionCount > 0) { + Text( + text = "$sessionCount sessions", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +fun RecentActivityCard( + recentSessions: Int +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Recent Activity", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = if (recentSessions > 0) { + "You've had $recentSessions recent sessions" + } else { + "No recent activity" + }, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt new file mode 100644 index 0000000..cebb369 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt @@ -0,0 +1,1855 @@ +package com.atridad.openclimb.ui.screens + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.Dialog +import com.atridad.openclimb.ui.components.FullscreenImageViewer +import com.atridad.openclimb.ui.components.ImageDisplaySection +import com.atridad.openclimb.ui.viewmodel.ClimbViewModel +import com.atridad.openclimb.data.model.* +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import androidx.lifecycle.viewModelScope +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionDetailScreen( + sessionId: String, + viewModel: ClimbViewModel, + onNavigateBack: () -> Unit, + onNavigateToEdit: (String) -> Unit +) { + val context = LocalContext.current + val attempts by viewModel.getAttemptsBySession(sessionId).collectAsState(initial = emptyList()) + val sessions by viewModel.sessions.collectAsState() + val problems by viewModel.problems.collectAsState() + val gyms by viewModel.gyms.collectAsState() + + var isGeneratingShare by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + var showAddAttemptDialog by remember { mutableStateOf(false) } + + // Get session details + val session = sessions.find { it.id == sessionId } + val gym = session?.let { s -> gyms.find { it.id == s.gymId } } + + // Calculate stats + val successfulAttempts = attempts.filter { + it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT) + } + val uniqueProblems = attempts.map { it.problemId }.distinct() + val attemptedProblems = problems.filter { it.id in uniqueProblems } + val completedProblems = successfulAttempts.map { it.problemId }.distinct() + + val attemptsWithProblems = attempts.mapNotNull { attempt -> + val problem = problems.find { it.id == attempt.problemId } + if (problem != null) attempt to problem else null + }.sortedByDescending { attempt -> + // Sort by result priority, then by timestamp + when (attempt.first.result) { + AttemptResult.ONSIGHT -> 5 + AttemptResult.FLASH -> 4 + AttemptResult.REDPOINT -> 3 + AttemptResult.SUCCESS -> 2 + AttemptResult.FALL -> 1 + else -> 0 + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Session Details") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + // Share button + if (session?.duration != null) { // Only show for completed sessions + IconButton( + onClick = { + isGeneratingShare = true + viewModel.viewModelScope.launch { + val shareFile = viewModel.generateSessionShareCard(context, sessionId) + isGeneratingShare = false + shareFile?.let { file -> + viewModel.shareSessionCard(context, file) + } + } + }, + enabled = !isGeneratingShare + ) { + if (isGeneratingShare) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share Session" + ) + } + } + } + + IconButton(onClick = { showDeleteDialog = true }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + + IconButton(onClick = { onNavigateToEdit(sessionId) }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + } + ) + }, + floatingActionButton = { + // Show FAB only for active sessions (those without duration) + if (session?.duration == null) { + FloatingActionButton( + onClick = { showAddAttemptDialog = true } + ) { + Icon(Icons.Default.Add, contentDescription = "Add Attempt") + } + } + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Session Header + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Text( + text = gym?.name ?: "Unknown Gym", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = formatDate(session?.date ?: ""), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + + session?.let { s -> + if (s.duration != null) { + Spacer(modifier = Modifier.height(8.dp)) + + val timeText = "Duration: ${s.duration} minutes" + + Text( + text = timeText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + session?.notes?.let { notes -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = notes, + style = MaterialTheme.typography.bodyMedium + ) + } + + // Session status indicator + Spacer(modifier = Modifier.height(12.dp)) + + Surface( + color = if (session?.duration != null) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = if (session?.duration != null) "Completed" else "In Progress", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = if (session?.duration != null) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.Medium + ) + } + } + } + } + + // Stats Summary + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Text( + text = "Session Stats", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (attempts.isEmpty()) { + Text( + text = "No attempts recorded yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem( + label = "Total Attempts", + value = attempts.size.toString() + ) + StatItem( + label = "Problems", + value = uniqueProblems.size.toString() + ) + StatItem( + label = "Successful", + value = successfulAttempts.size.toString() + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem( + label = "Completed", + value = completedProblems.size.toString() + ) + StatItem( + label = "Success Rate", + value = "${((successfulAttempts.size.toDouble() / attempts.size) * 100).toInt()}%" + ) + + // Show grade range if available + val grades = attemptedProblems.map { it.difficulty.grade } + if (grades.isNotEmpty()) { + StatItem( + label = "Grade Range", + value = "${grades.minOrNull()} - ${grades.maxOrNull()}" + ) + } else { + StatItem( + label = "Grade Range", + value = "N/A" + ) + } + } + } + } + } + } + + // Attempts List + item { + Text( + text = "Attempts (${attempts.size})", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + + if (attemptsWithProblems.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No attempts yet", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Start attempting problems to see your progress!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } else { + items(attemptsWithProblems.size) { index -> + val (attempt, problem) = attemptsWithProblems[index] + SessionAttemptCard( + attempt = attempt, + problem = problem + ) + } + } + } + } + + // Delete confirmation dialog + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete Session") }, + text = { + Column { + Text("Are you sure you want to delete this session?") + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "This will also delete all attempts associated with this session.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + }, + confirmButton = { + TextButton( + onClick = { + session?.let { s -> + viewModel.deleteSession(s) + onNavigateBack() + } + showDeleteDialog = false + } + ) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("Cancel") + } + } + ) + } + + if (showAddAttemptDialog && session != null && gym != null) { + EnhancedAddAttemptDialog( + session = session, + gym = gym, + problems = problems.filter { it.gymId == gym.id && it.isActive }, + onDismiss = { showAddAttemptDialog = false }, + onAttemptAdded = { attempt -> + viewModel.addAttempt(attempt) + showAddAttemptDialog = false + }, + onProblemCreated = { problem -> + viewModel.addProblem(problem) + } + ) + } + } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProblemDetailScreen( + problemId: String, + viewModel: ClimbViewModel, + onNavigateBack: () -> Unit, + onNavigateToEdit: (String) -> Unit +) { + val context = LocalContext.current + var showDeleteDialog by remember { mutableStateOf(false) } + var showImageViewer by remember { mutableStateOf(false) } + var selectedImageIndex by remember { mutableStateOf(0) } + val attempts by viewModel.getAttemptsByProblem(problemId).collectAsState(initial = emptyList()) + val sessions by viewModel.sessions.collectAsState() + val gyms by viewModel.gyms.collectAsState() + + // Get problem details + var problem by remember { mutableStateOf(null) } + + LaunchedEffect(problemId) { + problem = viewModel.getProblemById(problemId).first() + } + + val gym = problem?.let { p -> gyms.find { it.id == p.gymId } } + + // Calculate stats + val successfulAttempts = attempts.filter { + it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT) + } + val successRate = if (attempts.isNotEmpty()) { + (successfulAttempts.size.toDouble() / attempts.size * 100).toInt() + } else 0 + + val attemptsWithSessions = attempts.mapNotNull { attempt -> + val session = sessions.find { it.id == attempt.sessionId } + if (session != null) attempt to session else null + }.sortedByDescending { it.second.date } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Problem Details") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { showDeleteDialog = true }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + IconButton(onClick = { onNavigateToEdit(problemId) }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Problem Header + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Text( + text = problem?.name ?: "Unknown Problem", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + problem?.let { p -> + Text( + text = "${p.difficulty.system.name}: ${p.difficulty.grade}", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + + problem?.let { p -> + Text( + text = p.climbType.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + gym?.let { g -> + Column(horizontalAlignment = Alignment.End) { + Text( + text = g.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + + problem?.location?.let { location -> + Text( + text = location, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + problem?.description?.let { description -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium + ) + } + + // Display images if any + problem?.let { p -> + if (p.imagePaths.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + ImageDisplaySection( + imagePaths = p.imagePaths, + title = "Photos", + onImageClick = { index -> + selectedImageIndex = index + showImageViewer = true + } + ) + } + } + + problem?.setter?.let { setter -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Set by: $setter", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (problem?.tags?.isNotEmpty() == true) { + Spacer(modifier = Modifier.height(12.dp)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(problem?.tags ?: emptyList()) { tag -> + AssistChip( + onClick = { }, + label = { Text(tag) } + ) + } + } + } + + problem?.notes?.let { notes -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = notes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + // Progress Summary + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Text( + text = "Progress Summary", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (attempts.isEmpty()) { + Text( + text = "No attempts recorded yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem( + label = "Total Attempts", + value = attempts.size.toString() + ) + StatItem( + label = "Successful", + value = successfulAttempts.size.toString() + ) + StatItem( + label = "Success Rate", + value = "$successRate%" + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + if (successfulAttempts.isNotEmpty()) { + val firstSuccess = successfulAttempts.minByOrNull { attempt -> + sessions.find { it.id == attempt.sessionId }?.date ?: "" + } + firstSuccess?.let { attempt -> + val session = sessions.find { it.id == attempt.sessionId } + Text( + text = "First success: ${formatDate(session?.date ?: "")} (${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }})", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } + + // Attempt History + item { + Text( + text = "Attempt History (${attempts.size})", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + + if (attemptsWithSessions.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No attempts yet", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Start a session and track your attempts on this problem!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } else { + items(attemptsWithSessions.size) { index -> + val (attempt, session) = attemptsWithSessions[index] + AttemptHistoryCard( + attempt = attempt, + session = session, + gym = gym + ) + } + } + } + } + + // Delete confirmation dialog + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete Problem") }, + text = { + Column { + Text("Are you sure you want to delete this problem?") + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "This will also delete all attempts associated with this problem.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + }, + confirmButton = { + TextButton( + onClick = { + problem?.let { p -> + viewModel.deleteProblem(p, context) + onNavigateBack() + } + showDeleteDialog = false + } + ) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("Cancel") + } + } + ) + } + + // Fullscreen Image Viewer + problem?.let { p -> + if (showImageViewer && p.imagePaths.isNotEmpty()) { + FullscreenImageViewer( + imagePaths = p.imagePaths, + initialIndex = selectedImageIndex, + onDismiss = { showImageViewer = false } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GymDetailScreen( + gymId: String, + viewModel: ClimbViewModel, + onNavigateBack: () -> Unit, + onNavigateToEdit: (String) -> Unit +) { + val gyms by viewModel.gyms.collectAsState() + val gym = gyms.find { it.id == gymId } + val problems by viewModel.getProblemsByGym(gymId).collectAsState(initial = emptyList()) + val sessions by viewModel.getSessionsByGym(gymId).collectAsState(initial = emptyList()) + val allAttempts by viewModel.attempts.collectAsState() + + // Calculate statistics + val gymAttempts = allAttempts.filter { attempt -> + problems.any { problem -> problem.id == attempt.problemId } + } + + val successfulAttempts = gymAttempts.filter { + it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT) + } + + val successRate = if (gymAttempts.isNotEmpty()) { + (successfulAttempts.size.toDouble() / gymAttempts.size * 100).toInt() + } else 0 + + val uniqueProblemsClimbed = gymAttempts.map { it.problemId }.toSet().size + val totalSessions = sessions.size + val activeSessions = sessions.count { it.status == SessionStatus.ACTIVE } + + var showDeleteDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(gym?.name ?: "Gym Details") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { showDeleteDialog = true }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + IconButton(onClick = { onNavigateToEdit(gymId) }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + } + ) + } + ) { paddingValues -> + if (gym == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text("Gym not found") + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Gym Information Card + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Text( + text = gym.name, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + if (gym.location?.isNotBlank() == true) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = gym.location, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (gym.notes?.isNotBlank() == true) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = gym.notes, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + + // Statistics Card + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Text( + text = "Statistics", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Statistics Grid + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = problems.size.toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Problems", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = totalSessions.toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Sessions", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "$successRate%", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Success Rate", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = gymAttempts.size.toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Total Attempts", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = uniqueProblemsClimbed.toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Problems Climbed", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (activeSessions > 0) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = activeSessions.toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Active Sessions", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + + // Recent Problems Card + if (problems.isNotEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Text( + text = "Problems (${problems.size})", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Show recent problems (limit to 5) + problems.sortedByDescending { it.createdAt }.take(5).forEach { problem -> + val problemAttempts = gymAttempts.filter { it.problemId == problem.id } + val problemSuccessful = problemAttempts.any { + it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(12.dp) + ) { + ListItem( + headlineContent = { + Text( + text = problem.name ?: "Unnamed Problem", + fontWeight = FontWeight.Medium + ) + }, + supportingContent = { + Text("${problem.difficulty.grade} • ${problem.climbType} • ${problemAttempts.size} attempts") + }, + trailingContent = { + if (problemSuccessful) { + Icon( + Icons.Default.Check, + contentDescription = "Completed", + tint = MaterialTheme.colorScheme.primary + ) + } + } + ) + } + } + + if (problems.size > 5) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "... and ${problems.size - 5} more problems", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + + // Recent Sessions Card + if (sessions.isNotEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Text( + text = "Recent Sessions (${sessions.size})", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Show recent sessions (limit to 3) + sessions.sortedByDescending { it.date }.take(3).forEach { session -> + val sessionAttempts = gymAttempts.filter { it.sessionId == session.id } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(12.dp) + ) { + ListItem( + headlineContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (session.status == SessionStatus.ACTIVE) "Active Session" + else "Session", + fontWeight = FontWeight.Medium + ) + if (session.status == SessionStatus.ACTIVE) { + Badge( + containerColor = MaterialTheme.colorScheme.primary + ) { + Text("ACTIVE", style = MaterialTheme.typography.labelSmall) + } + } + } + }, + supportingContent = { + val dateTime = try { + LocalDateTime.parse(session.date) + } catch (e: Exception) { + null + } + val formattedDate = dateTime?.format( + DateTimeFormatter.ofPattern("MMM dd, yyyy") + ) ?: session.date + + Text("$formattedDate • ${sessionAttempts.size} attempts") + }, + trailingContent = { + session.duration?.let { duration -> + Text( + text = "${duration}min", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } + } + + if (sessions.size > 3) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "... and ${sessions.size - 3} more sessions", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + + // Empty state if no data + if (problems.isEmpty() && sessions.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No activity yet", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Start a session or add problems to see them here", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } + + // Delete confirmation dialog + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete Gym") }, + text = { + Column { + Text("Are you sure you want to delete this gym?") + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "This will also delete all problems and sessions associated with this gym.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + }, + confirmButton = { + TextButton( + onClick = { + gym?.let { g -> + viewModel.deleteGym(g) + onNavigateBack() + } + showDeleteDialog = false + } + ) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("Cancel") + } + } + ) + } +} + + + +@Composable +fun StatItem( + label: String, + value: String +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun AttemptHistoryCard( + attempt: Attempt, + session: ClimbSession, + gym: Gym? +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = formatDate(session.date), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + gym?.let { g -> + Text( + text = g.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + AttemptResultBadge(result = attempt.result) + } + + attempt.notes?.let { notes -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = notes, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + +@Composable +fun AttemptResultBadge(result: AttemptResult) { + val backgroundColor = when (result) { + AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT -> MaterialTheme.colorScheme.primaryContainer + AttemptResult.FALL -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant + } + + val textColor = when (result) { + AttemptResult.SUCCESS, AttemptResult.FLASH, AttemptResult.REDPOINT, AttemptResult.ONSIGHT -> MaterialTheme.colorScheme.onPrimaryContainer + AttemptResult.FALL -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Surface( + color = backgroundColor, + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = result.name.lowercase().replaceFirstChar { it.uppercase() }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = textColor, + fontWeight = FontWeight.Medium + ) + } +} + +@Composable +fun SessionAttemptCard( + attempt: Attempt, + problem: Problem +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = problem.name ?: "Unknown Problem", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + + Text( + text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + + problem.location?.let { location -> + Text( + text = location, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + AttemptResultBadge(result = attempt.result) + } + + attempt.notes?.let { notes -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = notes, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + +private fun formatDate(dateString: String): String { + return try { + val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + val date = LocalDateTime.parse(dateString, formatter) + val displayFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy") + date.format(displayFormatter) + } catch (e: Exception) { + dateString.take(10) // Fallback to just the date part + } +} + +private fun formatTime(timeString: String): String { + return try { + val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + val time = LocalDateTime.parse(timeString, formatter) + val displayFormatter = DateTimeFormatter.ofPattern("h:mm a") + time.format(displayFormatter) + } catch (e: Exception) { + timeString.take(8) // Fallback to time part + } +} + +private fun calculateSessionDuration(startTime: String, endTime: String): String { + return try { + val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + val start = LocalDateTime.parse(startTime, formatter) + val end = LocalDateTime.parse(endTime, formatter) + val duration = java.time.Duration.between(start, end) + + val hours = duration.toHours() + val minutes = duration.toMinutes() % 60 + + when { + hours > 0 -> "${hours}h ${minutes}m" + minutes > 0 -> "${minutes}m" + else -> "< 1m" + } + } catch (e: Exception) { + "Unknown" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnhancedAddAttemptDialog( + session: ClimbSession, + gym: Gym, + problems: List, + onDismiss: () -> Unit, + onAttemptAdded: (Attempt) -> Unit, + onProblemCreated: (Problem) -> Unit +) { + var selectedProblem by remember { mutableStateOf(null) } + var selectedResult by remember { mutableStateOf(AttemptResult.FALL) } + var highestHold by remember { mutableStateOf("") } + var notes by remember { mutableStateOf("") } + var showCreateProblem by remember { mutableStateOf(false) } + + // New problem creation state + var newProblemName by remember { mutableStateOf("") } + var newProblemGrade by remember { mutableStateOf("") } + var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) } + var selectedDifficultySystem by remember { mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f) + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + Text( + text = "Add Attempt", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 20.dp) + ) + + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + item { + if (!showCreateProblem) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Select Problem", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + + if (problems.isEmpty()) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No active problems in this gym", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { showCreateProblem = true } + ) { + Text("Create New Problem") + } + } + } + } else { + LazyColumn( + modifier = Modifier.height(140.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(problems) { problem -> + val isSelected = selectedProblem?.id == problem.id + Card( + onClick = { selectedProblem = problem }, + colors = CardDefaults.cardColors( + containerColor = if (isSelected) + MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant, + ), + border = if (isSelected) + BorderStroke( + 2.dp, + MaterialTheme.colorScheme.primary + ) + else null, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = problem.name ?: "Unnamed Problem", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = if (isSelected) + MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}", + style = MaterialTheme.typography.bodyMedium, + color = if (isSelected) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + fontWeight = FontWeight.Medium + ) + } + } + } + } + + // Option to create new problem + OutlinedButton( + onClick = { showCreateProblem = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Create New Problem") + } + } + } + } else { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Create New Problem", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + + TextButton( + onClick = { showCreateProblem = false } + ) { + Text("← Back", color = MaterialTheme.colorScheme.primary) + } + } + + OutlinedTextField( + value = newProblemName, + onValueChange = { newProblemName = it }, + label = { Text("Problem Name") }, + placeholder = { Text("e.g., 'The Red Overhang'") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + + // Climb Type Selection + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Climb Type", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(gym.supportedClimbTypes) { climbType -> + FilterChip( + onClick = { selectedClimbType = climbType }, + label = { + Text( + climbType.name.lowercase().replaceFirstChar { it.uppercase() }, + fontWeight = FontWeight.Medium + ) + }, + selected = selectedClimbType == climbType, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + } + } + + // Difficulty System Selection + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Difficulty System", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(gym.difficultySystems) { system -> + FilterChip( + onClick = { selectedDifficultySystem = system }, + label = { + Text( + system.name, + fontWeight = FontWeight.Medium + ) + }, + selected = selectedDifficultySystem == system, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + } + } + + OutlinedTextField( + value = newProblemGrade, + onValueChange = { newProblemGrade = it }, + label = { Text("Grade *") }, + placeholder = { + Text(when (selectedDifficultySystem) { + DifficultySystem.V_SCALE -> "e.g., V0, V4, V10" + DifficultySystem.FONT -> "e.g., 3, 6A+, 8B" + DifficultySystem.YDS -> "e.g., 5.8, 5.12a" + DifficultySystem.FRENCH -> "e.g., 6a, 7c+" + DifficultySystem.CUSTOM -> "Custom grade" + else -> "Enter grade" + }) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ), + isError = newProblemGrade.isBlank(), + supportingText = if (newProblemGrade.isBlank()) { + { Text("Grade is required", color = MaterialTheme.colorScheme.error) } + } else null + ) + } + } + } + + // Result Selection (always shown) + item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Attempt Result", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier + .padding(12.dp) + .selectableGroup() + ) { + AttemptResult.entries.forEach { result -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = selectedResult == result, + onClick = { selectedResult = result }, + role = Role.RadioButton + ) + .padding(vertical = 4.dp) + ) { + RadioButton( + selected = selectedResult == result, + onClick = null, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary + ) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = result.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selectedResult == result) FontWeight.Medium else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Additional Details", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + + OutlinedTextField( + value = highestHold, + onValueChange = { highestHold = it }, + label = { Text("Highest Hold") }, + placeholder = { Text("e.g., 'jugs near the top', 'crux move'") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes") }, + placeholder = { Text("e.g., 'need to work on heel hooks', 'pumped out'") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 4, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Text("Cancel", fontWeight = FontWeight.Medium) + } + + Button( + onClick = { + if (showCreateProblem) { + // Create new problem first + if (newProblemGrade.isNotBlank()) { + val difficulty = DifficultyGrade( + system = selectedDifficultySystem, + grade = newProblemGrade, + numericValue = when (selectedDifficultySystem) { + DifficultySystem.V_SCALE -> newProblemGrade.removePrefix("V").toIntOrNull() ?: 0 + else -> newProblemGrade.hashCode() % 100 + } + ) + + val newProblem = Problem.create( + gymId = gym.id, + name = newProblemName.ifBlank { null }, + climbType = selectedClimbType, + difficulty = difficulty + ) + + onProblemCreated(newProblem) + + // Create attempt for the new problem + val attempt = Attempt.create( + sessionId = session.id, + problemId = newProblem.id, + result = selectedResult, + highestHold = highestHold.ifBlank { null }, + notes = notes.ifBlank { null } + ) + onAttemptAdded(attempt) + } + } else { + // Create attempt for selected problem + selectedProblem?.let { problem -> + val attempt = Attempt.create( + sessionId = session.id, + problemId = problem.id, + result = selectedResult, + highestHold = highestHold.ifBlank { null }, + notes = notes.ifBlank { null } + ) + onAttemptAdded(attempt) + } + } + }, + enabled = if (showCreateProblem) newProblemGrade.isNotBlank() else selectedProblem != null, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + ) { + Text("Add", fontWeight = FontWeight.Medium) + } + } + } + } + } +} + +@Composable +fun StatisticItem( + label: String, + value: String, + valueColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = valueColor + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} +} +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt new file mode 100644 index 0000000..c94ae10 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt @@ -0,0 +1,127 @@ +package com.atridad.openclimb.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.atridad.openclimb.data.model.Gym +import com.atridad.openclimb.ui.viewmodel.ClimbViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GymsScreen( + viewModel: ClimbViewModel, + onNavigateToGymDetail: (String) -> Unit, + onNavigateToAddGym: () -> Unit +) { + val gyms by viewModel.gyms.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Text( + text = "Climbing Gyms", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (gyms.isEmpty()) { + EmptyStateMessage( + title = "No Gyms Added", + message = "Add your favorite climbing gyms to start tracking your progress!", + onActionClick = { }, + actionText = "" + ) + } else { + LazyColumn { + items(gyms) { gym -> + GymCard( + gym = gym, + onClick = { onNavigateToGymDetail(gym.id) } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GymCard( + gym: Gym, + onClick: () -> Unit +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = gym.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + gym.location?.let { location -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = location, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row { + gym.supportedClimbTypes.forEach { climbType -> + AssistChip( + onClick = { }, + label = { + Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) + }, + modifier = Modifier.padding(end = 4.dp) + ) + } + } + + if (gym.difficultySystems.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.name }}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + gym.notes?.let { notes -> + if (notes.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = notes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2 + ) + } + } + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt new file mode 100644 index 0000000..9f2b3a0 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt @@ -0,0 +1,178 @@ +package com.atridad.openclimb.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.atridad.openclimb.data.model.Problem +import com.atridad.openclimb.ui.components.FullscreenImageViewer +import com.atridad.openclimb.ui.components.ImageDisplay +import com.atridad.openclimb.ui.viewmodel.ClimbViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProblemsScreen( + viewModel: ClimbViewModel, + onNavigateToProblemDetail: (String) -> Unit, + onNavigateToAddProblem: (String?) -> Unit +) { + val problems by viewModel.problems.collectAsState() + val gyms by viewModel.gyms.collectAsState() + var showImageViewer by remember { mutableStateOf(false) } + var selectedImagePaths by remember { mutableStateOf>(emptyList()) } + var selectedImageIndex by remember { mutableStateOf(0) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Text( + text = "Problems & Routes", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (problems.isEmpty()) { + EmptyStateMessage( + title = if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet", + message = if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!", + onActionClick = { }, + actionText = "" + ) + } else { + LazyColumn { + items(problems) { problem -> + ProblemCard( + problem = problem, + gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", + onClick = { onNavigateToProblemDetail(problem.id) }, + onImageClick = { imagePaths, index -> + selectedImagePaths = imagePaths + selectedImageIndex = index + showImageViewer = true + } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + + // Fullscreen Image Viewer + if (showImageViewer && selectedImagePaths.isNotEmpty()) { + FullscreenImageViewer( + imagePaths = selectedImagePaths, + initialIndex = selectedImageIndex, + onDismiss = { showImageViewer = false } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProblemCard( + problem: Problem, + gymName: String, + onClick: () -> Unit, + onImageClick: ((List, Int) -> Unit)? = null +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = problem.name ?: "Unnamed Problem", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = gymName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = problem.difficulty.grade, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = problem.climbType.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + problem.location?.let { location -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Location: $location", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (problem.tags.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Row { + problem.tags.take(3).forEach { tag -> + AssistChip( + onClick = { }, + label = { Text(tag) }, + modifier = Modifier.padding(end = 4.dp) + ) + } + } + } + + // Display images if any + if (problem.imagePaths.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + ImageDisplay( + imagePaths = problem.imagePaths.take(3), // Show max 3 images in list + imageSize = 60, + onImageClick = { index -> + onImageClick?.invoke(problem.imagePaths, index) + } + ) + } + + if (!problem.isActive) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Inactive", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt new file mode 100644 index 0000000..6d5b15a --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/SessionsScreen.kt @@ -0,0 +1,203 @@ +package com.atridad.openclimb.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.atridad.openclimb.data.model.ClimbSession +import com.atridad.openclimb.data.model.SessionStatus +import com.atridad.openclimb.ui.components.ActiveSessionBanner +import com.atridad.openclimb.ui.viewmodel.ClimbViewModel +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionsScreen( + viewModel: ClimbViewModel, + onNavigateToSessionDetail: (String) -> Unit, + onNavigateToAddSession: (String?) -> Unit +) { + val context = LocalContext.current + val sessions by viewModel.sessions.collectAsState() + val gyms by viewModel.gyms.collectAsState() + val activeSession by viewModel.activeSession.collectAsState() + + // Filter out active sessions from regular session list + val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED } + val activeSessionGym = activeSession?.let { session -> + gyms.find { it.id == session.gymId } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Climbing Sessions", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Active session banner + ActiveSessionBanner( + activeSession = activeSession, + gym = activeSessionGym, + onSessionClick = { + activeSession?.let { onNavigateToSessionDetail(it.id) } + }, + onEndSession = { + activeSession?.let { + viewModel.endSession(context, it.id) + } + } + ) + + if (activeSession != null) { + Spacer(modifier = Modifier.height(16.dp)) + } + + if (completedSessions.isEmpty() && activeSession == null) { + EmptyStateMessage( + title = if (gyms.isEmpty()) "No Gyms Available" else "No Sessions Yet", + message = if (gyms.isEmpty()) "Add a gym first to start tracking your climbing sessions!" else "Start your first climbing session!", + onActionClick = { }, + actionText = "" + ) + } else { + LazyColumn { + items(completedSessions) { session -> + SessionCard( + session = session, + gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym", + onClick = { onNavigateToSessionDetail(session.id) } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionCard( + session: ClimbSession, + gymName: String, + onClick: () -> Unit +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = gymName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = formatDate(session.date), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + session.duration?.let { duration -> + Text( + text = "Duration: ${duration} minutes", + style = MaterialTheme.typography.bodyMedium + ) + } + + session.notes?.let { notes -> + if (notes.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = notes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2 + ) + } + } + } + } +} + +@Composable +fun EmptyStateMessage( + title: String, + message: String, + onActionClick: () -> Unit, + actionText: String +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (actionText.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = onActionClick) { + Text(actionText) + } + } + } +} + +private fun formatDate(dateString: String): String { + return try { + val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00") + date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy")) + } catch (e: Exception) { + dateString + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..6c71a09 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt @@ -0,0 +1,367 @@ +package com.atridad.openclimb.ui.screens + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import android.os.Environment +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.atridad.openclimb.ui.viewmodel.ClimbViewModel +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: ClimbViewModel +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + val packageInfo = remember { + context.packageManager.getPackageInfo(context.packageName, 0) + } + val appVersion = packageInfo.versionName + + // File picker launcher for import - accepts both ZIP and JSON files + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + uri?.let { + try { + val inputStream = context.contentResolver.openInputStream(uri) + // Determine file extension from content resolver + val fileName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && cursor.moveToFirst()) { + cursor.getString(nameIndex) + } else null + } ?: "import_file" + + val extension = fileName.substringAfterLast(".", "") + val tempFileName = if (extension.isNotEmpty()) "temp_import.$extension" else "temp_import" + val tempFile = File(context.cacheDir, tempFileName) + + inputStream?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + viewModel.importData(tempFile) + } catch (e: Exception) { + viewModel.setError("Failed to read file: ${e.message}") + } + } + } + + // File picker launcher for export - save location (ZIP format with images) + val exportZipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/zip") + ) { uri -> + uri?.let { + try { + viewModel.exportDataToZipUri(context, uri) + } catch (e: Exception) { + viewModel.setError("Failed to save file: ${e.message}") + } + } + } + + // 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( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Text( + text = "Settings", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + + // Data Management Section + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Data Management", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Export Data + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) { + ListItem( + headlineContent = { Text("Export Data with Images") }, + supportingContent = { Text("Export all your climbing data and images to ZIP file (recommended)") }, + leadingContent = { Icon(Icons.Default.Share, contentDescription = null) }, + trailingContent = { + TextButton( + onClick = { + val defaultFileName = "openclimb_export_${ + java.time.LocalDateTime.now() + .toString() + .replace(":", "-") + .replace(".", "-") + }.zip" + exportZipLauncher.launch(defaultFileName) + }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Export ZIP") + } + } + } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) { + ListItem( + headlineContent = { Text("Export Data Only") }, + supportingContent = { Text("Export climbing data to JSON without images") }, + leadingContent = { Icon(Icons.Default.Share, contentDescription = null) }, + trailingContent = { + TextButton( + onClick = { + val defaultFileName = "openclimb_export_${ + java.time.LocalDateTime.now() + .toString() + .replace(":", "-") + .replace(".", "-") + }.json" + exportJsonLauncher.launch(defaultFileName) + }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Export JSON") + } + } + } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) { + ListItem( + headlineContent = { Text("Import Data") }, + supportingContent = { Text("Import climbing data from ZIP or JSON file") }, + leadingContent = { Icon(Icons.Default.Add, contentDescription = null) }, + trailingContent = { + TextButton( + onClick = { + importLauncher.launch("*/*") + }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Import") + } + } + } + ) + } + } + } + } + + // App Information Section + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "App Information", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) { + ListItem( + headlineContent = { Text("Version") }, + supportingContent = { Text(appVersion ?: "Unknown") }, + leadingContent = { Icon(Icons.Default.Info, contentDescription = null) } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) { + ListItem( + headlineContent = { Text("About") }, + supportingContent = { Text("OpenClimb - Track your climbing progress") }, + leadingContent = { Icon(Icons.Default.Info, contentDescription = null) } + ) + } + } + } + } + + + } + + // Show loading/message states + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + 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 + ) + } + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/ui/theme/Color.kt b/app/src/main/java/com/atridad/openclimb/ui/theme/Color.kt new file mode 100644 index 0000000..aaab909 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/theme/Color.kt @@ -0,0 +1,75 @@ +package com.atridad.openclimb.ui.theme + +import androidx.compose.ui.graphics.Color + +// Climbing-themed Material You color palette +// Orange - Primary (represents rock/sandstone climbing) +val ClimbOrange10 = Color(0xFF1F0E00) +val ClimbOrange20 = Color(0xFF3E1C00) +val ClimbOrange30 = Color(0xFF5D2B00) +val ClimbOrange40 = Color(0xFF7C3900) +val ClimbOrange80 = Color(0xFFFFB786) +val ClimbOrange90 = Color(0xFFFFDCC2) +val ClimbOrange100 = Color(0xFFFFFFFF) + +// Grey - Secondary (represents granite/slate) +val ClimbGrey10 = Color(0xFF1F1F1F) +val ClimbGrey20 = Color(0xFF2F2F2F) +val ClimbGrey30 = Color(0xFF484848) +val ClimbGrey40 = Color(0xFF606060) +val ClimbGrey80 = Color(0xFFC7C7C7) +val ClimbGrey90 = Color(0xFFE3E3E3) +val ClimbGrey100 = Color(0xFFFFFFFF) + +// Blue - Tertiary (represents ice/water) +val ClimbBlue10 = Color(0xFF001F2A) +val ClimbBlue20 = Color(0xFF003544) +val ClimbBlue30 = Color(0xFF004D61) +val ClimbBlue40 = Color(0xFF00677F) +val ClimbBlue80 = Color(0xFF5DDBFF) +val ClimbBlue90 = Color(0xFFB8EAFF) +val ClimbBlue100 = Color(0xFFFFFFFF) + +// Red - Error colors +val ClimbRed10 = Color(0xFF410001) +val ClimbRed20 = Color(0xFF680003) +val ClimbRed30 = Color(0xFF930006) +val ClimbRed40 = Color(0xFFBA1B1B) +val ClimbRed80 = Color(0xFFFFB4A9) +val ClimbRed90 = Color(0xFFFFDAD4) +val ClimbRed100 = Color(0xFFFFFFFF) + +// Neutral colors for surfaces +val ClimbNeutral0 = Color(0xFF000000) +val ClimbNeutral4 = Color(0xFF0F0F0F) +val ClimbNeutral6 = Color(0xFF141414) +val ClimbNeutral10 = Color(0xFF1F1F1F) +val ClimbNeutral12 = Color(0xFF232323) +val ClimbNeutral17 = Color(0xFF2C2C2C) +val ClimbNeutral20 = Color(0xFF313131) +val ClimbNeutral22 = Color(0xFF363636) +val ClimbNeutral24 = Color(0xFF393939) +val ClimbNeutral87 = Color(0xFFDDDDDD) +val ClimbNeutral90 = Color(0xFFE6E6E6) +val ClimbNeutral92 = Color(0xFFEBEBEB) +val ClimbNeutral94 = Color(0xFFF0F0F0) +val ClimbNeutral95 = Color(0xFFF3F3F3) +val ClimbNeutral96 = Color(0xFFF5F5F5) +val ClimbNeutral98 = Color(0xFFFAFAFA) +val ClimbNeutral100 = Color(0xFFFFFFFF) + +// Neutral variant colors for outlines and variants +val ClimbNeutralVariant30 = Color(0xFF484848) +val ClimbNeutralVariant50 = Color(0xFF797979) +val ClimbNeutralVariant60 = Color(0xFF939393) +val ClimbNeutralVariant80 = Color(0xFFC7C7C7) +val ClimbNeutralVariant90 = Color(0xFFE3E3E3) + +// Legacy colors for backward compatibility +val Purple80 = ClimbOrange80 +val PurpleGrey80 = ClimbGrey80 +val Pink80 = ClimbBlue80 + +val Purple40 = ClimbOrange40 +val PurpleGrey40 = ClimbGrey40 +val Pink40 = ClimbBlue40 \ No newline at end of file diff --git a/app/src/main/java/com/atridad/openclimb/ui/theme/Theme.kt b/app/src/main/java/com/atridad/openclimb/ui/theme/Theme.kt new file mode 100644 index 0000000..c02a3ba --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/theme/Theme.kt @@ -0,0 +1,123 @@ +package com.atridad.openclimb.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +// Climbing-themed dark color scheme with full Material You compatibility +private val DarkColorScheme = darkColorScheme( + primary = ClimbOrange80, + onPrimary = ClimbOrange20, + primaryContainer = ClimbOrange30, + onPrimaryContainer = ClimbOrange90, + secondary = ClimbGrey80, + onSecondary = ClimbGrey20, + secondaryContainer = ClimbGrey30, + onSecondaryContainer = ClimbGrey90, + tertiary = ClimbBlue80, + onTertiary = ClimbBlue20, + tertiaryContainer = ClimbBlue30, + onTertiaryContainer = ClimbBlue90, + error = ClimbRed80, + onError = ClimbRed20, + errorContainer = ClimbRed30, + onErrorContainer = ClimbRed90, + surface = ClimbNeutral10, + onSurface = ClimbNeutral90, + surfaceVariant = ClimbNeutralVariant30, + onSurfaceVariant = ClimbNeutralVariant80, + outline = ClimbNeutralVariant60, + outlineVariant = ClimbNeutralVariant30, + scrim = ClimbNeutral0, + inverseSurface = ClimbNeutral90, + inverseOnSurface = ClimbNeutral20, + inversePrimary = ClimbOrange40, + surfaceDim = ClimbNeutral6, + surfaceBright = ClimbNeutral24, + surfaceContainerLowest = ClimbNeutral4, + surfaceContainerLow = ClimbNeutral10, + surfaceContainer = ClimbNeutral12, + surfaceContainerHigh = ClimbNeutral17, + surfaceContainerHighest = ClimbNeutral22 +) + +// Climbing-themed light color scheme with full Material You compatibility +private val LightColorScheme = lightColorScheme( + primary = ClimbOrange40, + onPrimary = ClimbOrange100, + primaryContainer = ClimbOrange90, + onPrimaryContainer = ClimbOrange10, + secondary = ClimbGrey40, + onSecondary = ClimbGrey100, + secondaryContainer = ClimbGrey90, + onSecondaryContainer = ClimbGrey10, + tertiary = ClimbBlue40, + onTertiary = ClimbBlue100, + tertiaryContainer = ClimbBlue90, + onTertiaryContainer = ClimbBlue10, + error = ClimbRed40, + onError = ClimbRed100, + errorContainer = ClimbRed90, + onErrorContainer = ClimbRed10, + surface = ClimbNeutral98, + onSurface = ClimbNeutral10, + surfaceVariant = ClimbNeutralVariant90, + onSurfaceVariant = ClimbNeutralVariant30, + outline = ClimbNeutralVariant50, + outlineVariant = ClimbNeutralVariant80, + scrim = ClimbNeutral0, + inverseSurface = ClimbNeutral20, + inverseOnSurface = ClimbNeutral95, + inversePrimary = ClimbOrange80, + surfaceDim = ClimbNeutral87, + surfaceBright = ClimbNeutral98, + surfaceContainerLowest = ClimbNeutral100, + surfaceContainerLow = ClimbNeutral96, + surfaceContainer = ClimbNeutral94, + surfaceContainerHigh = ClimbNeutral92, + surfaceContainerHighest = ClimbNeutral90 +) + +@Composable +fun OpenClimbTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ and provides full Material You theming + // When enabled, it adapts to the user's system wallpaper colors + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/atridad/openclimb/ui/theme/Type.kt b/app/src/main/java/com/atridad/openclimb/ui/theme/Type.kt new file mode 100644 index 0000000..38ce75a --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/theme/Type.kt @@ -0,0 +1,18 @@ +package com.atridad.openclimb.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) \ No newline at end of file diff --git a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt new file mode 100644 index 0000000..25eaa82 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt @@ -0,0 +1,395 @@ +package com.atridad.openclimb.ui.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.atridad.openclimb.data.model.* +import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.service.SessionTrackingService +import com.atridad.openclimb.utils.ImageUtils +import com.atridad.openclimb.utils.SessionShareUtils +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +class ClimbViewModel( + private val repository: ClimbRepository +) : ViewModel() { + + // UI State flows + private val _uiState = MutableStateFlow(ClimbUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Data flows + val gyms = repository.getAllGyms().stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList() + ) + + val problems = repository.getAllProblems().stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList() + ) + + val sessions = repository.getAllSessions().stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList() + ) + + val activeSession = repository.getActiveSessionFlow().stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = null + ) + + val attempts = repository.getAllAttempts().stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList() + ) + + + + // Gym operations + fun addGym(gym: Gym) { + viewModelScope.launch { + repository.insertGym(gym) + } + } + + fun updateGym(gym: Gym) { + viewModelScope.launch { + repository.updateGym(gym) + } + } + + fun deleteGym(gym: Gym) { + viewModelScope.launch { + repository.deleteGym(gym) + } + } + + fun getGymById(id: String): Flow = flow { + emit(repository.getGymById(id)) + } + + // Problem operations + fun addProblem(problem: Problem) { + viewModelScope.launch { + repository.insertProblem(problem) + } + } + + fun updateProblem(problem: Problem) { + viewModelScope.launch { + repository.updateProblem(problem) + } + } + + fun deleteProblem(problem: Problem, context: Context) { + viewModelScope.launch { + // Delete associated images + problem.imagePaths.forEach { imagePath -> + ImageUtils.deleteImage(context, imagePath) + } + + repository.deleteProblem(problem) + + // Clean up any remaining orphaned images + cleanupOrphanedImages(context) + } + } + + private suspend fun cleanupOrphanedImages(context: Context) { + val allProblems = repository.getAllProblems().first() + val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() + ImageUtils.cleanupOrphanedImages(context, referencedImagePaths) + } + + fun getProblemById(id: String): Flow = flow { + emit(repository.getProblemById(id)) + } + + fun getProblemsByGym(gymId: String): Flow> = + repository.getProblemsByGym(gymId) + + // Session operations + fun addSession(session: ClimbSession) { + viewModelScope.launch { + repository.insertSession(session) + } + } + + fun updateSession(session: ClimbSession) { + viewModelScope.launch { + repository.updateSession(session) + } + } + + fun deleteSession(session: ClimbSession) { + viewModelScope.launch { + repository.deleteSession(session) + } + } + + fun getSessionById(id: String): Flow = flow { + emit(repository.getSessionById(id)) + } + + fun getSessionsByGym(gymId: String): Flow> = + repository.getSessionsByGym(gymId) + + // Active session management + fun startSession(context: Context, gymId: String, notes: String? = null) { + viewModelScope.launch { + val existingActive = repository.getActiveSession() + if (existingActive != null) { + _uiState.value = _uiState.value.copy( + error = "There's already an active session. Please end it first." + ) + return@launch + } + + val newSession = ClimbSession.create(gymId = gymId, notes = notes) + repository.insertSession(newSession) + + // Start the tracking service + val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id) + context.startForegroundService(serviceIntent) + + _uiState.value = _uiState.value.copy( + message = "Session started successfully!" + ) + } + } + + fun endSession(context: Context, sessionId: String) { + viewModelScope.launch { + val session = repository.getSessionById(sessionId) + if (session != null && session.status == SessionStatus.ACTIVE) { + val completedSession = with(ClimbSession) { session.complete() } + repository.updateSession(completedSession) + + // Stop the tracking service + val serviceIntent = SessionTrackingService.createStopIntent(context) + context.startService(serviceIntent) + + _uiState.value = _uiState.value.copy( + message = "Session completed!" + ) + } + } + } + + fun pauseSession(sessionId: String) { + viewModelScope.launch { + val session = repository.getSessionById(sessionId) + if (session != null && session.status == SessionStatus.ACTIVE) { + val pausedSession = session.copy( + status = SessionStatus.PAUSED, + updatedAt = java.time.LocalDateTime.now().toString() + ) + repository.updateSession(pausedSession) + } + } + } + + fun resumeSession(sessionId: String) { + viewModelScope.launch { + val session = repository.getSessionById(sessionId) + if (session != null && session.status == SessionStatus.PAUSED) { + val resumedSession = session.copy( + status = SessionStatus.ACTIVE, + updatedAt = java.time.LocalDateTime.now().toString() + ) + repository.updateSession(resumedSession) + } + } + } + + // Attempt operations + fun addAttempt(attempt: Attempt) { + viewModelScope.launch { + repository.insertAttempt(attempt) + } + } + + fun updateAttempt(attempt: Attempt) { + viewModelScope.launch { + repository.updateAttempt(attempt) + } + } + + fun deleteAttempt(attempt: Attempt) { + viewModelScope.launch { + repository.deleteAttempt(attempt) + } + } + + fun getAttemptsBySession(sessionId: String): Flow> = + repository.getAttemptsBySession(sessionId) + + fun getAttemptsByProblem(problemId: String): Flow> = + repository.getAttemptsByProblem(problemId) + + + + // Analytics operations + // fun getProblemProgress(problemId: String): Flow = + // repository.getProblemProgress(problemId) + + // fun getSessionSummary(sessionId: String): Flow = + // 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) { + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(isLoading = true) + repository.exportAllDataToZipUri(context, uri) + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "Data with images exported successfully" + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Export failed: ${e.message}" + ) + } + } + } + + fun importData(file: File) { + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(isLoading = true) + + // Check if it's a ZIP file or JSON file + if (file.name.lowercase().endsWith(".zip")) { + repository.importDataFromZip(file) + } else { + repository.importDataFromJson(file) + } + + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "Data imported successfully from ${file.name}" + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Import failed: ${e.message}" + ) + } + } + } + + // UI state operations + fun clearMessage() { + _uiState.value = _uiState.value.copy(message = null) + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + fun setError(message: String) { + _uiState.value = _uiState.value.copy(error = message) + } + + // Search operations + fun searchGyms(query: String): Flow> = repository.searchGyms(query) + fun searchProblems(query: String): Flow> = repository.searchProblems(query) + + // Share operations + suspend fun generateSessionShareCard( + context: Context, + sessionId: String + ): File? = withContext(Dispatchers.IO) { + try { + val session = repository.getSessionById(sessionId) ?: return@withContext null + val attempts = repository.getAttemptsBySession(sessionId).first() + val problems = repository.getAllProblems().first().filter { problem -> + attempts.any { it.problemId == problem.id } + } + val gym = repository.getGymById(session.gymId) ?: return@withContext null + + val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems) + SessionShareUtils.generateShareCard(context, session, gym, stats) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(error = "Failed to generate share card: ${e.message}") + null + } + } + + fun shareSessionCard(context: Context, imageFile: File) { + SessionShareUtils.shareSessionCard(context, imageFile) + } +} + +data class ClimbUiState( + val isLoading: Boolean = false, + val message: String? = null, + val error: String? = null +) diff --git a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt new file mode 100644 index 0000000..1fe048e --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt @@ -0,0 +1,18 @@ +package com.atridad.openclimb.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.atridad.openclimb.data.repository.ClimbRepository + +class ClimbViewModelFactory( + private val repository: ClimbRepository +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) { + return ClimbViewModel(repository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt b/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt new file mode 100644 index 0000000..448cf57 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt @@ -0,0 +1,196 @@ +package com.atridad.openclimb.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.UUID + +object ImageUtils { + + private const val IMAGES_DIR = "problem_images" + private const val MAX_IMAGE_SIZE = 1024 + private const val IMAGE_QUALITY = 85 + + /** + * Creates the images directory if it doesn't exist + */ + private fun getImagesDirectory(context: Context): File { + val imagesDir = File(context.filesDir, IMAGES_DIR) + if (!imagesDir.exists()) { + imagesDir.mkdirs() + } + return imagesDir + } + + /** + * Saves an image from URI to app's private storage with compression + * @param context Android context + * @param imageUri URI of the image to save + * @return The relative file path if successful, null otherwise + */ + fun saveImageFromUri(context: Context, imageUri: Uri): String? { + return try { + val inputStream = context.contentResolver.openInputStream(imageUri) + inputStream?.use { input -> + // Decode and compress the image + val originalBitmap = BitmapFactory.decodeStream(input) + val compressedBitmap = compressImage(originalBitmap) + + // Generate unique filename + val filename = "${UUID.randomUUID()}.jpg" + val imageFile = File(getImagesDirectory(context), filename) + + // Save compressed image + FileOutputStream(imageFile).use { output -> + compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) + } + + // Clean up bitmaps + originalBitmap.recycle() + compressedBitmap.recycle() + + // Return relative path + "$IMAGES_DIR/$filename" + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Compresses and resizes an image bitmap + */ + private fun compressImage(original: Bitmap): Bitmap { + val width = original.width + val height = original.height + + // Calculate the scaling factor + val scaleFactor = if (width > height) { + if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f + } else { + if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f + } + + return if (scaleFactor < 1f) { + val newWidth = (width * scaleFactor).toInt() + val newHeight = (height * scaleFactor).toInt() + Bitmap.createScaledBitmap(original, newWidth, newHeight, true) + } else { + original + } + } + + /** + * Gets the full file path for an image + * @param context Android context + * @param relativePath The relative path returned by saveImageFromUri + * @return Full file path + */ + fun getImageFile(context: Context, relativePath: String): File { + return File(context.filesDir, relativePath) + } + + /** + * Deletes an image file + * @param context Android context + * @param relativePath The relative path of the image to delete + * @return true if deleted successfully, false otherwise + */ + fun deleteImage(context: Context, relativePath: String): Boolean { + return try { + val file = getImageFile(context, relativePath) + file.delete() + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + /** + * Copies an image file to export directory + * @param context Android context + * @param relativePath The relative path of the image + * @param exportDir The directory to copy to + * @return The filename in the export directory, null if failed + */ + fun copyImageForExport(context: Context, relativePath: String, exportDir: File): String? { + return try { + val sourceFile = getImageFile(context, relativePath) + if (!sourceFile.exists()) return null + + val filename = sourceFile.name + val destFile = File(exportDir, filename) + + sourceFile.copyTo(destFile, overwrite = true) + filename + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Imports an image file from the import directory + * @param context Android context + * @param sourceFile The source image file to import + * @return The relative path in app storage, null if failed + */ + fun importImageFile(context: Context, sourceFile: File): String? { + return try { + if (!sourceFile.exists()) return null + + // Generate new filename to avoid conflicts + val extension = sourceFile.extension.ifEmpty { "jpg" } + val filename = "${UUID.randomUUID()}.$extension" + val destFile = File(getImagesDirectory(context), filename) + + sourceFile.copyTo(destFile, overwrite = true) + "$IMAGES_DIR/$filename" + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Gets all image files in the images directory + * @param context Android context + * @return List of relative paths for all images + */ + fun getAllImages(context: Context): List { + return try { + val imagesDir = getImagesDirectory(context) + imagesDir.listFiles()?.mapNotNull { file -> + if (file.isFile && (file.extension == "jpg" || file.extension == "jpeg" || file.extension == "png")) { + "$IMAGES_DIR/${file.name}" + } else null + } ?: emptyList() + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } + + /** + * Cleans up orphaned images that are not referenced by any problems + * @param context Android context + * @param referencedPaths Set of image paths that are still being used + */ + fun cleanupOrphanedImages(context: Context, referencedPaths: Set) { + try { + val allImages = getAllImages(context) + val orphanedImages = allImages.filter { it !in referencedPaths } + + orphanedImages.forEach { path -> + deleteImage(context, path) + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt b/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt new file mode 100644 index 0000000..4ec52b0 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt @@ -0,0 +1,336 @@ +package com.atridad.openclimb.utils + +import android.content.Context +import android.content.Intent +import android.graphics.* +import android.graphics.drawable.GradientDrawable +import androidx.core.content.FileProvider +import com.atridad.openclimb.data.model.* +import java.io.File +import java.io.FileOutputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.math.roundToInt + +object SessionShareUtils { + + data class SessionStats( + val totalAttempts: Int, + val successfulAttempts: Int, + val problems: List, + val uniqueProblemsAttempted: Int, + val uniqueProblemsCompleted: Int, + val averageGrade: String?, + val sessionDuration: String, + val topResult: AttemptResult? + ) + + fun calculateSessionStats( + session: ClimbSession, + attempts: List, + problems: List + ): SessionStats { + val successfulResults = listOf( + AttemptResult.SUCCESS, + AttemptResult.FLASH, + AttemptResult.REDPOINT, + AttemptResult.ONSIGHT + ) + + val successfulAttempts = attempts.filter { it.result in successfulResults } + val uniqueProblems = attempts.map { it.problemId }.distinct() + val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct() + + val attemptedProblems = problems.filter { it.id in uniqueProblems } + val averageGrade = if (attemptedProblems.isNotEmpty()) { + // This is a simplified average - in reality you'd need proper grade conversion + val gradeValues = attemptedProblems.mapNotNull { problem -> + problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull() + } + if (gradeValues.isNotEmpty()) { + "V${gradeValues.average().roundToInt()}" + } else null + } else null + + val duration = if (session.duration != null) "${session.duration}m" else "Unknown" + val topResult = attempts.maxByOrNull { + when (it.result) { + AttemptResult.ONSIGHT -> 5 + AttemptResult.FLASH -> 4 + AttemptResult.REDPOINT -> 3 + AttemptResult.SUCCESS -> 2 + AttemptResult.FALL -> 1 + else -> 0 + } + }?.result + + return SessionStats( + totalAttempts = attempts.size, + successfulAttempts = successfulAttempts.size, + problems = attemptedProblems, + uniqueProblemsAttempted = uniqueProblems.size, + uniqueProblemsCompleted = uniqueCompletedProblems.size, + averageGrade = averageGrade, + sessionDuration = duration, + topResult = topResult + ) + } + + private fun calculateDuration(startTime: String?, endTime: String?): String { + return try { + if (startTime != null && endTime != null) { + val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + val start = LocalDateTime.parse(startTime, formatter) + val end = LocalDateTime.parse(endTime, formatter) + val duration = java.time.Duration.between(start, end) + + val hours = duration.toHours() + val minutes = duration.toMinutes() % 60 + + when { + hours > 0 -> "${hours}h ${minutes}m" + minutes > 0 -> "${minutes}m" + else -> "< 1m" + } + } else { + "Unknown" + } + } catch (e: Exception) { + "Unknown" + } + } + + fun generateShareCard( + context: Context, + session: ClimbSession, + gym: Gym, + stats: SessionStats + ): File? { + return try { + val width = 1080 + val height = 1350 + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + val gradientDrawable = GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + intArrayOf( + Color.parseColor("#667eea"), + Color.parseColor("#764ba2") + ) + ) + gradientDrawable.setBounds(0, 0, width, height) + gradientDrawable.draw(canvas) + + // Setup paint objects + val titlePaint = Paint().apply { + color = Color.WHITE + textSize = 72f + typeface = Typeface.DEFAULT_BOLD + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + val subtitlePaint = Paint().apply { + color = Color.parseColor("#E8E8E8") + textSize = 48f + typeface = Typeface.DEFAULT + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + val statLabelPaint = Paint().apply { + color = Color.parseColor("#B8B8B8") + textSize = 36f + typeface = Typeface.DEFAULT + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + val statValuePaint = Paint().apply { + color = Color.WHITE + textSize = 64f + typeface = Typeface.DEFAULT_BOLD + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + val cardPaint = Paint().apply { + color = Color.parseColor("#40FFFFFF") + isAntiAlias = true + } + + // Draw main card background + val cardRect = RectF(60f, 200f, width - 60f, height - 100f) + canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint) + + // Draw content + var yPosition = 300f + + // Title + canvas.drawText("Climbing Session", width / 2f, yPosition, titlePaint) + yPosition += 80f + + // Gym and date + canvas.drawText(gym.name, width / 2f, yPosition, subtitlePaint) + yPosition += 60f + + val dateText = formatSessionDate(session.date) + canvas.drawText(dateText, width / 2f, yPosition, subtitlePaint) + yPosition += 120f + + // Stats grid + val statsStartY = yPosition + val columnWidth = width / 2f + + // Left column stats + var leftY = statsStartY + drawStatItem(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint) + leftY += 140f + drawStatItem(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint) + leftY += 140f + drawStatItem(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint) + + // Right column stats + var rightY = statsStartY + drawStatItem(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint) + rightY += 140f + drawStatItem(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint) + rightY += 140f + + stats.averageGrade?.let { grade -> + drawStatItem(canvas, width - columnWidth / 2f, rightY, "Avg Grade", grade, statLabelPaint, statValuePaint) + } + + // Success rate arc + if (stats.totalAttempts > 0) { + val successRate = (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100 + drawSuccessRateArc(canvas, width / 2f, height - 280f, successRate, statLabelPaint, statValuePaint) + } + + // App branding + val brandingPaint = Paint().apply { + color = Color.parseColor("#80FFFFFF") + textSize = 32f + typeface = Typeface.DEFAULT + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + canvas.drawText("OpenClimb", width / 2f, height - 40f, brandingPaint) + + // Save to file + val shareDir = File(context.cacheDir, "shares") + if (!shareDir.exists()) { + shareDir.mkdirs() + } + + val filename = "session_${session.id}_${System.currentTimeMillis()}.png" + val file = File(shareDir, filename) + + val outputStream = FileOutputStream(file) + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + outputStream.flush() + outputStream.close() + + bitmap.recycle() + + file + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + private fun drawStatItem( + canvas: Canvas, + x: Float, + y: Float, + label: String, + value: String, + labelPaint: Paint, + valuePaint: Paint + ) { + canvas.drawText(value, x, y, valuePaint) + canvas.drawText(label, x, y + 50f, labelPaint) + } + + private fun drawSuccessRateArc( + canvas: Canvas, + centerX: Float, + centerY: Float, + successRate: Float, + labelPaint: Paint, + valuePaint: Paint + ) { + val radius = 80f + val strokeWidth = 16f + + // Background arc + val bgPaint = Paint().apply { + color = Color.parseColor("#40FFFFFF") + style = Paint.Style.STROKE + this.strokeWidth = strokeWidth + isAntiAlias = true + strokeCap = Paint.Cap.ROUND + } + + // Success arc + val successPaint = Paint().apply { + color = Color.parseColor("#4CAF50") + style = Paint.Style.STROKE + this.strokeWidth = strokeWidth + isAntiAlias = true + strokeCap = Paint.Cap.ROUND + } + + val rect = RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius) + + // Draw background arc (full circle) + canvas.drawArc(rect, -90f, 360f, false, bgPaint) + + // Draw success arc + val sweepAngle = (successRate / 100f) * 360f + canvas.drawArc(rect, -90f, sweepAngle, false, successPaint) + + // Draw percentage text + val percentText = "${successRate.roundToInt()}%" + canvas.drawText(percentText, centerX, centerY + 10f, valuePaint) + canvas.drawText("Success Rate", centerX, centerY + 60f, labelPaint) + } + + private fun formatSessionDate(dateString: String): String { + return try { + val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + val date = LocalDateTime.parse(dateString, formatter) + val displayFormatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy") + date.format(displayFormatter) + } catch (e: Exception) { + dateString.take(10) + } + } + + fun shareSessionCard(context: Context, imageFile: File) { + try { + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + imageFile + ) + + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! 🧗‍♀️ #OpenClimb") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + val chooser = Intent.createChooser(shareIntent, "Share Session") + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooser) + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt b/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt new file mode 100644 index 0000000..0438e44 --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt @@ -0,0 +1,207 @@ +package com.atridad.openclimb.utils + +import android.content.Context +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.time.LocalDateTime +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +object ZipExportImportUtils { + + private const val DATA_JSON_FILENAME = "data.json" + private const val IMAGES_DIR_NAME = "images" + + /** + * Creates a ZIP file containing the JSON data and all referenced images + * @param context Android context + * @param exportData The data to export (should be serializable) + * @param referencedImagePaths Set of image paths referenced in the data + * @param directory Optional directory to save to, uses default if null + * @return The created ZIP file + */ + fun createExportZip( + context: Context, + exportData: com.atridad.openclimb.data.repository.ClimbDataExport, + referencedImagePaths: Set, + directory: File? = null + ): File { + val exportDir = directory ?: File(context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS), "OpenClimb") + if (!exportDir.exists()) { + exportDir.mkdirs() + } + + val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-") + val zipFile = File(exportDir, "openclimb_export_$timestamp.zip") + + ZipOutputStream(FileOutputStream(zipFile)).use { zipOut -> + // Add JSON data file + val json = Json { prettyPrint = true } + 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 + referencedImagePaths.forEach { imagePath -> + try { + 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() + } + } + } + + return zipFile + } + + /** + * Creates a ZIP file and writes it to a provided URI + * @param context Android context + * @param uri The URI to write to + * @param exportData The data to export + * @param referencedImagePaths Set of image paths referenced in the data + */ + fun createExportZipToUri( + context: Context, + uri: android.net.Uri, + exportData: com.atridad.openclimb.data.repository.ClimbDataExport, + referencedImagePaths: Set + ) { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + ZipOutputStream(outputStream).use { zipOut -> + // Add JSON data file + val json = Json { prettyPrint = true } + 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 + referencedImagePaths.forEach { imagePath -> + try { + 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() + } + } + } + } ?: throw IOException("Could not open output stream") + } + + /** + * Data class to hold extraction results + */ + data class ImportResult( + val jsonContent: String, + val importedImagePaths: Map // original filename -> new relative path + ) + + /** + * Extracts a ZIP file and returns the JSON content and imported image paths + * @param context Android context + * @param zipFile The ZIP file to extract + * @return ImportResult containing the JSON and image path mappings + */ + fun extractImportZip(context: Context, zipFile: File): ImportResult { + var jsonContent = "" + val importedImagePaths = mutableMapOf() + + ZipInputStream(FileInputStream(zipFile)).use { zipIn -> + var entry = zipIn.nextEntry + + while (entry != null) { + when { + entry.name == DATA_JSON_FILENAME -> { + // Read JSON data + jsonContent = zipIn.readBytes().toString(Charsets.UTF_8) + } + + entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> { + // Extract image file + 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 + } + } + + 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 + * This function maps the old image paths to the new ones after import + */ + fun updateProblemImagePaths( + problems: List, + imagePathMapping: Map + ): List { + return problems.map { problem -> + val updatedImagePaths = problem.imagePaths.mapNotNull { oldPath -> + // Extract filename from the old path + val filename = oldPath.substringAfterLast("/") + imagePathMapping[filename] + } + problem.copy(imagePaths = updatedImagePaths) + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..93d5beb --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + OpenClimb + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..07c956e --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..20e2a01 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..79c0871 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,64 @@ +[versions] +agp = "8.9.1" +kotlin = "2.0.21" +coreKtx = "1.15.0" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +lifecycleRuntimeKtx = "2.9.2" +activityCompose = "1.10.1" +composeBom = "2024.09.00" +room = "2.6.1" +navigation = "2.8.4" +viewmodel = "2.9.2" +kotlinxSerialization = "1.7.1" +kotlinxCoroutines = "1.9.0" +coil = "2.7.0" +ksp = "2.0.21-1.0.25" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } + +# Room Database +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } + +# Navigation +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } + +# ViewModel +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewmodel" } + +# Serialization +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +# Coroutines +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } + +# Image Loading +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } + +# Charts - MPAndroidChart for now, will be replaced with Vico when stable +mpandroidchart = { group = "com.github.PhilJay", name = "MPAndroidChart", version = "v3.1.0" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..799922d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Aug 15 11:23:25 MDT 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/local.properties b/local.properties new file mode 100644 index 0000000..e3fa214 --- /dev/null +++ b/local.properties @@ -0,0 +1,10 @@ +## This file is automatically generated by Android Studio. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file should *NOT* be checked into Version Control Systems, +# as it contains information specific to your local configuration. +# +# Location of the SDK. This is only used by Gradle. +# For customization when using a Version Control System, please read the +# header note. +sdk.dir=/Users/atridad/Library/Android/sdk \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..8d5dd2f --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } +} + +rootProject.name = "OpenClimb" +include(":app")