Compare commits

...

32 Commits

Author SHA1 Message Date
8db6ed0e82 1.3.0 - Graphing Fixes 2025-08-28 00:18:54 -06:00
8b9901383a 1.1.2 - More fixes for notification reliability 2025-08-27 22:21:53 -06:00
cf2adeef7a 1.1.2 - More fixes for notification reliability 2025-08-22 23:22:23 -06:00
a7481135b4 1.1.1 - More fixes for notification reliabilityyyyy 2025-08-22 21:00:08 -06:00
748a23e1c0 1.1.1 - More fixes for notification reliability 2025-08-22 20:59:36 -06:00
f078cfc6e1 1.1.0 - Export/Import overhaul 2025-08-22 20:39:19 -06:00
8bb1f422c1 1.0.1 - Notification reliability update... again 2025-08-22 19:13:40 -06:00
327dfba425 1.0.1 - Notification reliability update 2025-08-22 19:11:21 -06:00
96759e402d 1.0.0 - 1.0 baybeeeee 2025-08-22 16:19:25 -06:00
ed76fb2fb2 Remove outdated comment 2025-08-22 10:56:43 -06:00
870278f240 0.5.0 - Optimizations and better session management 2025-08-19 00:35:04 -06:00
4eef77bd3b 0.4.5 2025-08-18 09:53:40 -06:00
2d957db948 0.4.5 2025-08-18 09:52:53 -06:00
22bed6a961 Merge pull request 'Updated to support API version 36 properly' (#2) from dependencies into main
Reviewed-on: atridad/OpenClimb#2
2025-08-18 15:49:36 +00:00
b443c18a19 Updated to support API version 36 properly 2025-08-18 00:46:28 -06:00
89f1e350b3 0.4.4 - Cleaned up range views 2025-08-17 01:29:15 -06:00
0f976f685f 0.4.3 - Removing Average Grade... its not useful 2025-08-17 01:13:10 -06:00
c07186a7df 0.4.2 - Fixed issue with photo upload streams 2025-08-17 00:57:48 -06:00
15a5e217a5 0.4.1 - Small fix for share image 2025-08-16 20:20:30 -06:00
b86ab591fe Readme changes w obtainium 2025-08-16 19:06:39 -06:00
70c85d159e 0.4.0 - Bug fixes and improvements 2025-08-16 19:04:11 -06:00
d6c5e937df MOAR 2025-08-16 02:35:12 -06:00
829bbbff7a More cleanup 2025-08-16 02:34:29 -06:00
e1ebf412bd Add .gitignore 2025-08-16 02:33:43 -06:00
5c133b655e Remove files I dont need committed... 2025-08-16 02:33:11 -06:00
cc1edbc65c 0.3.3 2025-08-16 02:31:52 -06:00
ca770b9db3 0.3.2 - Optimizations 2025-08-16 00:48:38 -06:00
7edb7c8191 0.3.1 - Bugfix for status bar 2025-08-15 19:38:01 -06:00
1ca6b33882 Build 2025-08-15 19:31:35 -06:00
bd6b5cc652 0.3.0 - Filtering and Better Scales 2025-08-15 19:30:50 -06:00
6e16a30429 0.2.0 quick fixes 2025-08-15 14:51:30 -06:00
66fdef78d9 Adding icons 2025-08-15 14:42:36 -06:00
71 changed files with 3900 additions and 3302 deletions

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Gradle files
.gradle/
build/
release/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.aab
*.apk
output-metadata.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof

View File

@@ -1,2 +0,0 @@
#Fri Aug 15 12:27:13 MDT 2025
gradle.version=8.11.1

View File

@@ -1,2 +0,0 @@
#Fri Aug 15 12:29:02 MDT 2025
java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home

Binary file not shown.

View File

@@ -817,6 +817,19 @@
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="default" value="true" />
<option name="id" value="tokay" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />

View File

@@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-27T22:55:36.064836Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Pixel_9a.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

3
.idea/gradle.xml generated
View File

@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="gradleJvm" value="#JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />

8
.idea/misc.xml generated
View File

@@ -1,10 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" />
</project>

View File

@@ -1,87 +0,0 @@
kotlin version: 2.0.21
error message: java.lang.IllegalStateException: Storage for [/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.set(PersistentStorage.kt:96)
at org.jetbrains.kotlin.incremental.LookupStorage.addFileIfNeeded(LookupStorage.kt:165)
at org.jetbrains.kotlin.incremental.LookupStorage.addAll$lambda$4(LookupStorage.kt:117)
at org.jetbrains.kotlin.utils.CollectionsKt.keysToMap(collections.kt:117)
at org.jetbrains.kotlin.incremental.LookupStorage.addAll(LookupStorage.kt:117)
at org.jetbrains.kotlin.incremental.BuildUtilKt.update(buildUtil.kt:134)
at com.google.devtools.ksp.LookupStorageWrapperImpl.update(IncrementalContext.kt:231)
at com.google.devtools.ksp.common.IncrementalContextBase.updateLookupCache(IncrementalContextBase.kt:133)
at com.google.devtools.ksp.common.IncrementalContextBase.updateCaches(IncrementalContextBase.kt:365)
at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:471)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:362)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Suppressed: java.lang.Exception: Storage[/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageIfExists(LazyStorage.kt:53)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.get(LazyStorage.kt:76)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.get(PersistentStorage.kt:92)
at org.jetbrains.kotlin.incremental.LookupStorage.get(LookupStorage.kt:99)
at com.google.devtools.ksp.LookupStorageWrapperImpl.get(IncrementalContext.kt:224)
at com.google.devtools.ksp.common.IncrementalContextBase.calcDirtyFiles(IncrementalContextBase.kt:234)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:196)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:189)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.handleException(KotlinSymbolProcessingExtension.kt:414)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:189)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
... 23 more

View File

@@ -1,87 +0,0 @@
kotlin version: 2.0.21
error message: java.lang.IllegalStateException: Storage for [/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.set(PersistentStorage.kt:96)
at org.jetbrains.kotlin.incremental.LookupStorage.addFileIfNeeded(LookupStorage.kt:165)
at org.jetbrains.kotlin.incremental.LookupStorage.addAll$lambda$4(LookupStorage.kt:117)
at org.jetbrains.kotlin.utils.CollectionsKt.keysToMap(collections.kt:117)
at org.jetbrains.kotlin.incremental.LookupStorage.addAll(LookupStorage.kt:117)
at org.jetbrains.kotlin.incremental.BuildUtilKt.update(buildUtil.kt:134)
at com.google.devtools.ksp.LookupStorageWrapperImpl.update(IncrementalContext.kt:231)
at com.google.devtools.ksp.common.IncrementalContextBase.updateLookupCache(IncrementalContextBase.kt:133)
at com.google.devtools.ksp.common.IncrementalContextBase.updateCaches(IncrementalContextBase.kt:365)
at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:471)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:362)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Suppressed: java.lang.Exception: Storage[/Users/atridad/Developer/personal/OpenClimb/app/build/kspCaches/debug/symbolLookups/id-to-file.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:55)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:45)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:71)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageIfExists(LazyStorage.kt:53)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.get(LazyStorage.kt:76)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.get(PersistentStorage.kt:92)
at org.jetbrains.kotlin.incremental.LookupStorage.get(LookupStorage.kt:99)
at com.google.devtools.ksp.LookupStorageWrapperImpl.get(IncrementalContext.kt:224)
at com.google.devtools.ksp.common.IncrementalContextBase.calcDirtyFiles(IncrementalContextBase.kt:234)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:196)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension$doAnalysis$2.invoke(KotlinSymbolProcessingExtension.kt:189)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.handleException(KotlinSymbolProcessingExtension.kt:414)
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:189)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
... 23 more

View File

@@ -1,3 +0,0 @@
{
"java.configuration.updateBuildConfiguration": "disabled"
}

View File

@@ -7,7 +7,7 @@ This is a FOSS Android app meant to help climbers track their sessions, routes/p
You have two options:
1. Download the latest APK from the Released page
2. Use <a href="">Obtainium</a>
2. Use <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D">Obtainium</a>
## Requirements

View File

@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -8,21 +10,21 @@ plugins {
android {
namespace = "com.atridad.openclimb"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "com.atridad.openclimb"
minSdk = 31
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
minSdk = 34
targetSdk = 36
versionCode = 19
versionName = "1.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -30,17 +32,27 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "11"
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
buildFeatures {
compose = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
// Core Android libraries
implementation(libs.androidx.core.ktx)
@@ -57,6 +69,7 @@ dependencies {
// Room Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// Navigation
@@ -74,13 +87,18 @@ dependencies {
// 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)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)

View File

@@ -8,6 +8,9 @@
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Hardware features -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Permissions for notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -27,7 +30,7 @@
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.OpenClimb">
android:theme="@style/Theme.OpenClimb.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -51,7 +54,8 @@
android:name=".service.SessionTrackingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
android:foregroundServiceType="specialUse"
android:description="@string/session_tracking_service_description">
<meta-data
android:name="android.app.foreground_service_type"
android:value="specialUse" />

View File

@@ -13,6 +13,7 @@ import com.atridad.openclimb.ui.theme.OpenClimbTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(R.style.Theme_OpenClimb)
enableEdgeToEdge()
setContent {
OpenClimbTheme {

View File

@@ -53,6 +53,9 @@ interface AttemptDao {
@Query("SELECT COUNT(*) FROM attempts")
suspend fun getAttemptsCount(): Int
@Query("DELETE FROM attempts")
suspend fun deleteAllAttempts()
@Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId")
suspend fun getAttemptsCountBySession(sessionId: String): Int

View File

@@ -59,6 +59,9 @@ interface ClimbSessionDao {
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
suspend fun getActiveSession(): ClimbSession?
@Query("DELETE FROM climb_sessions")
suspend fun deleteAllSessions()
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
fun getActiveSessionFlow(): Flow<ClimbSession?>
}

View File

@@ -37,4 +37,7 @@ interface GymDao {
@Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'")
fun searchGyms(searchQuery: String): Flow<List<Gym>>
@Query("DELETE FROM gyms")
suspend fun deleteAllGyms()
}

View File

@@ -59,4 +59,10 @@ interface ProblemDao {
ORDER BY updatedAt DESC
""")
fun searchProblems(searchQuery: String): Flow<List<Problem>>
@Query("SELECT COUNT(*) FROM problems")
suspend fun getProblemsCount(): Int
@Query("DELETE FROM problems")
suspend fun deleteAllProblems()
}

View File

@@ -9,12 +9,10 @@ import java.time.LocalDateTime
@Serializable
enum class AttemptResult {
SUCCESS, // Completed the problem/route
FALL, // Fell but made progress
NO_PROGRESS, // Couldn't make meaningful progress
FLASH, // Completed on first try
REDPOINT, // Completed after previous attempts
ONSIGHT // Completed on first try without prior knowledge
SUCCESS,
FALL,
NO_PROGRESS,
FLASH,
}
@Entity(

View File

@@ -31,10 +31,10 @@ data class ClimbSession(
@PrimaryKey
val id: String,
val gymId: String,
val date: String, // ISO date string
val startTime: String? = null, // When session was started
val endTime: String? = null, // When session was completed
val duration: Long? = null, // Duration in minutes (calculated when completed)
val date: String,
val startTime: String? = null,
val endTime: String? = null,
val duration: Long? = null,
val status: SessionStatus = SessionStatus.ACTIVE,
val notes: String? = null,
val createdAt: String,
@@ -65,7 +65,7 @@ data class ClimbSession(
val start = LocalDateTime.parse(startTime)
val end = LocalDateTime.parse(endTime)
java.time.Duration.between(start, end).toMinutes()
} catch (e: Exception) {
} catch (_: Exception) {
null
}
} else null

View File

@@ -5,5 +5,13 @@ import kotlinx.serialization.Serializable
@Serializable
enum class ClimbType {
ROPE,
BOULDER
BOULDER;
/**
* Get the display name
*/
fun getDisplayName(): String = when (this) {
ROPE -> "Rope"
BOULDER -> "Bouldering"
}
}

View File

@@ -4,23 +4,105 @@ import kotlinx.serialization.Serializable
@Serializable
enum class DifficultySystem {
// Rope climbing systems
YDS, // Yosemite Decimal System (5.1 - 5.15d)
FRENCH, // French system (3 - 9c+)
UIAA, // UIAA system (I - XII+)
BRITISH, // British system (Mod - E11)
// Bouldering
V_SCALE, // V-Scale (VB - V17)
FONT, // Fontainebleau (3 - 8C+)
// Bouldering systems
V_SCALE, // V-Scale (VB - V17)
FONT, // Fontainebleau (3 - 9A+)
// Rope
YDS, // Yosemite Decimal System (5.0 - 5.15d)
// Custom system for gyms that use their own colors/naming
CUSTOM
// Custom difficulty systems
CUSTOM;
/**
* Get the display name for the UI
*/
fun getDisplayName(): String = when (this) {
V_SCALE -> "V Scale"
FONT -> "Font Scale"
YDS -> "YDS (Yosemite)"
CUSTOM -> "Custom"
}
/**
* Check if this system is for bouldering
*/
fun isBoulderingSystem(): Boolean = when (this) {
V_SCALE, FONT -> true
YDS -> false
CUSTOM -> true // Custom is available for all
}
/**
* Check if this system is for rope climbing
*/
fun isRopeSystem(): Boolean = when (this) {
YDS -> true
V_SCALE, FONT -> false
CUSTOM -> true
}
/**
* Get available grades for this system
*/
fun getAvailableGrades(): List<String> = when (this) {
V_SCALE -> listOf("VB", "V0", "V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9", "V10", "V11", "V12", "V13", "V14", "V15", "V16", "V17")
FONT -> listOf("3", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6A+", "6B", "6B+", "6C", "6C+", "7A", "7A+", "7B", "7B+", "7C", "7C+", "8A", "8A+", "8B", "8B+", "8C", "8C+")
YDS -> listOf("5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "5.10a", "5.10b", "5.10c", "5.10d", "5.11a", "5.11b", "5.11c", "5.11d", "5.12a", "5.12b", "5.12c", "5.12d", "5.13a", "5.13b", "5.13c", "5.13d", "5.14a", "5.14b", "5.14c", "5.14d", "5.15a", "5.15b", "5.15c", "5.15d")
CUSTOM -> emptyList()
}
companion object {
/**
* Get all difficulty systems based on type
*/
fun getSystemsForClimbType(climbType: ClimbType): List<DifficultySystem> = when (climbType) {
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }
ClimbType.ROPE -> entries.filter { it.isRopeSystem() }
}
}
}
@Serializable
data class DifficultyGrade(
val system: DifficultySystem,
val grade: String,
val numericValue: Int // For comparison and analytics
)
val numericValue: Int
) {
/**
* Compare this grade with another grade of the same system
* Returns negative if this grade is easier, positive if harder, 0 if equal
*/
fun compareTo(other: DifficultyGrade): Int {
if (system != other.system) return 0
return when (system) {
DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade)
DifficultySystem.FONT -> compareFontGrades(grade, other.grade)
DifficultySystem.YDS -> compareYDSGrades(grade, other.grade)
DifficultySystem.CUSTOM -> grade.compareTo(other.grade)
}
}
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
// Handle VB (easiest) specially
if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB" && grade2 == "VB") return 0
// Extract numeric values for V grades
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
return num1.compareTo(num2)
}
private fun compareFontGrades(grade1: String, grade2: String): Int {
// Simple string comparison for Font grades
return grade1.compareTo(grade2)
}
private fun compareYDSGrades(grade1: String, grade2: String): Int {
// Simple string comparison for YDS grades
return grade1.compareTo(grade2)
}
}

View File

@@ -13,10 +13,10 @@ data class Gym(
val name: String,
val location: String? = null,
val supportedClimbTypes: List<ClimbType>,
val difficultySystems: List<DifficultySystem>, // What systems this gym uses
val customDifficultyGrades: List<String> = emptyList(), // For gyms using colors/custom names
val difficultySystems: List<DifficultySystem>,
val customDifficultyGrades: List<String> = emptyList(),
val notes: String? = null,
val createdAt: String, // ISO string format for serialization
val createdAt: String,
val updatedAt: String
) {
companion object {

View File

@@ -28,12 +28,12 @@ data class Problem(
val description: String? = null,
val climbType: ClimbType,
val difficulty: DifficultyGrade,
val setter: String? = null, // Route setter name
val tags: List<String> = emptyList(), // e.g., "overhang", "slab", "crimpy"
val location: String? = null, // Wall section, area in gym
val imagePaths: List<String> = emptyList(), // Local file paths to photos
val isActive: Boolean = true, // Whether the problem is still up
val dateSet: String? = null, // When the problem was set
val setter: String? = null,
val tags: List<String> = emptyList(),
val location: String? = null,
val imagePaths: List<String> = emptyList(),
val isActive: Boolean = true,
val dateSet: String? = null,
val notes: String? = null,
val createdAt: String,
val updatedAt: String

View File

@@ -1,50 +0,0 @@
package com.atridad.openclimb.data.model
import kotlinx.serialization.Serializable
import java.time.LocalDateTime
@Serializable
data class ProblemProgress(
val problemId: String,
val totalAttempts: Int,
val successfulAttempts: Int,
val firstAttemptDate: String,
val lastAttemptDate: String,
val bestResult: AttemptResult,
val averageAttempts: Double,
val successRate: Double,
val personalBest: String? = null, // Highest hold or completion details
val notes: String? = null
)
@Serializable
data class SessionSummary(
val sessionId: String,
val date: String,
val totalAttempts: Int,
val successfulAttempts: Int,
val uniqueProblems: Int,
val avgDifficulty: Double,
val maxDifficulty: DifficultyGrade,
val climbTypes: List<ClimbType>,
val duration: Long?, // in minutes
val notes: String? = null
)
@Serializable
data class ClimbingStats(
val totalSessions: Int,
val totalAttempts: Int,
val totalSuccesses: Int,
val overallSuccessRate: Double,
val uniqueProblemsAttempted: Int,
val uniqueProblemsCompleted: Int,
val averageSessionDuration: Double, // in minutes
val favoriteGym: String?,
val mostAttemptedDifficulty: DifficultyGrade?,
val currentStreak: Int, // consecutive sessions
val longestStreak: Int,
val firstClimbDate: String?,
val lastClimbDate: String?,
val improvementTrend: String? = null // "improving", "stable", "declining"
)

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.os.Environment
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.utils.ImageUtils
import com.atridad.openclimb.utils.ZipExportImportUtils
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
@@ -14,7 +13,7 @@ import java.io.File
import java.time.LocalDateTime
class ClimbRepository(
private val database: OpenClimbDatabase,
database: OpenClimbDatabase,
private val context: Context
) {
private val gymDao = database.gymDao()
@@ -40,7 +39,6 @@ class ClimbRepository(
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
fun getActiveProblems(): Flow<List<Problem>> = problemDao.getActiveProblems()
suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem)
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
@@ -50,17 +48,14 @@ class ClimbRepository(
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = sessionDao.getSessionsByGym(gymId)
fun getRecentSessions(limit: Int = 10): Flow<List<ClimbSession>> = sessionDao.getRecentSessions(limit)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
fun getSessionsByStatus(status: SessionStatus): Flow<List<ClimbSession>> = sessionDao.getSessionsByStatus(status)
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
// Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
suspend fun getAttemptById(id: String): Attempt? = attemptDao.getAttemptById(id)
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
@@ -69,181 +64,148 @@ class ClimbRepository(
// JSON Export functionality
suspend fun exportAllDataToJson(directory: File? = null): File {
val exportDir = directory ?: File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "OpenClimb")
if (!exportDir.exists()) {
exportDir.mkdirs()
}
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
val exportFile = File(exportDir, "openclimb_export_$timestamp.json")
val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first()
val exportData = ClimbDataExport(
exportedAt = LocalDateTime.now().toString(),
gyms = allGyms,
problems = allProblems,
sessions = allSessions,
attempts = allAttempts
)
val jsonString = json.encodeToString(exportData)
exportFile.writeText(jsonString)
return exportFile
}
suspend fun exportAllDataToUri(context: Context, uri: android.net.Uri) {
val gyms = gymDao.getAllGyms().first()
val problems = problemDao.getAllProblems().first()
val sessions = sessionDao.getAllSessions().first()
val attempts = attemptDao.getAllAttempts().first()
val exportData = ClimbDataExport(
exportedAt = LocalDateTime.now().toString(),
gyms = gyms,
problems = problems,
sessions = sessions,
attempts = attempts
)
val jsonString = json.encodeToString(exportData)
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(jsonString.toByteArray())
} ?: throw Exception("Could not open output stream")
}
suspend fun importDataFromJson(file: File) {
try {
val jsonContent = file.readText()
val importData = json.decodeFromString<ClimbDataExport>(jsonContent)
// Import gyms (replace if exists due to primary key constraint)
importData.gyms.forEach { gym ->
try {
gymDao.insertGym(gym)
} catch (e: Exception) {
// If insertion fails due to primary key conflict, update instead
gymDao.updateGym(gym)
}
}
// Import problems
importData.problems.forEach { problem ->
try {
problemDao.insertProblem(problem)
} catch (e: Exception) {
problemDao.updateProblem(problem)
}
}
// Import sessions
importData.sessions.forEach { session ->
try {
sessionDao.insertSession(session)
} catch (e: Exception) {
sessionDao.updateSession(session)
}
}
// Import attempts
importData.attempts.forEach { attempt ->
try {
attemptDao.insertAttempt(attempt)
} catch (e: Exception) {
attemptDao.updateAttempt(attempt)
}
}
} catch (e: Exception) {
throw Exception("Failed to import data: ${e.message}")
}
}
// ZIP Export functionality with images
// ZIP Export with images - Single format for reliability
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
)
try {
// Collect all data with proper error handling
val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first()
// Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val exportData = ClimbDataExport(
exportedAt = LocalDateTime.now().toString(),
version = "1.0",
gyms = allGyms,
problems = allProblems,
sessions = allSessions,
attempts = allAttempts
)
// Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths = referencedImagePaths.filter { imagePath ->
try {
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath)
imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) {
false
}
}.toSet()
// Log any missing images for debugging
val missingImages = referencedImagePaths - validImagePaths
if (missingImages.isNotEmpty()) {
android.util.Log.w("ClimbRepository", "Some referenced images are missing: $missingImages")
}
return ZipExportImportUtils.createExportZip(
context = context,
exportData = exportData,
referencedImagePaths = validImagePaths,
directory = directory
)
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
}
}
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
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
)
try {
// Collect all data with proper error handling
val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first()
// Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val exportData = ClimbDataExport(
exportedAt = LocalDateTime.now().toString(),
version = "1.0",
gyms = allGyms,
problems = allProblems,
sessions = allSessions,
attempts = allAttempts
)
// Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths = referencedImagePaths.filter { imagePath ->
try {
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath)
imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) {
false
}
}.toSet()
ZipExportImportUtils.createExportZipToUri(
context = context,
uri = uri,
exportData = exportData,
referencedImagePaths = validImagePaths
)
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
}
}
suspend fun importDataFromZip(file: File) {
try {
val importResult = ZipExportImportUtils.extractImportZip(context, file)
val importData = json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
// Validate the ZIP file
if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist")
}
// Update problem image paths with the new imported paths
// Extract and validate the ZIP contents
val importResult = ZipExportImportUtils.extractImportZip(context, file)
// Validate JSON content
if (importResult.jsonContent.isBlank()) {
throw Exception("Invalid ZIP file: no data.json found or empty content")
}
// Parse and validate the data structure
val importData = try {
json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
} catch (e: Exception) {
throw Exception("Invalid data format: ${e.message}")
}
// Validate data integrity
validateImportData(importData)
// Clear existing data to avoid conflicts
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
// Import gyms first (problems depend on gyms)
importData.gyms.forEach { gym ->
try {
gymDao.insertGym(gym)
} catch (e: Exception) {
throw Exception("Failed to import gym ${gym.name}: ${e.message}")
}
}
// Import problems with updated image paths
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths(
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)
throw Exception("Failed to import problem ${problem.name}: ${e.message}")
}
}
@@ -252,21 +214,100 @@ class ClimbRepository(
try {
sessionDao.insertSession(session)
} catch (e: Exception) {
sessionDao.updateSession(session)
throw Exception("Failed to import session: ${e.message}")
}
}
// Import attempts
// Import attempts last (depends on problems and sessions)
importData.attempts.forEach { attempt ->
try {
attemptDao.insertAttempt(attempt)
} catch (e: Exception) {
attemptDao.updateAttempt(attempt)
throw Exception("Failed to import attempt: ${e.message}")
}
}
} catch (e: Exception) {
throw Exception("Failed to import data: ${e.message}")
throw Exception("Import failed: ${e.message}")
}
}
private fun validateDataIntegrity(
gyms: List<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
) {
// Validate that all problems reference valid gyms
val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) {
throw Exception("Data integrity error: ${invalidProblems.size} problems reference non-existent gyms")
}
// Validate that all sessions reference valid gyms
val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) {
throw Exception("Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms")
}
// Validate that all attempts reference valid problems and sessions
val problemIds = problems.map { it.id }.toSet()
val sessionIds = sessions.map { it.id }.toSet()
val invalidAttempts = attempts.filter {
it.problemId !in problemIds || it.sessionId !in sessionIds
}
if (invalidAttempts.isNotEmpty()) {
throw Exception("Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions")
}
}
private fun validateImportData(importData: ClimbDataExport) {
if (importData.gyms.isEmpty()) {
throw Exception("Import data is invalid: no gyms found")
}
if (importData.version.isBlank()) {
throw Exception("Import data is invalid: no version information")
}
// Check for reasonable data sizes to prevent malicious imports
if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 ||
importData.sessions.size > 10000 ||
importData.attempts.size > 100000) {
throw Exception("Import data is too large: possible corruption or malicious file")
}
}
suspend fun resetAllData() {
try {
// Clear all data from database
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
// Clear all images from storage
clearAllImages()
} catch (e: Exception) {
throw Exception("Reset failed: ${e.message}")
}
}
private fun clearAllImages() {
try {
// Get the images directory
val imagesDir = File(context.filesDir, "images")
if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0
imagesDir.deleteRecursively()
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files")
}
} catch (e: Exception) {
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}")
}
}
}
@@ -274,6 +315,7 @@ class ClimbRepository(
@kotlinx.serialization.Serializable
data class ClimbDataExport(
val exportedAt: String,
val version: String = "1.0",
val gyms: List<Gym>,
val problems: List<Problem>,
val sessions: List<ClimbSession>,

View File

@@ -6,7 +6,6 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.atridad.openclimb.MainActivity
@@ -16,15 +15,17 @@ 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
import kotlinx.coroutines.runBlocking
class SessionTrackingService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var notificationJob: Job? = null
private var monitoringJob: Job? = null
private lateinit var repository: ClimbRepository
private lateinit var notificationManager: NotificationManager
companion object {
const val NOTIFICATION_ID = 1001
@@ -40,9 +41,10 @@ class SessionTrackingService : Service() {
}
}
fun createStopIntent(context: Context): Intent {
fun createStopIntent(context: Context, sessionId: String): Intent {
return Intent(context, SessionTrackingService::class.java).apply {
action = ACTION_STOP_SESSION
putExtra(EXTRA_SESSION_ID, sessionId)
}
}
}
@@ -52,6 +54,7 @@ class SessionTrackingService : Service() {
val database = OpenClimbDatabase.getDatabase(this)
repository = ClimbRepository(database, this)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel()
}
@@ -65,87 +68,185 @@ class SessionTrackingService : Service() {
}
}
ACTION_STOP_SESSION -> {
stopSessionTracking()
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
serviceScope.launch {
try {
val targetSession = when {
sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession()
}
if (targetSession != null && targetSession.status == com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
val completed = with(com.atridad.openclimb.data.model.ClimbSession) { targetSession.complete() }
repository.updateSession(completed)
}
} finally {
stopSessionTracking()
}
}
}
}
return START_STICKY
return START_REDELIVER_INTENT
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
}
override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) {
notificationJob?.cancel()
monitoringJob?.cancel()
try {
createAndShowNotification(sessionId)
} catch (e: Exception) {
e.printStackTrace()
}
notificationJob = serviceScope.launch {
while (isActive) {
updateNotification(sessionId)
delay(1000)
try {
if (!isNotificationActive()) {
delay(1000L)
createAndShowNotification(sessionId)
}
while (isActive) {
delay(5000L)
updateNotification(sessionId)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
monitoringJob = serviceScope.launch {
try {
while (isActive) {
delay(10000L)
if (!isNotificationActive()) {
updateNotification(sessionId)
}
val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
break
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun stopSessionTracking() {
notificationJob?.cancel()
monitoringJob?.cancel()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun isNotificationActive(): Boolean {
return try {
val activeNotifications = notificationManager.activeNotifications
activeNotifications.any { it.id == NOTIFICATION_ID }
} catch (e: Exception) {
false
}
}
private suspend fun updateNotification(sessionId: String) {
try {
val session = repository.getSessionById(sessionId)
createAndShowNotification(sessionId)
} catch (e: Exception) {
e.printStackTrace()
try {
delay(10000L)
createAndShowNotification(sessionId)
} catch (retryException: Exception) {
retryException.printStackTrace()
stopSessionTracking()
}
}
}
private fun createAndShowNotification(sessionId: String) {
try {
val session = runBlocking {
repository.getSessionById(sessionId)
}
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
return
}
val gym = repository.getGymById(session.gymId)
val attempts = repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
val gym = runBlocking {
repository.getGymById(session.gymId)
}
val attempts = runBlocking {
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
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when {
hours > 0 -> "${hours}h ${remainingMinutes}m"
remainingMinutes > 0 -> "${remainingMinutes}m"
else -> "< 1m"
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s"
}
} catch (e: Exception) {
} catch (_: Exception) {
"Active"
}
} ?: "Active"
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("OpenClimb Session Active")
.setContentTitle("Climbing Session Active")
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts")
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true)
.setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent())
.addAction(
R.drawable.ic_launcher_foreground,
R.drawable.ic_mountains,
"Open Session",
createOpenAppIntent()
)
.addAction(
R.drawable.ic_launcher_foreground,
android.R.drawable.ic_menu_close_clear_cancel,
"End Session",
createStopIntent()
createStopPendingIntent(sessionId)
)
.build()
startForeground(NOTIFICATION_ID, notification)
notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) {
// Handle errors gracefully
stopSessionTracking()
e.printStackTrace()
throw e
}
}
private fun createOpenAppIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
action = "OPEN_SESSION"
}
return PendingIntent.getActivity(
this,
@@ -155,8 +256,8 @@ class SessionTrackingService : Service() {
)
}
private fun createStopIntent(): PendingIntent {
val intent = createStopIntent(this)
private fun createStopPendingIntent(sessionId: String): PendingIntent {
val intent = createStopIntent(this, sessionId)
return PendingIntent.getService(
this,
1,
@@ -166,24 +267,26 @@ class SessionTrackingService : Service() {
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Session Tracking",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows active climbing session information"
setShowBadge(false)
}
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
val channel = NotificationChannel(
CHANNEL_ID,
"Session Tracking",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Shows active climbing session information"
setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
enableLights(false)
enableVibration(false)
setSound(null, null)
}
notificationManager.createNotificationChannel(channel)
}
override fun onDestroy() {
super.onDestroy()
notificationJob?.cancel()
monitoringJob?.cancel()
serviceScope.cancel()
}
}

View File

@@ -1,8 +1,13 @@
package com.atridad.openclimb.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -18,17 +23,18 @@ import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.navigation.Screen
import com.atridad.openclimb.navigation.bottomNavigationItems
import com.atridad.openclimb.ui.components.NotificationPermissionDialog
import com.atridad.openclimb.ui.screens.*
import com.atridad.openclimb.ui.theme.OpenClimbTheme
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
import com.atridad.openclimb.utils.NotificationPermissionUtils
@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) }
@@ -36,6 +42,40 @@ fun OpenClimbApp() {
factory = ClimbViewModelFactory(repository)
)
// Notification permission state
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
// Permission launcher
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
// Handle permission result
if (isGranted) {
// Permission granted, continue
} else {
// Permission denied, show dialog again later
showNotificationPermissionDialog = false
}
}
// Check notification permission on first launch
LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
showNotificationPermissionDialog = true
}
}
}
// Ensure session tracking service is running when app resumes
LaunchedEffect(Unit) {
viewModel.ensureSessionTrackingServiceRunning(context)
}
// FAB configuration
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
@@ -69,13 +109,19 @@ fun OpenClimbApp() {
LaunchedEffect(gyms, activeSession) {
fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
FabConfig(
icon = Icons.Default.Add,
icon = Icons.Default.PlayArrow,
contentDescription = "Start Session",
onClick = {
if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id)
// Check notification permission before starting session
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
showNotificationPermissionDialog = true
} else {
navController.navigate(Screen.AddEditSession())
if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id)
} else {
navController.navigate(Screen.AddEditSession())
}
}
}
)
@@ -87,9 +133,6 @@ fun OpenClimbApp() {
viewModel = viewModel,
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
},
onNavigateToAddSession = { gymId ->
navController.navigate(Screen.AddEditSession(gymId = gymId))
}
)
}
@@ -113,9 +156,6 @@ fun OpenClimbApp() {
viewModel = viewModel,
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
},
onNavigateToAddProblem = { gymId ->
navController.navigate(Screen.AddEditProblem(gymId = gymId))
}
)
}
@@ -141,9 +181,6 @@ fun OpenClimbApp() {
viewModel = viewModel,
onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId))
},
onNavigateToAddGym = {
navController.navigate(Screen.AddEditGym())
}
)
}
@@ -158,18 +195,20 @@ fun OpenClimbApp() {
// Detail screens
composable<Screen.SessionDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.SessionDetail>()
LaunchedEffect(Unit) { fabConfig = null }
SessionDetailScreen(
sessionId = args.sessionId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { sessionId ->
navController.navigate(Screen.AddEditSession(sessionId = sessionId))
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
composable<Screen.ProblemDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
LaunchedEffect(Unit) { fabConfig = null }
ProblemDetailScreen(
problemId = args.problemId,
viewModel = viewModel,
@@ -182,12 +221,19 @@ fun OpenClimbApp() {
composable<Screen.GymDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.GymDetail>()
LaunchedEffect(Unit) { fabConfig = null }
GymDetailScreen(
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = gymId))
},
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
},
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
@@ -195,6 +241,7 @@ fun OpenClimbApp() {
composable<Screen.AddEditGym> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen(
gymId = args.gymId,
viewModel = viewModel,
@@ -204,6 +251,7 @@ fun OpenClimbApp() {
composable<Screen.AddEditProblem> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditProblemScreen(
problemId = args.problemId,
gymId = args.gymId,
@@ -214,6 +262,7 @@ fun OpenClimbApp() {
composable<Screen.AddEditSession> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditSession>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditSessionScreen(
sessionId = args.sessionId,
gymId = args.gymId,
@@ -222,6 +271,16 @@ fun OpenClimbApp() {
)
}
}
// Notification permission dialog
if (showNotificationPermissionDialog) {
NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = {
permissionLauncher.launch(NotificationPermissionUtils.getNotificationPermissionString())
}
)
}
}
}
@@ -247,17 +306,15 @@ fun OpenClimbBottomNavigation(navController: NavHostController) {
selected = isSelected,
onClick = {
navController.navigate(item.screen) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(Screen.Sessions) {
saveState = true
// Clear the entire back stack and go to the selected tab's root screen
popUpTo(0) {
inclusive = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
// Don't restore state - always start fresh when switching tabs
restoreState = false
}
}
)

View File

@@ -3,18 +3,17 @@ 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 com.atridad.openclimb.ui.theme.CustomIcons
import kotlinx.coroutines.delay
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
@@ -26,6 +25,16 @@ fun ActiveSessionBanner(
onEndSession: () -> Unit
) {
if (activeSession != null) {
// Add a timer that updates every second for real-time duration counting
var currentTime by remember { mutableStateOf(LocalDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(1000) // Update every second
currentTime = LocalDateTime.now()
}
}
Card(
modifier = Modifier
.fillMaxWidth()
@@ -69,7 +78,7 @@ fun ActiveSessionBanner(
)
activeSession.startTime?.let { startTime ->
val duration = calculateDuration(startTime)
val duration = calculateDuration(startTime, currentTime)
Text(
text = duration,
style = MaterialTheme.typography.bodySmall,
@@ -86,7 +95,7 @@ fun ActiveSessionBanner(
)
) {
Icon(
Icons.Default.Close,
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
contentDescription = "End session"
)
}
@@ -95,94 +104,20 @@ fun ActiveSessionBanner(
}
}
@Composable
fun StartSessionButton(
gyms: List<Gym>,
onStartSession: (String) -> Unit
) {
var showGymSelection by remember { mutableStateOf(false) }
if (gyms.isEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No gyms available",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Add a gym first to start a session",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
Button(
onClick = { showGymSelection = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Start Session")
}
}
if (showGymSelection) {
AlertDialog(
onDismissRequest = { showGymSelection = false },
title = { Text("Select Gym") },
text = {
Column {
gyms.forEach { gym ->
TextButton(
onClick = {
onStartSession(gym.id)
showGymSelection = false
},
modifier = Modifier.fillMaxWidth()
) {
Text(
text = gym.name,
modifier = Modifier.fillMaxWidth()
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showGymSelection = false }) {
Text("Cancel")
}
}
)
}
}
private fun calculateDuration(startTimeString: String): String {
private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String {
return try {
val startTime = LocalDateTime.parse(startTimeString)
val now = LocalDateTime.now()
val minutes = ChronoUnit.MINUTES.between(startTime, now)
val hours = minutes / 60
val remainingMinutes = minutes % 60
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when {
hours > 0 -> "${hours}h ${remainingMinutes}m"
remainingMinutes > 0 -> "${remainingMinutes}m"
else -> "< 1m"
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s"
}
} catch (e: Exception) {
} catch (_: Exception) {
"Active"
}
}

View File

@@ -50,7 +50,7 @@ fun FullscreenImageViewer(
LaunchedEffect(pagerState.currentPage) {
thumbnailListState.animateScrollToItem(
index = pagerState.currentPage,
scrollOffset = -200 // Center the item
scrollOffset = -200
)
}

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale

View File

@@ -1,6 +1,5 @@
package com.atridad.openclimb.ui.components
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
@@ -20,7 +19,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils
import java.io.File
@Composable
fun ImagePicker(
@@ -41,7 +39,7 @@ fun ImagePicker(
val remainingSlots = maxImages - currentCount
val urisToProcess = uris.take(remainingSlots)
// Process each selected image
// Process images
val newImagePaths = mutableListOf<String>()
urisToProcess.forEach { uri ->
val imagePath = ImageUtils.saveImageFromUri(context, uri)

View File

@@ -0,0 +1,302 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Data point for the line chart
*/
data class ChartDataPoint(
val x: Float,
val y: Float,
val label: String? = null
)
/**
* Configuration for chart styling
*/
data class ChartStyle(
val lineColor: Color,
val fillColor: Color,
val lineWidth: Float = 3f,
val gridColor: Color,
val textColor: Color,
val backgroundColor: Color
)
/**
* Custom Line Chart with area fill below the line
*/
@Composable
fun LineChart(
data: List<ChartDataPoint>,
modifier: Modifier = Modifier,
style: ChartStyle = ChartStyle(
lineColor = MaterialTheme.colorScheme.primary,
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
backgroundColor = MaterialTheme.colorScheme.surface
),
showGrid: Boolean = true,
xAxisFormatter: (Float) -> String = { it.toString() },
yAxisFormatter: (Float) -> String = { it.toString() }
) {
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
Box(modifier = modifier) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
if (data.isEmpty()) return@Canvas
val padding = with(density) { 32.dp.toPx() }
val chartWidth = size.width - padding * 2
val chartHeight = size.height - padding * 2
// Calculate data bounds
val dataMinY = data.minOf { it.y }
val dataMaxY = data.maxOf { it.y }
// Add some padding to Y-axis (10% above and below the data range)
val yPadding = if (dataMaxY == dataMinY) 1f else (dataMaxY - dataMinY) * 0.1f
val minY = dataMinY - yPadding
val maxY = dataMaxY + yPadding
val minX = data.minOf { it.x }
val maxX = data.maxOf { it.x }
val xRange = if (maxX - minX == 0f) 1f else maxX - minX // Minimum range of 1 for single points
val yRange = maxY - minY
// Ensure we have valid ranges
if (yRange == 0f) return@Canvas
// Convert data points to screen coordinates
val screenPoints = data.map { point ->
val x = padding + (point.x - minX) / xRange * chartWidth
val y = padding + chartHeight - (point.y - minY) / yRange * chartHeight
Offset(x, y)
}
// Draw background
drawRect(
color = style.backgroundColor,
topLeft = Offset(padding, padding),
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight)
)
// Draw grid
if (showGrid) {
drawGrid(
padding = padding,
chartWidth = chartWidth,
chartHeight = chartHeight,
gridColor = style.gridColor,
minX = minX,
maxX = maxX,
minY = minY,
maxY = maxY,
textMeasurer = textMeasurer,
textColor = style.textColor,
xAxisFormatter = xAxisFormatter,
yAxisFormatter = yAxisFormatter,
actualDataPoints = data
)
}
// Draw area fill
if (screenPoints.size > 1) {
drawAreaFill(
points = screenPoints,
padding = padding,
chartHeight = chartHeight,
fillColor = style.fillColor
)
}
// Draw line
if (screenPoints.size > 1) {
drawLine(
points = screenPoints,
lineColor = style.lineColor,
lineWidth = style.lineWidth
)
}
// Draw data points - more pronounced
screenPoints.forEach { point ->
// Draw outer circle (larger)
drawCircle(
color = style.lineColor,
radius = 8f,
center = point
)
// Draw inner circle (white center)
drawCircle(
color = style.backgroundColor,
radius = 5f,
center = point
)
// Draw border for better visibility
drawCircle(
color = style.lineColor,
radius = 8f,
center = point,
style = Stroke(width = 2f)
)
}
}
}
}
private fun DrawScope.drawGrid(
padding: Float,
chartWidth: Float,
chartHeight: Float,
gridColor: Color,
minX: Float,
maxX: Float,
minY: Float,
maxY: Float,
textMeasurer: TextMeasurer,
textColor: Color,
xAxisFormatter: (Float) -> String,
yAxisFormatter: (Float) -> String,
actualDataPoints: List<ChartDataPoint>
) {
val textStyle = TextStyle(
color = textColor,
fontSize = 10.sp
)
// Draw vertical grid lines (X-axis) - only at integer values for sessions
val xRange = maxX - minX
if (xRange > 0) {
val startX = kotlin.math.ceil(minX).toInt()
val endX = kotlin.math.floor(maxX).toInt()
for (sessionNum in startX..endX) {
val x = padding + (sessionNum.toFloat() - minX) / xRange * chartWidth
// Draw grid line
drawLine(
color = gridColor,
start = Offset(x, padding),
end = Offset(x, padding + chartHeight),
strokeWidth = 1.dp.toPx()
)
// X-axis labels removed per user request
}
}
// Draw horizontal grid lines (Y-axis) - only at actual data point values
val yRange = maxY - minY
if (yRange > 0) {
// Get unique Y values from actual data points
val actualYValues = actualDataPoints.map { kotlin.math.round(it.y).toInt() }.toSet()
actualYValues.forEach { gradeValue ->
val y = padding + chartHeight - (gradeValue.toFloat() - minY) / yRange * chartHeight
// Only draw if within chart bounds
if (y >= padding && y <= padding + chartHeight) {
// Draw grid line
drawLine(
color = gridColor,
start = Offset(padding, y),
end = Offset(padding + chartWidth, y),
strokeWidth = 1.dp.toPx()
)
// Draw label
val text = yAxisFormatter(gradeValue.toFloat())
val textSize = textMeasurer.measure(text, textStyle)
drawText(
textMeasurer = textMeasurer,
text = text,
style = textStyle,
topLeft = Offset(
padding - textSize.size.width - 8.dp.toPx(),
y - textSize.size.height / 2f
)
)
}
}
}
}
private fun DrawScope.drawAreaFill(
points: List<Offset>,
padding: Float,
chartHeight: Float,
fillColor: Color
) {
val bottomY = padding + chartHeight // This represents the bottom of the chart area
val path = Path().apply {
// Start from bottom-left (at chart bottom level)
moveTo(points.first().x, bottomY)
// Draw to first point
lineTo(points.first().x, points.first().y)
// Draw line through all points
for (i in 1 until points.size) {
lineTo(points[i].x, points[i].y)
}
// Close the path by going to bottom-right (at chart bottom level) and back to start
lineTo(points.last().x, bottomY)
lineTo(points.first().x, bottomY)
close()
}
drawPath(
path = path,
color = fillColor
)
}
private fun DrawScope.drawLine(
points: List<Offset>,
lineColor: Color,
lineWidth: Float
) {
val path = Path().apply {
moveTo(points.first().x, points.first().y)
for (i in 1 until points.size) {
lineTo(points[i].x, points[i].y)
}
}
drawPath(
path = path,
color = lineColor,
style = Stroke(
width = lineWidth,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
}

View File

@@ -0,0 +1,89 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun NotificationPermissionDialog(
onDismiss: () -> Unit,
onRequestPermission: () -> Unit
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = MaterialTheme.shapes.medium
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = "Notifications",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Enable Notifications",
style = MaterialTheme.typography.headlineSmall,
fontWeight = MaterialTheme.typography.headlineSmall.fontWeight,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "OpenClimb needs notification permission to show your active climbing session. This helps you track your progress and ensures the session doesn't get interrupted.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text("Not Now")
}
Button(
onClick = {
onRequestPermission()
onDismiss()
},
modifier = Modifier.weight(1f)
) {
Text("Enable")
}
}
}
}
}
}

View File

@@ -5,10 +5,9 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -17,21 +16,13 @@ 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 androidx.compose.ui.platform.LocalContext
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.ui.components.ImagePicker
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import kotlinx.coroutines.flow.first
import java.time.LocalDateTime
// Data class for attempt input
data class AttemptInput(
val problemId: String,
val result: AttemptResult,
val highestHold: String = "",
val notes: String = ""
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddEditGymScreen(
@@ -47,32 +38,56 @@ fun AddEditGymScreen(
val isEditing = gymId != null
// Calculate available difficulty systems based on selected climb types
val availableDifficultySystems = if (selectedClimbTypes.isEmpty()) {
emptyList()
} else {
selectedClimbTypes.flatMap { climbType ->
DifficultySystem.getSystemsForClimbType(climbType)
}.distinct()
}
// Reset selected difficulty systems when available systems change
LaunchedEffect(availableDifficultySystems) {
selectedDifficultySystems = selectedDifficultySystems.filter { it in availableDifficultySystems }.toSet()
}
// Load existing gym data for editing
LaunchedEffect(gymId) {
if (gymId != null) {
val existingGym = viewModel.getGymById(gymId).first()
existingGym?.let { gym ->
name = gym.name
location = gym.location ?: ""
notes = gym.notes ?: ""
selectedClimbTypes = gym.supportedClimbTypes.toSet()
selectedDifficultySystems = gym.difficultySystems.toSet()
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isEditing) "Edit Gym" else "Add Gym") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(
onClick = {
val gym = if (isEditing) {
Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
} else {
Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
}
val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
if (isEditing) {
viewModel.updateGym(gym)
viewModel.updateGym(gym.copy(id = gymId!!))
} else {
viewModel.addGym(gym)
}
onNavigateBack()
},
enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty()
enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty() && selectedDifficultySystems.isNotEmpty()
) {
Text("Save")
}
@@ -142,7 +157,7 @@ fun AddEditGymScreen(
onCheckedChange = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() })
Text(climbType.getDisplayName())
}
}
}
@@ -163,29 +178,38 @@ fun AddEditGymScreen(
Spacer(modifier = Modifier.height(8.dp))
DifficultySystem.entries.forEach { system ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = system in selectedDifficultySystems,
onClick = {
selectedDifficultySystems = if (system in selectedDifficultySystems) {
selectedDifficultySystems - system
} else {
selectedDifficultySystems + system
}
},
role = Role.Checkbox
if (selectedClimbTypes.isEmpty()) {
Text(
text = "Select climb types first to see available difficulty systems",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
availableDifficultySystems.forEach { system ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = system in selectedDifficultySystems,
onClick = {
selectedDifficultySystems = if (system in selectedDifficultySystems) {
selectedDifficultySystems - system
} else {
selectedDifficultySystems + system
}
},
role = Role.Checkbox
)
) {
Checkbox(
checked = system in selectedDifficultySystems,
onCheckedChange = null
)
) {
Checkbox(
checked = system in selectedDifficultySystems,
onCheckedChange = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(system.name)
Spacer(modifier = Modifier.width(8.dp))
Text(system.getDisplayName())
}
}
}
}
@@ -244,6 +268,7 @@ fun AddEditProblemScreen(
notes = p.notes ?: ""
isActive = p.isActive
imagePaths = p.imagePaths
selectedGym = gyms.find { it.id == p.gymId }
}
}
}
@@ -254,8 +279,39 @@ fun AddEditProblemScreen(
}
}
val availableDifficultySystems = selectedGym?.difficultySystems ?: DifficultySystem.entries.toList()
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
val availableDifficultySystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
selectedGym?.difficultySystems?.contains(system) != false
}
// Auto-select climb type if there's only one available
LaunchedEffect(availableClimbTypes) {
if (availableClimbTypes.size == 1 && selectedClimbType != availableClimbTypes.first()) {
selectedClimbType = availableClimbTypes.first()
}
}
// Auto-select or reset difficulty system based on climb type
LaunchedEffect(selectedClimbType, availableDifficultySystems) {
when {
// If current system is not compatible, select the first available one
selectedDifficultySystem !in availableDifficultySystems -> {
selectedDifficultySystem = availableDifficultySystems.firstOrNull() ?: DifficultySystem.CUSTOM
}
// If there's only one available system and nothing is selected, auto-select it
availableDifficultySystems.size == 1 && selectedDifficultySystem != availableDifficultySystems.first() -> {
selectedDifficultySystem = availableDifficultySystems.first()
}
}
}
// Reset grade when difficulty system changes (unless it's a valid grade for the new system)
LaunchedEffect(selectedDifficultySystem) {
val availableGrades = selectedDifficultySystem.getAvailableGrades()
if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) {
difficultyGrade = ""
}
}
Scaffold(
topBar = {
@@ -263,7 +319,7 @@ fun AddEditProblemScreen(
title = { Text(if (isEditing) "Edit Problem" else "Add Problem") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
@@ -437,7 +493,7 @@ fun AddEditProblemScreen(
availableClimbTypes.forEach { climbType ->
FilterChip(
onClick = { selectedClimbType = climbType },
label = { Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) },
label = { Text(climbType.getDisplayName()) },
selected = selectedClimbType == climbType
)
}
@@ -476,7 +532,7 @@ fun AddEditProblemScreen(
items(availableDifficultySystems) { system ->
FilterChip(
onClick = { selectedDifficultySystem = system },
label = { Text(system.name) },
label = { Text(system.getDisplayName()) },
selected = selectedDifficultySystem == system
)
}
@@ -484,23 +540,58 @@ fun AddEditProblemScreen(
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = difficultyGrade,
onValueChange = { difficultyGrade = it },
label = { Text("Grade *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = {
Text(when (selectedDifficultySystem) {
DifficultySystem.V_SCALE -> "e.g., V0, V4, V10"
DifficultySystem.FONT -> "e.g., 3, 6A+, 8B"
DifficultySystem.YDS -> "e.g., 5.8, 5.12a"
DifficultySystem.FRENCH -> "e.g., 6a, 7c+"
DifficultySystem.CUSTOM -> "Custom grade"
else -> "Enter grade"
})
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
OutlinedTextField(
value = difficultyGrade,
onValueChange = { newValue ->
// Only allow integers for custom scales
if (newValue.isEmpty() || newValue.all { it.isDigit() }) {
difficultyGrade = newValue
}
},
label = { Text("Grade *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("Enter numeric grade (e.g. 5, 10, 15)") },
supportingText = { Text("Custom grades must be whole numbers") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
} else {
var expanded by remember { mutableStateOf(false) }
val availableGrades = selectedDifficultySystem.getAvailableGrades()
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = difficultyGrade,
onValueChange = { },
readOnly = true,
label = { Text("Grade *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, enabled = true)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
availableGrades.forEach { grade ->
DropdownMenuItem(
text = { Text(grade) },
onClick = {
difficultyGrade = grade
expanded = false
}
)
}
}
}
)
}
}
}
}
@@ -552,7 +643,7 @@ fun AddEditProblemScreen(
label = { Text("Tags (Optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("e.g., overhang, crimpy, dynamic (comma-separated)") }
placeholder = { Text("e.g., crimpy, dynamic (comma-separated)") }
)
Spacer(modifier = Modifier.height(8.dp))
@@ -605,17 +696,26 @@ fun AddEditSessionScreen(
) {
val isEditing = sessionId != null
val gyms by viewModel.gyms.collectAsState()
val problems by viewModel.problems.collectAsState()
val context = LocalContext.current
// Session form state
var selectedGym by remember { mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } }) }
var sessionDate by remember { mutableStateOf(LocalDateTime.now().toLocalDate().toString()) }
var duration by remember { mutableStateOf("") }
var sessionNotes by remember { mutableStateOf("") }
// Attempt tracking state
var attempts by remember { mutableStateOf(listOf<AttemptInput>()) }
var showAddAttemptDialog by remember { mutableStateOf(false) }
// Load existing session data for editing
LaunchedEffect(sessionId) {
if (sessionId != null) {
val existingSession = viewModel.getSessionById(sessionId).first()
existingSession?.let { session ->
selectedGym = gyms.find { it.id == session.gymId }
sessionDate = session.date.split("T")[0] // Extract date part
duration = session.duration?.toString() ?: ""
sessionNotes = session.notes ?: ""
}
}
}
LaunchedEffect(gymId, gyms) {
if (gymId != null && selectedGym == null) {
@@ -629,33 +729,21 @@ fun AddEditSessionScreen(
title = { Text(if (isEditing) "Edit Session" else "Add Session") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(
onClick = {
selectedGym?.let { gym ->
val session = ClimbSession.create(
gymId = gym.id,
notes = sessionNotes.ifBlank { null }
)
if (isEditing) {
val session = ClimbSession.create(
gymId = gym.id,
notes = sessionNotes.ifBlank { null }
)
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)
}
viewModel.startSession(context, gym.id, sessionNotes.ifBlank { null })
}
onNavigateBack()
}
@@ -666,15 +754,6 @@ fun AddEditSessionScreen(
}
}
)
},
floatingActionButton = {
if (selectedGym != null) {
FloatingActionButton(
onClick = { showAddAttemptDialog = true }
) {
Icon(Icons.Default.Add, contentDescription = "Add Attempt")
}
}
}
) { paddingValues ->
LazyColumn(
@@ -770,286 +849,9 @@ fun AddEditSessionScreen(
}
}
}
// Attempts Section
item {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Attempts (${attempts.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
if (attempts.isEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No attempts recorded yet. Add an attempt to track your progress.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Attempts List
items(attempts.size) { index ->
val attempt = attempts[index]
val problem = problems.find { it.id == attempt.problemId }
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = problem?.name ?: "Unknown Problem",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
problem?.difficulty?.let { difficulty ->
Text(
text = "${difficulty.system.name}: ${difficulty.grade}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = "Result: ${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }}",
style = MaterialTheme.typography.bodyMedium,
color = when (attempt.result) {
AttemptResult.SUCCESS, AttemptResult.FLASH,
AttemptResult.REDPOINT, AttemptResult.ONSIGHT -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
if (attempt.highestHold.isNotBlank()) {
Text(
text = "Highest hold: ${attempt.highestHold}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (attempt.notes.isNotBlank()) {
Text(
text = attempt.notes,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(
onClick = {
attempts = attempts.toMutableList().apply { removeAt(index) }
}
) {
Icon(Icons.Default.Delete, contentDescription = "Remove attempt")
}
}
}
}
}
}
}
if (showAddAttemptDialog && selectedGym != null) {
AddAttemptDialog(
problems = problems.filter { it.gymId == selectedGym!!.id && it.isActive },
onDismiss = { showAddAttemptDialog = false },
onAddAttempt = { attemptInput ->
attempts = attempts + attemptInput
showAddAttemptDialog = false
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddAttemptDialog(
problems: List<Problem>,
onDismiss: () -> Unit,
onAddAttempt: (AttemptInput) -> Unit
) {
var selectedProblem by remember { mutableStateOf<Problem?>(null) }
var selectedResult by remember { mutableStateOf(AttemptResult.FALL) }
var highestHold by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Add Attempt",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
// Problem Selection
Text(
text = "Problem",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
if (problems.isEmpty()) {
Text(
text = "No active problems in this gym. Add some problems first.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
} else {
LazyColumn(
modifier = Modifier.height(120.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(problems) { problem ->
Card(
onClick = { selectedProblem = problem },
colors = CardDefaults.cardColors(
containerColor = if (selectedProblem?.id == problem.id)
MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface
),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(12.dp)
) {
Text(
text = problem.name ?: "Unnamed Problem",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
// Result Selection
Text(
text = "Result",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Column(modifier = Modifier.selectableGroup()) {
AttemptResult.entries.forEach { result ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selectedResult == result,
onClick = { selectedResult = result },
role = Role.RadioButton
)
) {
RadioButton(
selected = selectedResult == result,
onClick = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = result.name.lowercase().replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyMedium
)
}
}
}
// Highest Hold
OutlinedTextField(
value = highestHold,
onValueChange = { highestHold = it },
label = { Text("Highest Hold (Optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("e.g., 'jugs near the top', 'crux move'") }
)
// Notes
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes (Optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
placeholder = { Text("e.g., 'need to work on heel hooks', 'pumped out'") }
)
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text("Cancel")
}
Button(
onClick = {
selectedProblem?.let { problem ->
onAddAttempt(
AttemptInput(
problemId = problem.id,
result = selectedResult,
highestHold = highestHold,
notes = notes
)
)
}
},
enabled = selectedProblem != null,
modifier = Modifier.weight(1f)
) {
Text("Add Attempt")
}
}
}
}
}
}

View File

@@ -6,9 +6,15 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.DifficultySystem
import com.atridad.openclimb.ui.components.ChartDataPoint
import com.atridad.openclimb.ui.components.LineChart
@Composable
fun AnalyticsScreen(
@@ -26,11 +32,23 @@ fun AnalyticsScreen(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text(
text = "Analytics",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Analytics",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
}
// Overall Stats
@@ -43,20 +61,10 @@ fun AnalyticsScreen(
)
}
// Success Rate
// Progress Chart
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
)
val progressData = calculateProgressOverTime(sessions, problems, attempts)
ProgressChartCard(progressData = progressData, problems = problems)
}
// Favorite Gym
@@ -79,41 +87,6 @@ fun AnalyticsScreen(
val recentSessions = sessions.take(5)
RecentActivityCard(recentSessions = recentSessions.size)
}
item {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Progress Charts",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Detailed charts and analytics coming soon!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "📊",
style = MaterialTheme.typography.displaySmall
)
}
}
}
}
}
@@ -153,14 +126,24 @@ fun OverallStatsCard(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SuccessRateCard(
successRate: Int,
successfulAttempts: Int,
totalAttempts: Int
fun ProgressChartCard(
progressData: List<ProgressDataPoint>,
problems: List<com.atridad.openclimb.data.model.Problem>,
) {
// Find all grading systems that have been used
val usedSystems = remember(problems) {
problems.map { it.difficulty.system }.distinct().filter { system ->
problems.any { it.difficulty.system == system }
}
}
var selectedSystem by remember {
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
}
var expanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth()
) {
@@ -169,38 +152,120 @@ fun SuccessRateCard(
.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,
text = "Progress Over Time",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
modifier = Modifier.weight(1f)
)
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
)
// Scale selector dropdown
if (usedSystems.size > 1) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = when (selectedSystem) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
},
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true)
.width(120.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
usedSystems.forEach { system ->
DropdownMenuItem(
text = {
Text(when (system) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
})
},
onClick = {
selectedSystem = system
expanded = false
}
)
}
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Filter progress data by selected scale
val filteredProgressData = remember(progressData, selectedSystem) {
progressData.filter { it.difficultySystem == selectedSystem }
}
if (filteredProgressData.isNotEmpty()) {
val chartData = remember(filteredProgressData) {
// Convert progress data to chart data points ordered by session
filteredProgressData
.sortedBy { it.date }
.mapIndexed { index, p ->
ChartDataPoint(
x = (index + 1).toFloat(),
y = p.maxGradeNumeric.toFloat(),
label = "Session ${index + 1}"
)
}
}
LineChart(
data = chartData,
modifier = Modifier.fillMaxWidth().height(220.dp),
xAxisFormatter = { value ->
"S${value.toInt()}" // S1, S2, S3, etc.
},
yAxisFormatter = { value ->
numericToGrade(selectedSystem, value.toInt())
}
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "X: session number, Y: max ${when(selectedSystem) {
DifficultySystem.V_SCALE -> "V-grade"
DifficultySystem.FONT -> "Font grade"
DifficultySystem.YDS -> "YDS grade"
DifficultySystem.CUSTOM -> "custom grade"
}} achieved",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "No progress data available for ${when(selectedSystem) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
}} system",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -274,3 +339,207 @@ fun RecentActivityCard(
}
}
}
data class ProgressDataPoint(
val date: String,
val maxGrade: String,
val maxGradeNumeric: Int,
val climbType: ClimbType,
val difficultySystem: DifficultySystem
)
fun calculateProgressOverTime(
sessions: List<com.atridad.openclimb.data.model.ClimbSession>,
problems: List<com.atridad.openclimb.data.model.Problem>,
attempts: List<com.atridad.openclimb.data.model.Attempt>
): List<ProgressDataPoint> {
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
return emptyList()
}
val sessionProgress = sessions.mapNotNull { session ->
val sessionAttempts = attempts.filter { it.sessionId == session.id }
if (sessionAttempts.isEmpty()) return@mapNotNull null
val attemptedProblemIds = sessionAttempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in attemptedProblemIds }
if (attemptedProblems.isEmpty()) return@mapNotNull null
val highestGradeProblem = attemptedProblems.maxByOrNull { problem ->
gradeToNumeric(problem.difficulty.system, problem.difficulty.grade)
}
if (highestGradeProblem != null) {
ProgressDataPoint(
date = session.date,
maxGrade = highestGradeProblem.difficulty.grade,
maxGradeNumeric = gradeToNumeric(highestGradeProblem.difficulty.system, highestGradeProblem.difficulty.grade),
climbType = highestGradeProblem.climbType,
difficultySystem = highestGradeProblem.difficulty.system
)
} else null
}
return sessionProgress.sortedBy { it.date }
}
fun gradeToNumeric(system: DifficultySystem, grade: String): Int {
return when (system) {
DifficultySystem.V_SCALE -> {
when (grade) {
"VB" -> 0
else -> grade.removePrefix("V").toIntOrNull() ?: 0
}
}
DifficultySystem.FONT -> {
when (grade) {
"3" -> 3
"4A" -> 4
"4B" -> 5
"4C" -> 6
"5A" -> 7
"5B" -> 8
"5C" -> 9
"6A" -> 10
"6A+" -> 11
"6B" -> 12
"6B+" -> 13
"6C" -> 14
"6C+" -> 15
"7A" -> 16
"7A+" -> 17
"7B" -> 18
"7B+" -> 19
"7C" -> 20
"7C+" -> 21
"8A" -> 22
"8A+" -> 23
"8B" -> 24
"8B+" -> 25
"8C" -> 26
"8C+" -> 27
else -> 0
}
}
DifficultySystem.YDS -> {
when (grade) {
"5.0" -> 50
"5.1" -> 51
"5.2" -> 52
"5.3" -> 53
"5.4" -> 54
"5.5" -> 55
"5.6" -> 56
"5.7" -> 57
"5.8" -> 58
"5.9" -> 59
"5.10a" -> 60
"5.10b" -> 61
"5.10c" -> 62
"5.10d" -> 63
"5.11a" -> 64
"5.11b" -> 65
"5.11c" -> 66
"5.11d" -> 67
"5.12a" -> 68
"5.12b" -> 69
"5.12c" -> 70
"5.12d" -> 71
"5.13a" -> 72
"5.13b" -> 73
"5.13c" -> 74
"5.13d" -> 75
"5.14a" -> 76
"5.14b" -> 77
"5.14c" -> 78
"5.14d" -> 79
"5.15a" -> 80
"5.15b" -> 81
"5.15c" -> 82
"5.15d" -> 83
else -> 0
}
}
DifficultySystem.CUSTOM -> {
// Custom grades are numeric strings, so parse them directly
grade.toIntOrNull() ?: 0
}
}
}
fun numericToGrade(system: DifficultySystem, numeric: Int): String {
return when (system) {
DifficultySystem.V_SCALE -> {
when (numeric) {
0 -> "VB"
else -> "V$numeric"
}
}
DifficultySystem.FONT -> {
when (numeric) {
3 -> "3"
4 -> "4A"
5 -> "4B"
6 -> "4C"
7 -> "5A"
8 -> "5B"
9 -> "5C"
10 -> "6A"
11 -> "6A+"
12 -> "6B"
13 -> "6B+"
14 -> "6C"
15 -> "6C+"
16 -> "7A"
17 -> "7A+"
18 -> "7B"
19 -> "7B+"
20 -> "7C"
21 -> "7C+"
22 -> "8A"
23 -> "8A+"
24 -> "8B"
25 -> "8B+"
26 -> "8C"
27 -> "8C+"
else -> numeric.toString()
}
}
DifficultySystem.YDS -> {
when (numeric) {
50 -> "5.0"
51 -> "5.1"
52 -> "5.2"
53 -> "5.3"
54 -> "5.4"
55 -> "5.5"
56 -> "5.6"
57 -> "5.7"
58 -> "5.8"
59 -> "5.9"
60 -> "5.10a"
61 -> "5.10b"
62 -> "5.10c"
63 -> "5.10d"
64 -> "5.11a"
65 -> "5.11b"
66 -> "5.11c"
67 -> "5.11d"
68 -> "5.12a"
69 -> "5.12b"
70 -> "5.12c"
71 -> "5.12d"
72 -> "5.13a"
73 -> "5.13b"
74 -> "5.13c"
75 -> "5.13d"
76 -> "5.14a"
77 -> "5.14b"
78 -> "5.14c"
79 -> "5.14d"
80 -> "5.15a"
81 -> "5.15b"
82 -> "5.15c"
83 -> "5.15d"
else -> numeric.toString()
}
}
DifficultySystem.CUSTOM -> numeric.toString()
}
}

View File

@@ -3,15 +3,14 @@ package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@@ -19,8 +18,7 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@Composable
fun GymsScreen(
viewModel: ClimbViewModel,
onNavigateToGymDetail: (String) -> Unit,
onNavigateToAddGym: () -> Unit
onNavigateToGymDetail: (String) -> Unit
) {
val gyms by viewModel.gyms.collectAsState()
@@ -29,11 +27,23 @@ fun GymsScreen(
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = "Climbing Gyms",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Climbing Gyms",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(16.dp))
@@ -95,7 +105,7 @@ fun GymCard(
AssistChip(
onClick = { },
label = {
Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() })
Text(climbType.getDisplayName())
},
modifier = Modifier.padding(end = 4.dp)
)
@@ -105,7 +115,7 @@ fun GymCard(
if (gym.difficultySystems.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.name }}",
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -2,16 +2,18 @@ package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.data.model.Problem
import com.atridad.openclimb.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplay
@@ -21,38 +23,157 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@Composable
fun ProblemsScreen(
viewModel: ClimbViewModel,
onNavigateToProblemDetail: (String) -> Unit,
onNavigateToAddProblem: (String?) -> Unit
onNavigateToProblemDetail: (String) -> Unit
) {
val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState()
var showImageViewer by remember { mutableStateOf(false) }
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedImageIndex by remember { mutableStateOf(0) }
var selectedImageIndex by remember { mutableIntStateOf(0) }
// Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
var selectedGym by remember { mutableStateOf<Gym?>(null) }
// Apply filters
val filteredProblems = problems.filter { problem ->
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
climbTypeMatch && gymMatch
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = "Problems & Routes",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Problems & Routes",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(16.dp))
if (problems.isEmpty()) {
// Filters Section
if (problems.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Filters",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
// Climb Type Filter
Text(
text = "Climb Type",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
FilterChip(
onClick = { selectedClimbType = null },
label = { Text("All Types") },
selected = selectedClimbType == null
)
}
items(ClimbType.entries) { climbType ->
FilterChip(
onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) },
selected = selectedClimbType == climbType
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Gym Filter
Text(
text = "Gym",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
FilterChip(
onClick = { selectedGym = null },
label = { Text("All Gyms") },
selected = selectedGym == null
)
}
items(gyms) { gym ->
FilterChip(
onClick = { selectedGym = gym },
label = { Text(gym.name) },
selected = selectedGym?.id == gym.id
)
}
}
// Filter result count
if (selectedClimbType != null || selectedGym != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Showing ${filteredProblems.size} of ${problems.size} problems",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
if (filteredProblems.isEmpty()) {
EmptyStateMessage(
title = if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet",
message = if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!",
title = if (problems.isEmpty()) {
if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet"
} else {
"No Problems Match Filters"
},
message = if (problems.isEmpty()) {
if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!"
} else {
"Try adjusting your filters to see more problems."
},
onActionClick = { },
actionText = ""
)
} else {
LazyColumn {
items(problems) { problem ->
items(filteredProblems) { problem ->
ProblemCard(
problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
@@ -124,7 +245,7 @@ fun ProblemCard(
)
Text(
text = problem.climbType.name.lowercase().replaceFirstChar { it.uppercase() },
text = problem.climbType.getDisplayName(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -3,16 +3,20 @@ package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.SessionStatus
import com.atridad.openclimb.ui.components.ActiveSessionBanner
@@ -24,13 +28,13 @@ import java.time.format.DateTimeFormatter
@Composable
fun SessionsScreen(
viewModel: ClimbViewModel,
onNavigateToSessionDetail: (String) -> Unit,
onNavigateToAddSession: (String?) -> Unit
onNavigateToSessionDetail: (String) -> Unit
) {
val context = LocalContext.current
val sessions by viewModel.sessions.collectAsState()
val gyms by viewModel.gyms.collectAsState()
val activeSession by viewModel.activeSession.collectAsState()
val uiState by viewModel.uiState.collectAsState()
// Filter out active sessions from regular session list
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
@@ -45,16 +49,20 @@ fun SessionsScreen(
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Climbing Sessions",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(16.dp))
@@ -97,6 +105,79 @@ fun SessionsScreen(
}
}
}
// Show UI state messages and errors
uiState.message?.let { message ->
LaunchedEffect(message) {
kotlinx.coroutines.delay(5000)
viewModel.clearMessage()
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
uiState.error?.let { error ->
LaunchedEffect(error) {
kotlinx.coroutines.delay(5000)
viewModel.clearError()
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -135,7 +216,7 @@ fun SessionCard(
session.duration?.let { duration ->
Text(
text = "Duration: ${duration} minutes",
text = "Duration: $duration minutes",
style = MaterialTheme.typography.bodyMedium
)
}
@@ -197,7 +278,7 @@ private fun formatDate(dateString: String): String {
return try {
val date = LocalDateTime.parse(dateString.split("T")[0] + "T00:00:00")
date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
} catch (e: Exception) {
} catch (_: Exception) {
dateString
}
}

View File

@@ -2,7 +2,6 @@ package com.atridad.openclimb.ui.screens
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import android.os.Environment
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -13,8 +12,10 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.io.File
@@ -25,12 +26,16 @@ fun SettingsScreen(
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
// State for reset confirmation dialog
var showResetDialog by remember { mutableStateOf(false) }
val packageInfo = remember {
context.packageManager.getPackageInfo(context.packageName, 0)
}
val appVersion = packageInfo.versionName
// File picker launcher for import - accepts both ZIP and JSON files
// File picker launcher for import - only accepts ZIP files
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
@@ -45,9 +50,13 @@ fun SettingsScreen(
} 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)
// Only allow ZIP files
if (!fileName.lowercase().endsWith(".zip")) {
viewModel.setError("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.")
return@let
}
val tempFile = File(context.cacheDir, "temp_import.zip")
inputStream?.use { input ->
tempFile.outputStream().use { output ->
@@ -61,7 +70,7 @@ fun SettingsScreen(
}
}
// File picker launcher for export - save location (ZIP format with images)
// File picker launcher for export - ZIP format with images
val exportZipLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/zip")
) { uri ->
@@ -74,19 +83,6 @@ fun SettingsScreen(
}
}
// JSON export launcher
val exportJsonLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let {
try {
viewModel.exportDataToUri(context, uri)
} catch (e: Exception) {
viewModel.setError("Failed to save file: ${e.message}")
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
@@ -94,11 +90,23 @@ fun SettingsScreen(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text(
text = "Settings",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Settings",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
}
// Data Management Section
@@ -166,19 +174,13 @@ fun SettingsScreen(
)
) {
ListItem(
headlineContent = { Text("Export Data Only") },
supportingContent = { Text("Export climbing data to JSON without images") },
leadingContent = { Icon(Icons.Default.Share, contentDescription = null) },
headlineContent = { Text("Import Data") },
supportingContent = { Text("Import climbing data from ZIP file (recommended format)") },
leadingContent = { Icon(Icons.Default.Add, contentDescription = null) },
trailingContent = {
TextButton(
onClick = {
val defaultFileName = "openclimb_export_${
java.time.LocalDateTime.now()
.toString()
.replace(":", "-")
.replace(".", "-")
}.json"
exportJsonLauncher.launch(defaultFileName)
importLauncher.launch("application/zip")
},
enabled = !uiState.isLoading
) {
@@ -188,7 +190,7 @@ fun SettingsScreen(
strokeWidth = 2.dp
)
} else {
Text("Export JSON")
Text("Import")
}
}
}
@@ -200,17 +202,17 @@ fun SettingsScreen(
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
containerColor = MaterialTheme.colorScheme.errorContainer.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) },
headlineContent = { Text("Reset All Data") },
supportingContent = { Text("Permanently delete all gyms, problems, sessions, attempts, and images") },
leadingContent = { Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) },
trailingContent = {
TextButton(
onClick = {
importLauncher.launch("*/*")
showResetDialog = true
},
enabled = !uiState.isLoading
) {
@@ -220,7 +222,7 @@ fun SettingsScreen(
strokeWidth = 2.dp
)
} else {
Text("Import")
Text("Reset", color = MaterialTheme.colorScheme.error)
}
}
}
@@ -256,9 +258,22 @@ fun SettingsScreen(
)
) {
ListItem(
headlineContent = { Text("Version") },
supportingContent = { Text(appVersion ?: "Unknown") },
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
headlineContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text("OpenClimb")
}
},
supportingContent = { Text("Track your climbing progress") },
leadingContent = { }
)
}
@@ -271,8 +286,8 @@ fun SettingsScreen(
)
) {
ListItem(
headlineContent = { Text("About") },
supportingContent = { Text("OpenClimb - Track your climbing progress") },
headlineContent = { Text("Version") },
supportingContent = { Text(appVersion ?: "Unknown") },
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) }
)
}
@@ -364,4 +379,51 @@ fun SettingsScreen(
}
}
}
// Reset confirmation dialog
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Reset All Data") },
text = {
Column {
Text("Are you sure you want to reset all data?")
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This will permanently delete:",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This action cannot be undone. Consider exporting your data first.",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.error
)
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.resetAllData()
showResetDialog = false
}
) {
Text("Reset All Data", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text("Cancel")
}
}
)
}
}

View File

@@ -63,13 +63,4 @@ val ClimbNeutralVariant30 = Color(0xFF484848)
val ClimbNeutralVariant50 = Color(0xFF797979)
val ClimbNeutralVariant60 = Color(0xFF939393)
val ClimbNeutralVariant80 = Color(0xFFC7C7C7)
val ClimbNeutralVariant90 = Color(0xFFE3E3E3)
// Legacy colors for backward compatibility
val Purple80 = ClimbOrange80
val PurpleGrey80 = ClimbGrey80
val Pink80 = ClimbBlue80
val Purple40 = ClimbOrange40
val PurpleGrey40 = ClimbGrey40
val Pink40 = ClimbBlue40
val ClimbNeutralVariant90 = Color(0xFFE3E3E3)

View File

@@ -0,0 +1,25 @@
package com.atridad.openclimb.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
object CustomIcons {
fun Stop(color: Color = Color.Black): ImageVector = ImageVector.Builder(
name = "Stop",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).path(
fill = SolidColor(color)
) {
moveTo(6f, 6f)
horizontalLineTo(18f)
verticalLineTo(18f)
horizontalLineTo(6f)
close()
}.build()
}

View File

@@ -1,7 +1,6 @@
package com.atridad.openclimb.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
@@ -10,7 +9,6 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
@@ -98,7 +96,7 @@ fun OpenClimbTheme(
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
dynamicColor && true -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
@@ -110,8 +108,8 @@ fun OpenClimbTheme(
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}

View File

@@ -147,6 +147,14 @@ class ClimbViewModel(
// Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch {
// Check notification permission first
if (!com.atridad.openclimb.utils.NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
_uiState.value = _uiState.value.copy(
error = "Notification permission is required to track your climbing session. Please enable notifications in settings."
)
return@launch
}
val existingActive = repository.getActiveSession()
if (existingActive != null) {
_uiState.value = _uiState.value.copy(
@@ -170,13 +178,21 @@ class ClimbViewModel(
fun endSession(context: Context, sessionId: String) {
viewModelScope.launch {
// Check notification permission first
if (!com.atridad.openclimb.utils.NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
_uiState.value = _uiState.value.copy(
error = "Notification permission is required to manage your climbing session. Please enable notifications in settings."
)
return@launch
}
val session = repository.getSessionById(sessionId)
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)
// Stop the tracking service, passing the session id so service can finalize if needed
val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
context.startService(serviceIntent)
_uiState.value = _uiState.value.copy(
@@ -186,28 +202,17 @@ class ClimbViewModel(
}
}
fun pauseSession(sessionId: String) {
/**
* Check if the session tracking service is running and restart it if needed
*/
fun ensureSessionTrackingServiceRunning(context: Context) {
viewModelScope.launch {
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)
val activeSession = repository.getActiveSession()
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
// Check if service is running by trying to start it again
// The service will handle duplicate starts gracefully
val serviceIntent = SessionTrackingService.createStartIntent(context, activeSession.id)
context.startForegroundService(serviceIntent)
}
}
}
@@ -219,89 +224,26 @@ class ClimbViewModel(
}
}
fun updateAttempt(attempt: Attempt) {
viewModelScope.launch {
repository.updateAttempt(attempt)
}
}
fun deleteAttempt(attempt: Attempt) {
viewModelScope.launch {
repository.deleteAttempt(attempt)
}
}
fun updateAttempt(attempt: Attempt) {
viewModelScope.launch {
repository.updateAttempt(attempt)
}
}
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
repository.getAttemptsByProblem(problemId)
// Analytics operations
// fun getProblemProgress(problemId: String): Flow<ProblemProgress?> =
// repository.getProblemProgress(problemId)
// fun getSessionSummary(sessionId: String): Flow<SessionSummary?> =
// repository.getSessionSummary(sessionId)
// Export operations
fun exportData(context: Context, directory: File? = null) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
val exportFile = repository.exportAllDataToJson(directory)
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Data exported to: ${exportFile.absolutePath}"
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "Export failed: ${e.message}"
)
}
}
}
fun exportDataToUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch {
try {
_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 {
@@ -325,13 +267,13 @@ class ClimbViewModel(
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)
// Only support ZIP format for reliability
if (!file.name.lowercase().endsWith(".zip")) {
throw Exception("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.")
}
repository.importDataFromZip(file)
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Data imported successfully from ${file.name}"
@@ -358,9 +300,25 @@ class ClimbViewModel(
_uiState.value = _uiState.value.copy(error = message)
}
// Search operations
fun searchGyms(query: String): Flow<List<Gym>> = repository.searchGyms(query)
fun searchProblems(query: String): Flow<List<Problem>> = repository.searchProblems(query)
fun resetAllData() {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
repository.resetAllData()
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "All data has been reset successfully"
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "Reset failed: ${e.message}"
)
}
}
}
// Share operations
suspend fun generateSessionShareCard(

View File

@@ -1,13 +1,14 @@
package com.atridad.openclimb.utils
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.UUID
import androidx.core.graphics.scale
object ImageUtils {
@@ -34,37 +35,96 @@ object ImageUtils {
*/
fun saveImageFromUri(context: Context, imageUri: Uri): String? {
return try {
val inputStream = context.contentResolver.openInputStream(imageUri)
inputStream?.use { input ->
// Decode and compress the image
val originalBitmap = BitmapFactory.decodeStream(input)
val compressedBitmap = compressImage(originalBitmap)
// Generate unique filename
val filename = "${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), filename)
// Save compressed image
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Clean up bitmaps
originalBitmap.recycle()
compressedBitmap.recycle()
// Return relative path
"$IMAGES_DIR/$filename"
// Decode bitmap from a fresh stream to avoid mark/reset dependency
val originalBitmap = context.contentResolver.openInputStream(imageUri)?.use { input ->
BitmapFactory.decodeStream(input)
} ?: return null
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap)
// Generate unique filename
val filename = "${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), filename)
// Save compressed image
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Clean up bitmaps
originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle()
}
compressedBitmap.recycle()
// Return relative path
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/**
* Corrects image orientation based on EXIF data
*/
private fun correctImageOrientation(context: Context, imageUri: Uri, bitmap: Bitmap): Bitmap {
return try {
val inputStream = context.contentResolver.openInputStream(imageUri)
inputStream?.use { input ->
val exif = android.media.ExifInterface(input)
val orientation = exif.getAttributeInt(
android.media.ExifInterface.TAG_ORIENTATION,
android.media.ExifInterface.ORIENTATION_NORMAL
)
val matrix = android.graphics.Matrix()
when (orientation) {
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
}
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
}
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
}
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
}
}
if (matrix.isIdentity) {
bitmap
} else {
android.graphics.Bitmap.createBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
)
}
} ?: bitmap
} catch (e: Exception) {
e.printStackTrace()
bitmap
}
}
/**
* Compresses and resizes an image bitmap
*/
@SuppressLint("UseKtx")
private fun compressImage(original: Bitmap): Bitmap {
val width = original.width
val height = original.height
@@ -79,7 +139,7 @@ object ImageUtils {
return if (scaleFactor < 1f) {
val newWidth = (width * scaleFactor).toInt()
val newHeight = (height * scaleFactor).toInt()
Bitmap.createScaledBitmap(original, newWidth, newHeight, true)
original.scale(newWidth, newHeight)
} else {
original
}
@@ -110,30 +170,7 @@ object ImageUtils {
false
}
}
/**
* Copies an image file to export directory
* @param context Android context
* @param relativePath The relative path of the image
* @param exportDir The directory to copy to
* @return The filename in the export directory, null if failed
*/
fun copyImageForExport(context: Context, relativePath: String, exportDir: File): String? {
return try {
val sourceFile = getImageFile(context, relativePath)
if (!sourceFile.exists()) return null
val filename = sourceFile.name
val destFile = File(exportDir, filename)
sourceFile.copyTo(destFile, overwrite = true)
filename
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/**
* Imports an image file from the import directory
* @param context Android context

View File

@@ -0,0 +1,33 @@
package com.atridad.openclimb.utils
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
object NotificationPermissionUtils {
/**
* Check if notification permission is granted
*/
fun isNotificationPermissionGranted(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
/**
* Check if notification permission should be requested
*/
fun shouldRequestNotificationPermission(): Boolean {
return true
}
/**
* Get the notification permission string
*/
fun getNotificationPermissionString(): String {
return Manifest.permission.POST_NOTIFICATIONS
}
}

View File

@@ -11,6 +11,8 @@ import java.io.FileOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt
object SessionShareUtils {
@@ -22,7 +24,8 @@ object SessionShareUtils {
val uniqueProblemsCompleted: Int,
val averageGrade: String?,
val sessionDuration: String,
val topResult: AttemptResult?
val topResult: AttemptResult?,
val topGrade: String?
)
fun calculateSessionStats(
@@ -32,9 +35,7 @@ object SessionShareUtils {
): SessionStats {
val successfulResults = listOf(
AttemptResult.SUCCESS,
AttemptResult.FLASH,
AttemptResult.REDPOINT,
AttemptResult.ONSIGHT
AttemptResult.FLASH
)
val successfulAttempts = attempts.filter { it.result in successfulResults }
@@ -42,22 +43,39 @@ object SessionShareUtils {
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in uniqueProblems }
val averageGrade = if (attemptedProblems.isNotEmpty()) {
// This is a simplified average - in reality you'd need proper grade conversion
val gradeValues = attemptedProblems.mapNotNull { problem ->
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
}
if (gradeValues.isNotEmpty()) {
"V${gradeValues.average().roundToInt()}"
} else null
} else null
// Calculate separate averages for different climbing types and difficulty systems
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder")
val ropeAverage = calculateAverageGrade(ropeProblems, "Rope")
// Combine averages for display
val averageGrade = when {
boulderAverage != null && ropeAverage != null -> "$boulderAverage / $ropeAverage"
boulderAverage != null -> boulderAverage
ropeAverage != null -> ropeAverage
else -> null
}
// Determine highest achieved grade (only from completed problems: SUCCESS or FLASH)
val completedProblems = problems.filter { it.id in uniqueCompletedProblems }
val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER }
val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE }
val topBoulder = highestGradeForProblems(completedBoulder)
val topRope = highestGradeForProblems(completedRope)
val topGrade = when {
topBoulder != null && topRope != null -> "$topBoulder / $topRope"
topBoulder != null -> topBoulder
topRope != null -> topRope
else -> null
}
val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
val topResult = attempts.maxByOrNull {
when (it.result) {
AttemptResult.ONSIGHT -> 5
AttemptResult.FLASH -> 4
AttemptResult.REDPOINT -> 3
AttemptResult.FLASH -> 3
AttemptResult.SUCCESS -> 2
AttemptResult.FALL -> 1
else -> 0
@@ -72,34 +90,81 @@ object SessionShareUtils {
uniqueProblemsCompleted = uniqueCompletedProblems.size,
averageGrade = averageGrade,
sessionDuration = duration,
topResult = topResult
topResult = topResult,
topGrade = topGrade
)
}
private fun calculateDuration(startTime: String?, endTime: String?): String {
return try {
if (startTime != null && endTime != null) {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val start = LocalDateTime.parse(startTime, formatter)
val end = LocalDateTime.parse(endTime, formatter)
val duration = java.time.Duration.between(start, end)
val hours = duration.toHours()
val minutes = duration.toMinutes() % 60
when {
hours > 0 -> "${hours}h ${minutes}m"
minutes > 0 -> "${minutes}m"
else -> "< 1m"
/**
* Calculate average grade for a specific set of problems, respecting their difficulty systems
*/
private fun calculateAverageGrade(problems: List<Problem>, climbingType: String): String? {
if (problems.isEmpty()) return null
// Group problems by difficulty system
val problemsBySystem = problems.groupBy { it.difficulty.system }
val averages = mutableListOf<String>()
problemsBySystem.forEach { (system, systemProblems) ->
when (system) {
DifficultySystem.V_SCALE -> {
val gradeValues = systemProblems.mapNotNull { problem ->
when {
problem.difficulty.grade == "VB" -> 0
else -> problem.difficulty.grade.removePrefix("V").toIntOrNull()
}
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt()
averages.add(if (avg == 0) "VB" else "V$avg")
}
}
DifficultySystem.FONT -> {
val gradeValues = systemProblems.mapNotNull { problem ->
// Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" -> 7)
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt()
averages.add("$avg")
}
}
DifficultySystem.YDS -> {
val gradeValues = systemProblems.mapNotNull { problem ->
// Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10)
val grade = problem.difficulty.grade
if (grade.startsWith("5.")) {
grade.substring(2).toDoubleOrNull()
} else null
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average()
averages.add("5.${String.format("%.1f", avg)}")
}
}
DifficultySystem.CUSTOM -> {
// For custom systems, try to extract numeric values
val gradeValues = systemProblems.mapNotNull { problem ->
problem.difficulty.grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull()
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average()
averages.add(String.format("%.1f", avg))
}
}
} else {
"Unknown"
}
} catch (e: Exception) {
"Unknown"
}
return if (averages.isNotEmpty()) {
if (averages.size == 1) {
averages.first()
} else {
averages.joinToString(" / ")
}
} else null
}
fun generateShareCard(
context: Context,
session: ClimbSession,
@@ -107,17 +172,17 @@ object SessionShareUtils {
stats: SessionStats
): File? {
return try {
val width = 1080
val height = 1350
val width = 1242 // 3:4 aspect at higher resolution for better fit
val height = 1656
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val gradientDrawable = GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf(
Color.parseColor("#667eea"),
Color.parseColor("#764ba2")
"#667eea".toColorInt(),
"#764ba2".toColorInt()
)
)
gradientDrawable.setBounds(0, 0, width, height)
@@ -133,7 +198,7 @@ object SessionShareUtils {
}
val subtitlePaint = Paint().apply {
color = Color.parseColor("#E8E8E8")
color = "#E8E8E8".toColorInt()
textSize = 48f
typeface = Typeface.DEFAULT
isAntiAlias = true
@@ -141,7 +206,7 @@ object SessionShareUtils {
}
val statLabelPaint = Paint().apply {
color = Color.parseColor("#B8B8B8")
color = "#B8B8B8".toColorInt()
textSize = 36f
typeface = Typeface.DEFAULT
isAntiAlias = true
@@ -157,12 +222,12 @@ object SessionShareUtils {
}
val cardPaint = Paint().apply {
color = Color.parseColor("#40FFFFFF")
color = "#40FFFFFF".toColorInt()
isAntiAlias = true
}
// Draw main card background
val cardRect = RectF(60f, 200f, width - 60f, height - 100f)
val cardRect = RectF(60f, 200f, width - 60f, height - 120f)
canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
// Draw content
@@ -183,35 +248,48 @@ object SessionShareUtils {
// Stats grid
val statsStartY = yPosition
val columnWidth = width / 2f
val columnMaxTextWidth = columnWidth - 120f
// Left column stats
var leftY = statsStartY
drawStatItem(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint)
leftY += 140f
drawStatItem(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint)
leftY += 140f
drawStatItem(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint)
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
leftY += 120f
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
leftY += 120f
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint, columnMaxTextWidth)
// Right column stats
var rightY = statsStartY
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint)
rightY += 140f
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint)
rightY += 140f
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
rightY += 120f
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
rightY += 120f
stats.averageGrade?.let { grade ->
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Avg Grade", grade, statLabelPaint, statValuePaint)
var rightYAfter = rightY
stats.topGrade?.let { grade ->
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint, columnMaxTextWidth)
rightYAfter += 120f
}
// Success rate arc
if (stats.totalAttempts > 0) {
val successRate = (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100
drawSuccessRateArc(canvas, width / 2f, height - 280f, successRate, statLabelPaint, statValuePaint)
// Grade range(s)
val boulderRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.BOULDER })
val ropeRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE })
val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f
if (boulderRange != null && ropeRange != null) {
// Two evenly spaced items
drawStatItemFitting(canvas, columnWidth / 2f, rangesY, "Boulder Range", boulderRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
drawStatItemFitting(canvas, width - columnWidth / 2f, rangesY, "Rope Range", ropeRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
} else if (boulderRange != null || ropeRange != null) {
// Single centered item
val singleRange = boulderRange ?: ropeRange ?: ""
drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f)
}
// App branding
val brandingPaint = Paint().apply {
color = Color.parseColor("#80FFFFFF")
color = "#80FFFFFF".toColorInt()
textSize = 32f
typeface = Typeface.DEFAULT
isAntiAlias = true
@@ -255,57 +333,50 @@ object SessionShareUtils {
canvas.drawText(label, x, y + 50f, labelPaint)
}
private fun drawSuccessRateArc(
/**
* Draws a stat item while fitting the value text to a max width by reducing text size if needed.
*/
private fun drawStatItemFitting(
canvas: Canvas,
centerX: Float,
centerY: Float,
successRate: Float,
x: Float,
y: Float,
label: String,
value: String,
labelPaint: Paint,
valuePaint: Paint
valuePaint: Paint,
maxTextWidth: Float
) {
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
val tempPaint = Paint(valuePaint)
var textSize = tempPaint.textSize
var textWidth = tempPaint.measureText(value)
while (textWidth > maxTextWidth && textSize > 36f) {
textSize -= 2f
tempPaint.textSize = textSize
textWidth = tempPaint.measureText(value)
}
// 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)
canvas.drawText(value, x, y, tempPaint)
canvas.drawText(label, x, y + 50f, labelPaint)
}
/**
* Returns a range string like "X - Y" for the given problems, based on their difficulty grades.
*/
private fun gradeRangeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
val grades = problems.map { it.difficulty }
val sorted = grades.sortedWith { a, b -> a.compareTo(b) }
return "${sorted.first().grade} - ${sorted.last().grade}"
}
private fun 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) {
} catch (_: Exception) {
dateString.take(10)
}
}
@@ -333,4 +404,48 @@ object SessionShareUtils {
e.printStackTrace()
}
}
/**
* Returns the highest grade string among the given problems, respecting their difficulty system.
*/
private fun highestGradeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
return problems.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }?.difficulty?.grade
}
/**
* Produces a comparable numeric rank for grades across supported systems.
*/
private fun gradeRank(system: DifficultySystem, grade: String): Double {
return when (system) {
DifficultySystem.V_SCALE -> {
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
}
DifficultySystem.FONT -> {
val list = DifficultySystem.FONT.getAvailableGrades()
val idx = list.indexOf(grade.uppercase())
if (idx >= 0) idx.toDouble() else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
}
DifficultySystem.YDS -> {
// Parse 5.X with optional letter a-d
val s = grade.lowercase()
if (!s.startsWith("5.")) return -1.0
val tail = s.removePrefix("5.")
val numberPart = tail.takeWhile { it.isDigit() || it == '.' }
val letterPart = tail.drop(numberPart.length).firstOrNull()
val base = numberPart.toDoubleOrNull() ?: return -1.0
val letterWeight = when (letterPart) {
'a' -> 0.0
'b' -> 0.1
'c' -> 0.2
'd' -> 0.3
else -> 0.0
}
base + letterWeight
}
DifficultySystem.CUSTOM -> {
grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() ?: -1.0
}
}
}
}

View File

@@ -1,7 +1,6 @@
package com.atridad.openclimb.utils
import android.content.Context
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.io.FileInputStream
@@ -16,6 +15,7 @@ object ZipExportImportUtils {
private const val DATA_JSON_FILENAME = "data.json"
private const val IMAGES_DIR_NAME = "images"
private const val METADATA_FILENAME = "metadata.txt"
/**
* Creates a ZIP file containing the JSON data and all referenced images
@@ -39,37 +39,67 @@ object ZipExportImportUtils {
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()
try {
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
// Add metadata file first
val metadata = createMetadata(exportData, referencedImagePaths)
val metadataEntry = ZipEntry(METADATA_FILENAME)
zipOut.putNextEntry(metadataEntry)
zipOut.write(metadata.toByteArray())
zipOut.closeEntry()
// Add JSON data file
val json = Json {
prettyPrint = true
ignoreUnknownKeys = 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 with validation
var successfulImages = 0
referencedImagePaths.forEach { imagePath ->
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (imageFile.exists() && imageFile.length() > 0) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry)
FileInputStream(imageFile).use { imageInput ->
imageInput.copyTo(zipOut)
}
zipOut.closeEntry()
successfulImages++
} else {
android.util.Log.w("ZipExportImportUtils", "Image file not found or empty: $imagePath")
}
} catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}")
}
}
// Log export summary
android.util.Log.i("ZipExportImportUtils", "Export completed: ${successfulImages}/${referencedImagePaths.size} images included")
}
// Validate the created ZIP file
if (!zipFile.exists() || zipFile.length() == 0L) {
throw IOException("Failed to create ZIP file: file is empty or doesn't exist")
}
return zipFile
} catch (e: Exception) {
// Clean up failed export
if (zipFile.exists()) {
zipFile.delete()
}
throw IOException("Failed to create export ZIP: ${e.message}")
}
return zipFile
}
/**
@@ -85,37 +115,73 @@ object ZipExportImportUtils {
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
referencedImagePaths: Set<String>
) {
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()
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
ZipOutputStream(outputStream).use { zipOut ->
// Add metadata file first
val metadata = createMetadata(exportData, referencedImagePaths)
val metadataEntry = ZipEntry(METADATA_FILENAME)
zipOut.putNextEntry(metadataEntry)
zipOut.write(metadata.toByteArray())
zipOut.closeEntry()
// Add JSON data file
val json = Json {
prettyPrint = true
ignoreUnknownKeys = 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 with validation
var successfulImages = 0
referencedImagePaths.forEach { imagePath ->
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (imageFile.exists() && imageFile.length() > 0) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry)
FileInputStream(imageFile).use { imageInput ->
imageInput.copyTo(zipOut)
}
zipOut.closeEntry()
successfulImages++
}
} catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}")
}
}
android.util.Log.i("ZipExportImportUtils", "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included")
}
}
} ?: throw IOException("Could not open output stream")
} ?: throw IOException("Could not open output stream")
} catch (e: Exception) {
throw IOException("Failed to create export ZIP to URI: ${e.message}")
}
}
private fun createMetadata(
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
referencedImagePaths: Set<String>
): String {
return buildString {
appendLine("OpenClimb Export Metadata")
appendLine("=======================")
appendLine("Export Date: ${exportData.exportedAt}")
appendLine("Version: ${exportData.version}")
appendLine("Gyms: ${exportData.gyms.size}")
appendLine("Problems: ${exportData.problems.size}")
appendLine("Sessions: ${exportData.sessions.size}")
appendLine("Attempts: ${exportData.attempts.size}")
appendLine("Referenced Images: ${referencedImagePaths.size}")
appendLine("Format: ZIP with embedded JSON data and images")
}
}
/**
@@ -134,59 +200,91 @@ object ZipExportImportUtils {
*/
fun extractImportZip(context: Context, zipFile: File): ImportResult {
var jsonContent = ""
var metadataContent = ""
val importedImagePaths = mutableMapOf<String, String>()
var foundRequiredFiles = mutableSetOf<String>()
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)
try {
ZipInputStream(FileInputStream(zipFile)).use { zipIn ->
var entry = zipIn.nextEntry
while (entry != null) {
when {
entry.name == METADATA_FILENAME -> {
// Read metadata for validation
metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("metadata")
android.util.Log.i("ZipExportImportUtils", "Found metadata: ${metadataContent.lines().take(3).joinToString()}")
}
entry.name == DATA_JSON_FILENAME -> {
// Read JSON data
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("data")
}
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
// Extract image file
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
try {
// Create temporary file to hold the extracted image
val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir)
FileOutputStream(tempFile).use { output ->
zipIn.copyTo(output)
}
// Validate the extracted image
if (tempFile.exists() && tempFile.length() > 0) {
// Import the image to permanent storage
val newPath = ImageUtils.importImageFile(context, tempFile)
if (newPath != null) {
importedImagePaths[originalFilename] = newPath
android.util.Log.d("ZipExportImportUtils", "Successfully imported image: $originalFilename -> $newPath")
} else {
android.util.Log.w("ZipExportImportUtils", "Failed to import image: $originalFilename")
}
} else {
android.util.Log.w("ZipExportImportUtils", "Extracted image is empty: $originalFilename")
}
// Clean up temp file
tempFile.delete()
} catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to process image $originalFilename: ${e.message}")
}
}
else -> {
android.util.Log.d("ZipExportImportUtils", "Skipping ZIP entry: ${entry.name}")
}
}
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
// 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
}
zipIn.closeEntry()
entry = zipIn.nextEntry
}
// Validate that we found the required files
if (!foundRequiredFiles.contains("data")) {
throw IOException("Invalid ZIP file: data.json not found")
}
if (jsonContent.isBlank()) {
throw IOException("Invalid ZIP file: data.json is empty")
}
android.util.Log.i("ZipExportImportUtils", "Import extraction completed: ${importedImagePaths.size} images processed")
return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) {
throw IOException("Failed to extract import ZIP: ${e.message}")
}
if (jsonContent.isEmpty()) {
throw IOException("No data.json file found in the ZIP archive")
}
return ImportResult(jsonContent, importedImagePaths)
}
/**
* Utility function to determine if a file is a ZIP file based on extension
*/
fun isZipFile(filename: String): Boolean {
return filename.lowercase().endsWith(".zip")
}
/**
* Updates image paths in a problem list after import
* This function maps the old image paths to the new ones after import

View File

@@ -4,167 +4,8 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
<!-- Clean white background -->
<path android:fillColor="#FFFFFF"
android:pathData="M0,0h108v108h-108z"/>
</vector>

View File

@@ -1,30 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
<group
android:scaleX="0.7"
android:scaleY="0.7"
android:translateX="16.2"
android:translateY="20">
<!-- Left mountain (yellow/amber) -->
<path
android:fillColor="#FFC107"
android:strokeColor="#1C1C1C"
android:strokeWidth="3"
android:strokeLineJoin="round"
android:pathData="M15,70 L35,25 L55,70 Z" />
<!-- Right mountain (red) -->
<path
android:fillColor="#F44336"
android:strokeColor="#1C1C1C"
android:strokeWidth="3"
android:strokeLineJoin="round"
android:pathData="M40,70 L65,15 L90,70 Z" />
</group>
</vector>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Left mountain (yellow/amber) -->
<path
android:fillColor="#FFC107"
android:pathData="M3,18 L8,9 L13,18 Z" />
<!-- Right mountain (red) -->
<path
android:fillColor="#F44336"
android:pathData="M11,18 L16,7 L21,18 Z" />
<!-- Black outlines -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#1C1C1C"
android:strokeWidth="1"
android:strokeLineJoin="round"
android:pathData="M3,18 L8,9 L13,18" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="#1C1C1C"
android:strokeWidth="1"
android:strokeLineJoin="round"
android:pathData="M11,18 L16,7 L21,18" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Splash background (dark) -->
<color name="splash_background">#FF121212</color>
</resources>

View File

@@ -7,4 +7,7 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Splash background (light) -->
<color name="splash_background">#FFFFFFFF</color>
</resources>

View File

@@ -1,3 +1,4 @@
<resources>
<string name="app_name">OpenClimb</string>
<string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string>
</resources>

View File

@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.OpenClimb.Splash" parent="Theme.OpenClimb">
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
<item name="android:windowSplashScreenAnimationDuration">200</item>
</style>
</resources>

File diff suppressed because one or more lines are too long

View File

@@ -1,26 +1,34 @@
[versions]
agp = "8.9.1"
kotlin = "2.0.21"
coreKtx = "1.15.0"
agp = "8.12.1"
kotlin = "2.2.10"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.9.2"
androidxTestCore = "1.7.0"
androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.3"
activityCompose = "1.10.1"
composeBom = "2024.09.00"
room = "2.6.1"
navigation = "2.8.4"
viewmodel = "2.9.2"
kotlinxSerialization = "1.7.1"
kotlinxCoroutines = "1.9.0"
composeBom = "2025.08.01"
room = "2.7.2"
navigation = "2.9.3"
viewmodel = "2.9.3"
kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2"
coil = "2.7.0"
ksp = "2.0.21-1.0.25"
ksp = "2.2.10-2.0.2"
[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-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
androidx-test-ext = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
@@ -48,12 +56,15 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
# Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Testing
mockk = { group = "io.mockk", name = "mockk", version = "1.13.8" }
# Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
# 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" }

View File

@@ -1,6 +1,6 @@
#Fri Aug 15 11:23:25 MDT 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists