+
+
\ 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 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml
new file mode 100644
index 0000000..3081bab
--- /dev/null
+++ b/app/src/main/res/xml/file_provider_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt b/app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt
new file mode 100644
index 0000000..39188ef
--- /dev/null
+++ b/app/src/test/java/com/atridad/openclimb/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.atridad.openclimb
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..952b930
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
\ No newline at end of file
diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html
new file mode 100644
index 0000000..fdfe85c
--- /dev/null
+++ b/build/reports/problems/problems-report.html
@@ -0,0 +1,663 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 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")