Compare commits

..

32 Commits
0.3.2 ... 1.4.0

Author SHA1 Message Date
f45ff8963d Merge remote-tracking branch 'origin/main' 2025-09-06 23:19:36 -06:00
5988cbf1fb 1.4.0 - Shortcuts & Widgets 2025-09-06 23:19:26 -06:00
13654cde70 Update README.md 2025-09-01 07:14:52 +00:00
9064dbe2ef Update README.md 2025-09-01 07:14:40 +00:00
0537da79e4 1.3.1 - Graphing Fixes Cont'd 2025-08-31 19:05:18 -06:00
4804049274 1.3.1 - Graphing Fixes Cont'd 2025-08-31 19:03:43 -06:00
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
128 changed files with 4216 additions and 4817 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

Binary file not shown.

View File

@@ -1,769 +0,0 @@
package org.gradle.accessors.dm;
import org.gradle.api.NonNullApi;
import org.gradle.api.artifacts.MinimalExternalModuleDependency;
import org.gradle.plugin.use.PluginDependency;
import org.gradle.api.artifacts.ExternalModuleDependencyBundle;
import org.gradle.api.artifacts.MutableVersionConstraint;
import org.gradle.api.provider.Provider;
import org.gradle.api.model.ObjectFactory;
import org.gradle.api.provider.ProviderFactory;
import org.gradle.api.internal.catalog.AbstractExternalDependencyFactory;
import org.gradle.api.internal.catalog.DefaultVersionCatalog;
import java.util.Map;
import org.gradle.api.internal.attributes.ImmutableAttributesFactory;
import org.gradle.api.internal.artifacts.dsl.CapabilityNotationParser;
import javax.inject.Inject;
/**
* A catalog of dependencies accessible via the {@code libs} extension.
*/
@NonNullApi
public class LibrariesForLibs extends AbstractExternalDependencyFactory {
private final AbstractExternalDependencyFactory owner = this;
private final AndroidxLibraryAccessors laccForAndroidxLibraryAccessors = new AndroidxLibraryAccessors(owner);
private final CoilLibraryAccessors laccForCoilLibraryAccessors = new CoilLibraryAccessors(owner);
private final KotlinxLibraryAccessors laccForKotlinxLibraryAccessors = new KotlinxLibraryAccessors(owner);
private final VersionAccessors vaccForVersionAccessors = new VersionAccessors(providers, config);
private final BundleAccessors baccForBundleAccessors = new BundleAccessors(objects, providers, config, attributesFactory, capabilityNotationParser);
private final PluginAccessors paccForPluginAccessors = new PluginAccessors(providers, config);
@Inject
public LibrariesForLibs(DefaultVersionCatalog config, ProviderFactory providers, ObjectFactory objects, ImmutableAttributesFactory attributesFactory, CapabilityNotationParser capabilityNotationParser) {
super(config, providers, objects, attributesFactory, capabilityNotationParser);
}
/**
* Dependency provider for <b>junit</b> with <b>junit:junit</b> coordinates and
* with version reference <b>junit</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getJunit() {
return create("junit");
}
/**
* Dependency provider for <b>mpandroidchart</b> with <b>com.github.PhilJay:MPAndroidChart</b> coordinates and
* with version <b>v3.1.0</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getMpandroidchart() {
return create("mpandroidchart");
}
/**
* Group of libraries at <b>androidx</b>
*/
public AndroidxLibraryAccessors getAndroidx() {
return laccForAndroidxLibraryAccessors;
}
/**
* Group of libraries at <b>coil</b>
*/
public CoilLibraryAccessors getCoil() {
return laccForCoilLibraryAccessors;
}
/**
* Group of libraries at <b>kotlinx</b>
*/
public KotlinxLibraryAccessors getKotlinx() {
return laccForKotlinxLibraryAccessors;
}
/**
* Group of versions at <b>versions</b>
*/
public VersionAccessors getVersions() {
return vaccForVersionAccessors;
}
/**
* Group of bundles at <b>bundles</b>
*/
public BundleAccessors getBundles() {
return baccForBundleAccessors;
}
/**
* Group of plugins at <b>plugins</b>
*/
public PluginAccessors getPlugins() {
return paccForPluginAccessors;
}
public static class AndroidxLibraryAccessors extends SubDependencyFactory {
private final AndroidxActivityLibraryAccessors laccForAndroidxActivityLibraryAccessors = new AndroidxActivityLibraryAccessors(owner);
private final AndroidxComposeLibraryAccessors laccForAndroidxComposeLibraryAccessors = new AndroidxComposeLibraryAccessors(owner);
private final AndroidxCoreLibraryAccessors laccForAndroidxCoreLibraryAccessors = new AndroidxCoreLibraryAccessors(owner);
private final AndroidxEspressoLibraryAccessors laccForAndroidxEspressoLibraryAccessors = new AndroidxEspressoLibraryAccessors(owner);
private final AndroidxLifecycleLibraryAccessors laccForAndroidxLifecycleLibraryAccessors = new AndroidxLifecycleLibraryAccessors(owner);
private final AndroidxNavigationLibraryAccessors laccForAndroidxNavigationLibraryAccessors = new AndroidxNavigationLibraryAccessors(owner);
private final AndroidxRoomLibraryAccessors laccForAndroidxRoomLibraryAccessors = new AndroidxRoomLibraryAccessors(owner);
private final AndroidxUiLibraryAccessors laccForAndroidxUiLibraryAccessors = new AndroidxUiLibraryAccessors(owner);
public AndroidxLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>junit</b> with <b>androidx.test.ext:junit</b> coordinates and
* with version reference <b>junitVersion</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getJunit() {
return create("androidx.junit");
}
/**
* Dependency provider for <b>material3</b> with <b>androidx.compose.material3:material3</b> coordinates and
* with <b>no version specified</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getMaterial3() {
return create("androidx.material3");
}
/**
* Group of libraries at <b>androidx.activity</b>
*/
public AndroidxActivityLibraryAccessors getActivity() {
return laccForAndroidxActivityLibraryAccessors;
}
/**
* Group of libraries at <b>androidx.compose</b>
*/
public AndroidxComposeLibraryAccessors getCompose() {
return laccForAndroidxComposeLibraryAccessors;
}
/**
* Group of libraries at <b>androidx.core</b>
*/
public AndroidxCoreLibraryAccessors getCore() {
return laccForAndroidxCoreLibraryAccessors;
}
/**
* Group of libraries at <b>androidx.espresso</b>
*/
public AndroidxEspressoLibraryAccessors getEspresso() {
return laccForAndroidxEspressoLibraryAccessors;
}
/**
* Group of libraries at <b>androidx.lifecycle</b>
*/
public AndroidxLifecycleLibraryAccessors getLifecycle() {
return laccForAndroidxLifecycleLibraryAccessors;
}
/**
* Group of libraries at <b>androidx.navigation</b>
*/
public AndroidxNavigationLibraryAccessors getNavigation() {
return laccForAndroidxNavigationLibraryAccessors;
}
/**
* Group of libraries at <b>androidx.room</b>
*/
public AndroidxRoomLibraryAccessors getRoom() {
return laccForAndroidxRoomLibraryAccessors;
}
/**
* Group of libraries at <b>androidx.ui</b>
*/
public AndroidxUiLibraryAccessors getUi() {
return laccForAndroidxUiLibraryAccessors;
}
}
public static class AndroidxActivityLibraryAccessors extends SubDependencyFactory {
public AndroidxActivityLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>compose</b> with <b>androidx.activity:activity-compose</b> coordinates and
* with version reference <b>activityCompose</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getCompose() {
return create("androidx.activity.compose");
}
}
public static class AndroidxComposeLibraryAccessors extends SubDependencyFactory {
public AndroidxComposeLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>bom</b> with <b>androidx.compose:compose-bom</b> coordinates and
* with version reference <b>composeBom</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getBom() {
return create("androidx.compose.bom");
}
}
public static class AndroidxCoreLibraryAccessors extends SubDependencyFactory {
public AndroidxCoreLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>ktx</b> with <b>androidx.core:core-ktx</b> coordinates and
* with version reference <b>coreKtx</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getKtx() {
return create("androidx.core.ktx");
}
}
public static class AndroidxEspressoLibraryAccessors extends SubDependencyFactory {
public AndroidxEspressoLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>core</b> with <b>androidx.test.espresso:espresso-core</b> coordinates and
* with version reference <b>espressoCore</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getCore() {
return create("androidx.espresso.core");
}
}
public static class AndroidxLifecycleLibraryAccessors extends SubDependencyFactory {
private final AndroidxLifecycleRuntimeLibraryAccessors laccForAndroidxLifecycleRuntimeLibraryAccessors = new AndroidxLifecycleRuntimeLibraryAccessors(owner);
private final AndroidxLifecycleViewmodelLibraryAccessors laccForAndroidxLifecycleViewmodelLibraryAccessors = new AndroidxLifecycleViewmodelLibraryAccessors(owner);
public AndroidxLifecycleLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Group of libraries at <b>androidx.lifecycle.runtime</b>
*/
public AndroidxLifecycleRuntimeLibraryAccessors getRuntime() {
return laccForAndroidxLifecycleRuntimeLibraryAccessors;
}
/**
* Group of libraries at <b>androidx.lifecycle.viewmodel</b>
*/
public AndroidxLifecycleViewmodelLibraryAccessors getViewmodel() {
return laccForAndroidxLifecycleViewmodelLibraryAccessors;
}
}
public static class AndroidxLifecycleRuntimeLibraryAccessors extends SubDependencyFactory {
public AndroidxLifecycleRuntimeLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>ktx</b> with <b>androidx.lifecycle:lifecycle-runtime-ktx</b> coordinates and
* with version reference <b>lifecycleRuntimeKtx</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getKtx() {
return create("androidx.lifecycle.runtime.ktx");
}
}
public static class AndroidxLifecycleViewmodelLibraryAccessors extends SubDependencyFactory {
public AndroidxLifecycleViewmodelLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>compose</b> with <b>androidx.lifecycle:lifecycle-viewmodel-compose</b> coordinates and
* with version reference <b>viewmodel</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getCompose() {
return create("androidx.lifecycle.viewmodel.compose");
}
}
public static class AndroidxNavigationLibraryAccessors extends SubDependencyFactory {
public AndroidxNavigationLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>compose</b> with <b>androidx.navigation:navigation-compose</b> coordinates and
* with version reference <b>navigation</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getCompose() {
return create("androidx.navigation.compose");
}
}
public static class AndroidxRoomLibraryAccessors extends SubDependencyFactory {
public AndroidxRoomLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>compiler</b> with <b>androidx.room:room-compiler</b> coordinates and
* with version reference <b>room</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getCompiler() {
return create("androidx.room.compiler");
}
/**
* Dependency provider for <b>ktx</b> with <b>androidx.room:room-ktx</b> coordinates and
* with version reference <b>room</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getKtx() {
return create("androidx.room.ktx");
}
/**
* Dependency provider for <b>runtime</b> with <b>androidx.room:room-runtime</b> coordinates and
* with version reference <b>room</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getRuntime() {
return create("androidx.room.runtime");
}
}
public static class AndroidxUiLibraryAccessors extends SubDependencyFactory implements DependencyNotationSupplier {
private final AndroidxUiTestLibraryAccessors laccForAndroidxUiTestLibraryAccessors = new AndroidxUiTestLibraryAccessors(owner);
private final AndroidxUiToolingLibraryAccessors laccForAndroidxUiToolingLibraryAccessors = new AndroidxUiToolingLibraryAccessors(owner);
public AndroidxUiLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>ui</b> with <b>androidx.compose.ui:ui</b> coordinates and
* with <b>no version specified</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> asProvider() {
return create("androidx.ui");
}
/**
* Dependency provider for <b>graphics</b> with <b>androidx.compose.ui:ui-graphics</b> coordinates and
* with <b>no version specified</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getGraphics() {
return create("androidx.ui.graphics");
}
/**
* Group of libraries at <b>androidx.ui.test</b>
*/
public AndroidxUiTestLibraryAccessors getTest() {
return laccForAndroidxUiTestLibraryAccessors;
}
/**
* Group of libraries at <b>androidx.ui.tooling</b>
*/
public AndroidxUiToolingLibraryAccessors getTooling() {
return laccForAndroidxUiToolingLibraryAccessors;
}
}
public static class AndroidxUiTestLibraryAccessors extends SubDependencyFactory {
public AndroidxUiTestLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>junit4</b> with <b>androidx.compose.ui:ui-test-junit4</b> coordinates and
* with <b>no version specified</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getJunit4() {
return create("androidx.ui.test.junit4");
}
/**
* Dependency provider for <b>manifest</b> with <b>androidx.compose.ui:ui-test-manifest</b> coordinates and
* with <b>no version specified</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getManifest() {
return create("androidx.ui.test.manifest");
}
}
public static class AndroidxUiToolingLibraryAccessors extends SubDependencyFactory implements DependencyNotationSupplier {
public AndroidxUiToolingLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>tooling</b> with <b>androidx.compose.ui:ui-tooling</b> coordinates and
* with <b>no version specified</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> asProvider() {
return create("androidx.ui.tooling");
}
/**
* Dependency provider for <b>preview</b> with <b>androidx.compose.ui:ui-tooling-preview</b> coordinates and
* with <b>no version specified</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getPreview() {
return create("androidx.ui.tooling.preview");
}
}
public static class CoilLibraryAccessors extends SubDependencyFactory {
public CoilLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>compose</b> with <b>io.coil-kt:coil-compose</b> coordinates and
* with version reference <b>coil</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getCompose() {
return create("coil.compose");
}
}
public static class KotlinxLibraryAccessors extends SubDependencyFactory {
private final KotlinxCoroutinesLibraryAccessors laccForKotlinxCoroutinesLibraryAccessors = new KotlinxCoroutinesLibraryAccessors(owner);
private final KotlinxSerializationLibraryAccessors laccForKotlinxSerializationLibraryAccessors = new KotlinxSerializationLibraryAccessors(owner);
public KotlinxLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Group of libraries at <b>kotlinx.coroutines</b>
*/
public KotlinxCoroutinesLibraryAccessors getCoroutines() {
return laccForKotlinxCoroutinesLibraryAccessors;
}
/**
* Group of libraries at <b>kotlinx.serialization</b>
*/
public KotlinxSerializationLibraryAccessors getSerialization() {
return laccForKotlinxSerializationLibraryAccessors;
}
}
public static class KotlinxCoroutinesLibraryAccessors extends SubDependencyFactory {
public KotlinxCoroutinesLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>android</b> with <b>org.jetbrains.kotlinx:kotlinx-coroutines-android</b> coordinates and
* with version reference <b>kotlinxCoroutines</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getAndroid() {
return create("kotlinx.coroutines.android");
}
}
public static class KotlinxSerializationLibraryAccessors extends SubDependencyFactory {
public KotlinxSerializationLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
/**
* Dependency provider for <b>json</b> with <b>org.jetbrains.kotlinx:kotlinx-serialization-json</b> coordinates and
* with version reference <b>kotlinxSerialization</b>
* <p>
* This dependency was declared in catalog libs.versions.toml
*/
public Provider<MinimalExternalModuleDependency> getJson() {
return create("kotlinx.serialization.json");
}
}
public static class VersionAccessors extends VersionFactory {
public VersionAccessors(ProviderFactory providers, DefaultVersionCatalog config) { super(providers, config); }
/**
* Version alias <b>activityCompose</b> with value <b>1.10.1</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getActivityCompose() { return getVersion("activityCompose"); }
/**
* Version alias <b>agp</b> with value <b>8.9.1</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getAgp() { return getVersion("agp"); }
/**
* Version alias <b>coil</b> with value <b>2.7.0</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getCoil() { return getVersion("coil"); }
/**
* Version alias <b>composeBom</b> with value <b>2024.09.00</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getComposeBom() { return getVersion("composeBom"); }
/**
* Version alias <b>coreKtx</b> with value <b>1.15.0</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getCoreKtx() { return getVersion("coreKtx"); }
/**
* Version alias <b>espressoCore</b> with value <b>3.7.0</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getEspressoCore() { return getVersion("espressoCore"); }
/**
* Version alias <b>junit</b> with value <b>4.13.2</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getJunit() { return getVersion("junit"); }
/**
* Version alias <b>junitVersion</b> with value <b>1.3.0</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getJunitVersion() { return getVersion("junitVersion"); }
/**
* Version alias <b>kotlin</b> with value <b>2.0.21</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getKotlin() { return getVersion("kotlin"); }
/**
* Version alias <b>kotlinxCoroutines</b> with value <b>1.9.0</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getKotlinxCoroutines() { return getVersion("kotlinxCoroutines"); }
/**
* Version alias <b>kotlinxSerialization</b> with value <b>1.7.1</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getKotlinxSerialization() { return getVersion("kotlinxSerialization"); }
/**
* Version alias <b>ksp</b> with value <b>2.0.21-1.0.25</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getKsp() { return getVersion("ksp"); }
/**
* Version alias <b>lifecycleRuntimeKtx</b> with value <b>2.9.2</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getLifecycleRuntimeKtx() { return getVersion("lifecycleRuntimeKtx"); }
/**
* Version alias <b>navigation</b> with value <b>2.8.4</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getNavigation() { return getVersion("navigation"); }
/**
* Version alias <b>room</b> with value <b>2.6.1</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getRoom() { return getVersion("room"); }
/**
* Version alias <b>viewmodel</b> with value <b>2.9.2</b>
* <p>
* If the version is a rich version and cannot be represented as a
* single version string, an empty string is returned.
* <p>
* This version was declared in catalog libs.versions.toml
*/
public Provider<String> getViewmodel() { return getVersion("viewmodel"); }
}
public static class BundleAccessors extends BundleFactory {
public BundleAccessors(ObjectFactory objects, ProviderFactory providers, DefaultVersionCatalog config, ImmutableAttributesFactory attributesFactory, CapabilityNotationParser capabilityNotationParser) { super(objects, providers, config, attributesFactory, capabilityNotationParser); }
}
public static class PluginAccessors extends PluginFactory {
private final AndroidPluginAccessors paccForAndroidPluginAccessors = new AndroidPluginAccessors(providers, config);
private final KotlinPluginAccessors paccForKotlinPluginAccessors = new KotlinPluginAccessors(providers, config);
public PluginAccessors(ProviderFactory providers, DefaultVersionCatalog config) { super(providers, config); }
/**
* Plugin provider for <b>ksp</b> with plugin id <b>com.google.devtools.ksp</b> and
* with version reference <b>ksp</b>
* <p>
* This plugin was declared in catalog libs.versions.toml
*/
public Provider<PluginDependency> getKsp() { return createPlugin("ksp"); }
/**
* Group of plugins at <b>plugins.android</b>
*/
public AndroidPluginAccessors getAndroid() {
return paccForAndroidPluginAccessors;
}
/**
* Group of plugins at <b>plugins.kotlin</b>
*/
public KotlinPluginAccessors getKotlin() {
return paccForKotlinPluginAccessors;
}
}
public static class AndroidPluginAccessors extends PluginFactory {
public AndroidPluginAccessors(ProviderFactory providers, DefaultVersionCatalog config) { super(providers, config); }
/**
* Plugin provider for <b>android.application</b> with plugin id <b>com.android.application</b> and
* with version reference <b>agp</b>
* <p>
* This plugin was declared in catalog libs.versions.toml
*/
public Provider<PluginDependency> getApplication() { return createPlugin("android.application"); }
}
public static class KotlinPluginAccessors extends PluginFactory {
public KotlinPluginAccessors(ProviderFactory providers, DefaultVersionCatalog config) { super(providers, config); }
/**
* Plugin provider for <b>kotlin.android</b> with plugin id <b>org.jetbrains.kotlin.android</b> and
* with version reference <b>kotlin</b>
* <p>
* This plugin was declared in catalog libs.versions.toml
*/
public Provider<PluginDependency> getAndroid() { return createPlugin("kotlin.android"); }
/**
* Plugin provider for <b>kotlin.compose</b> with plugin id <b>org.jetbrains.kotlin.plugin.compose</b> and
* with version reference <b>kotlin</b>
* <p>
* This plugin was declared in catalog libs.versions.toml
*/
public Provider<PluginDependency> getCompose() { return createPlugin("kotlin.compose"); }
/**
* Plugin provider for <b>kotlin.serialization</b> with plugin id <b>org.jetbrains.kotlin.plugin.serialization</b> and
* with version reference <b>kotlin</b>
* <p>
* This plugin was declared in catalog libs.versions.toml
*/
public Provider<PluginDependency> getSerialization() { return createPlugin("kotlin.serialization"); }
}
}

View File

@@ -1,2 +0,0 @@
#Fri Aug 15 14:37:16 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

@@ -51,6 +51,18 @@
<option name="screenX" value="1080" /> <option name="screenX" value="1080" />
<option name="screenY" value="2412" /> <option name="screenY" value="2412" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="OnePlus" />
<option name="codename" value="OP5552L1" />
<option name="id" value="OP5552L1" />
<option name="labId" value="google" />
<option name="manufacturer" value="OnePlus" />
<option name="name" value="CPH2415" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2412" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="OPPO" /> <option name="brand" value="OPPO" />
@@ -817,6 +829,19 @@
<option name="screenX" value="1080" /> <option name="screenX" value="1080" />
<option name="screenY" value="2424" /> <option name="screenY" value="2424" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="default" value="true" />
<option name="id" value="tokay" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="samsung" /> <option name="brand" value="samsung" />

View File

@@ -4,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-09-07T04:49:14.182787Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

3
.idea/misc.xml generated
View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" /> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" />
</project> </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

@@ -6,8 +6,8 @@ This is a FOSS Android app meant to help climbers track their sessions, routes/p
You have two options: You have two options:
1. Download the latest APK from the Released page 1. Download the latest APK from the Releases page
2. Use <a href="">Obtainium</a> 2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](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)
## Requirements ## Requirements

View File

@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@@ -8,14 +10,14 @@ plugins {
android { android {
namespace = "com.atridad.openclimb" namespace = "com.atridad.openclimb"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 34
targetSdk = 35 targetSdk = 36
versionCode = 5 versionCode = 21
versionName = "0.3.2" versionName = "1.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -30,17 +32,27 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "11" java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
} }
buildFeatures { buildFeatures {
compose = true compose = true
} }
} }
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies { dependencies {
// Core Android libraries // Core Android libraries
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
@@ -57,6 +69,7 @@ dependencies {
// Room Database // Room Database
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
// Navigation // Navigation
@@ -74,13 +87,18 @@ dependencies {
// Image Loading // Image Loading
implementation(libs.coil.compose) implementation(libs.coil.compose)
// Charts - Placeholder for future implementation
// Charts will be implemented with a stable library in future versions
// Testing // Testing
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)

Binary file not shown.

View File

@@ -1,37 +0,0 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.atridad.openclimb",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 4,
"versionName": "0.3.1",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 31
}

View File

@@ -7,12 +7,15 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" /> <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 --> <!-- Permissions for notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -27,14 +30,16 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.OpenClimb"> android:theme="@style/Theme.OpenClimb.Splash">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- FileProvider for sharing images --> <!-- FileProvider for sharing images -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -45,17 +50,30 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" /> android:resource="@xml/file_provider_paths" />
</provider> </provider>
<!-- Session tracking service --> <!-- Session tracking service -->
<service <service
android:name=".service.SessionTrackingService" android:name=".service.SessionTrackingService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="specialUse"> android:foregroundServiceType="specialUse"
android:description="@string/session_tracking_service_description">
<meta-data <meta-data
android:name="android.app.foreground_service_type" android:name="android.app.foreground_service_type"
android:value="specialUse" /> android:value="specialUse" />
</service> </service>
<!-- Widget Provider -->
<receiver
android:name=".widget.ClimbStatsWidgetProvider"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_climb_stats_info" />
</receiver>
</application> </application>
</manifest> </manifest>

View File

@@ -1,27 +1,40 @@
package com.atridad.openclimb package com.atridad.openclimb
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.atridad.openclimb.ui.OpenClimbApp import com.atridad.openclimb.ui.OpenClimbApp
import com.atridad.openclimb.ui.theme.OpenClimbTheme import com.atridad.openclimb.ui.theme.OpenClimbTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var shortcutAction by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setTheme(R.style.Theme_OpenClimb)
enableEdgeToEdge() enableEdgeToEdge()
shortcutAction = intent?.action
setContent { setContent {
OpenClimbTheme { OpenClimbTheme {
Surface( Surface(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize() OpenClimbApp(shortcutAction = shortcutAction)
) {
OpenClimbApp()
} }
} }
} }
} }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
shortcutAction = intent.action
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,7 +65,7 @@ data class ClimbSession(
val start = LocalDateTime.parse(startTime) val start = LocalDateTime.parse(startTime)
val end = LocalDateTime.parse(endTime) val end = LocalDateTime.parse(endTime)
java.time.Duration.between(start, end).toMinutes() java.time.Duration.between(start, end).toMinutes()
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
} else null } else null

View File

@@ -68,4 +68,41 @@ data class DifficultyGrade(
val system: DifficultySystem, val system: DifficultySystem,
val grade: String, val grade: String,
val numericValue: Int 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

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

View File

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

View File

@@ -1,8 +1,11 @@
package com.atridad.openclimb.ui package com.atridad.openclimb.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -18,200 +21,297 @@ import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.navigation.Screen import com.atridad.openclimb.navigation.Screen
import com.atridad.openclimb.navigation.bottomNavigationItems import com.atridad.openclimb.navigation.bottomNavigationItems
import com.atridad.openclimb.ui.components.NotificationPermissionDialog
import com.atridad.openclimb.ui.screens.* import com.atridad.openclimb.ui.screens.*
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
import com.atridad.openclimb.utils.AppShortcutManager
import com.atridad.openclimb.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun OpenClimbApp() { fun OpenClimbApp(shortcutAction: String? = null) {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val database = remember { OpenClimbDatabase.getDatabase(context) } val database = remember { OpenClimbDatabase.getDatabase(context) }
val repository = remember { ClimbRepository(database, context) } val repository = remember { ClimbRepository(database, context) }
val viewModel: ClimbViewModel = viewModel( val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository))
factory = ClimbViewModelFactory(repository)
) // Notification permission state
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
// FAB configuration var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
// Permission launcher
val permissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (!isGranted) {
showNotificationPermissionDialog = false
}
}
LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)
) {
showNotificationPermissionDialog = true
}
}
}
LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) }
val activeSession by viewModel.activeSession.collectAsState()
val gyms by viewModel.gyms.collectAsState()
LaunchedEffect(activeSession, gyms) {
AppShortcutManager.updateShortcuts(
context = context,
hasActiveSession = activeSession != null,
hasGyms = gyms.isNotEmpty()
)
}
LaunchedEffect(shortcutAction) {
when (shortcutAction) {
AppShortcutManager.ACTION_START_SESSION -> {
navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
}
if (activeSession == null && gyms.isNotEmpty()) {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(
context
)
) {
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id)
} else {
navController.navigate(Screen.AddEditSession())
}
}
}
}
AppShortcutManager.ACTION_END_SESSION -> {
navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
}
activeSession?.let { session -> viewModel.endSession(context, session.id) }
}
}
}
var fabConfig by remember { mutableStateOf<FabConfig?>(null) } var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
Scaffold( Scaffold(
bottomBar = { bottomBar = { OpenClimbBottomNavigation(navController = navController) },
OpenClimbBottomNavigation(navController = navController) floatingActionButton = {
}, fabConfig?.let { config ->
floatingActionButton = { FloatingActionButton(
fabConfig?.let { config -> onClick = config.onClick,
FloatingActionButton( containerColor = MaterialTheme.colorScheme.primary
onClick = config.onClick, ) {
containerColor = MaterialTheme.colorScheme.primary Icon(
) { imageVector = config.icon,
Icon( contentDescription = config.contentDescription
imageVector = config.icon, )
contentDescription = config.contentDescription }
)
} }
} }
}
) { innerPadding -> ) { innerPadding ->
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Screen.Sessions, startDestination = Screen.Sessions,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) { ) {
// Main screens
composable<Screen.Sessions> { composable<Screen.Sessions> {
val gyms by viewModel.gyms.collectAsState()
val activeSession by viewModel.activeSession.collectAsState()
LaunchedEffect(gyms, activeSession) { LaunchedEffect(gyms, activeSession) {
fabConfig = if (gyms.isNotEmpty() && activeSession == null) { fabConfig =
FabConfig( if (gyms.isNotEmpty() && activeSession == null) {
icon = Icons.Default.Add, FabConfig(
contentDescription = "Start Session", icon = Icons.Default.PlayArrow,
onClick = { contentDescription = "Start Session",
if (gyms.size == 1) { onClick = {
viewModel.startSession(context, gyms.first().id) if (NotificationPermissionUtils
} else { .shouldRequestNotificationPermission() &&
navController.navigate(Screen.AddEditSession()) !NotificationPermissionUtils
} .isNotificationPermissionGranted(
context
)
) {
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id)
} else {
navController.navigate(Screen.AddEditSession())
}
}
}
)
} else {
null
} }
)
} else {
null
}
} }
SessionsScreen( SessionsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToSessionDetail = { sessionId -> onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId)) navController.navigate(Screen.SessionDetail(sessionId))
} }
) )
} }
composable<Screen.Problems> { composable<Screen.Problems> {
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
LaunchedEffect(gyms) { LaunchedEffect(gyms) {
fabConfig = if (gyms.isNotEmpty()) { fabConfig =
FabConfig( if (gyms.isNotEmpty()) {
icon = Icons.Default.Add, FabConfig(
contentDescription = "Add Problem", icon = Icons.Default.Add,
onClick = { contentDescription = "Add Problem",
navController.navigate(Screen.AddEditProblem()) onClick = {
navController.navigate(Screen.AddEditProblem())
}
)
} else {
null
} }
)
} else {
null
}
} }
ProblemsScreen( ProblemsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToProblemDetail = { problemId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
composable<Screen.Analytics> { composable<Screen.Analytics> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) { fabConfig = null }
fabConfig = null // No FAB for analytics
}
AnalyticsScreen(viewModel = viewModel) AnalyticsScreen(viewModel = viewModel)
} }
composable<Screen.Gyms> { composable<Screen.Gyms> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
fabConfig = FabConfig( fabConfig =
icon = Icons.Default.Add, FabConfig(
contentDescription = "Add Gym", icon = Icons.Default.Add,
onClick = { contentDescription = "Add Gym",
navController.navigate(Screen.AddEditGym()) onClick = { navController.navigate(Screen.AddEditGym()) }
} )
)
} }
GymsScreen( GymsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToGymDetail = { gymId -> onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId)) navController.navigate(Screen.GymDetail(gymId))
} }
) )
} }
composable<Screen.Settings> { composable<Screen.Settings> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) { fabConfig = null }
fabConfig = null // No FAB for settings
}
SettingsScreen(viewModel = viewModel) SettingsScreen(viewModel = viewModel)
} }
// Detail screens
composable<Screen.SessionDetail> { backStackEntry -> composable<Screen.SessionDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.SessionDetail>() val args = backStackEntry.toRoute<Screen.SessionDetail>()
LaunchedEffect(Unit) { fabConfig = null }
SessionDetailScreen( SessionDetailScreen(
sessionId = args.sessionId, sessionId = args.sessionId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { sessionId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.AddEditSession(sessionId = sessionId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
composable<Screen.ProblemDetail> { backStackEntry -> composable<Screen.ProblemDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.ProblemDetail>() val args = backStackEntry.toRoute<Screen.ProblemDetail>()
LaunchedEffect(Unit) { fabConfig = null }
ProblemDetailScreen( ProblemDetailScreen(
problemId = args.problemId, problemId = args.problemId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { problemId -> onNavigateToEdit = { problemId ->
navController.navigate(Screen.AddEditProblem(problemId = problemId)) navController.navigate(Screen.AddEditProblem(problemId = problemId))
} }
) )
} }
composable<Screen.GymDetail> { backStackEntry -> composable<Screen.GymDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.GymDetail>() val args = backStackEntry.toRoute<Screen.GymDetail>()
LaunchedEffect(Unit) { fabConfig = null }
GymDetailScreen( GymDetailScreen(
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId -> onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = gymId)) navController.navigate(Screen.AddEditGym(gymId = gymId))
} },
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
},
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
) )
} }
composable<Screen.AddEditGym> { backStackEntry -> composable<Screen.AddEditGym> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditGym>() val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen( AddEditGymScreen(
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
composable<Screen.AddEditProblem> { backStackEntry -> composable<Screen.AddEditProblem> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditProblem>() val args = backStackEntry.toRoute<Screen.AddEditProblem>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditProblemScreen( AddEditProblemScreen(
problemId = args.problemId, problemId = args.problemId,
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
composable<Screen.AddEditSession> { backStackEntry -> composable<Screen.AddEditSession> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditSession>() val args = backStackEntry.toRoute<Screen.AddEditSession>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditSessionScreen( AddEditSessionScreen(
sessionId = args.sessionId, sessionId = args.sessionId,
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
} }
// Notification permission dialog
if (showNotificationPermissionDialog) {
NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = {
permissionLauncher.launch(
NotificationPermissionUtils.getNotificationPermissionString()
)
}
)
}
} }
} }
@@ -219,44 +319,41 @@ fun OpenClimbApp() {
fun OpenClimbBottomNavigation(navController: NavHostController) { fun OpenClimbBottomNavigation(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
NavigationBar { NavigationBar {
bottomNavigationItems.forEach { item -> bottomNavigationItems.forEach { item ->
val isSelected = when (item.screen) { val isSelected =
is Screen.Sessions -> currentRoute?.contains("Session") == true when (item.screen) {
is Screen.Problems -> currentRoute?.contains("Problem") == true is Screen.Sessions -> currentRoute?.contains("Session") == true
is Screen.Gyms -> currentRoute?.contains("Gym") == true is Screen.Problems -> currentRoute?.contains("Problem") == true
is Screen.Analytics -> currentRoute?.contains("Analytics") == true is Screen.Gyms -> currentRoute?.contains("Gym") == true
is Screen.Settings -> currentRoute?.contains("Settings") == true is Screen.Analytics -> currentRoute?.contains("Analytics") == true
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true is Screen.Settings -> currentRoute?.contains("Settings") == true
} else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
}
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) }, NavigationBarItem(
label = { Text(item.label) }, icon = { Icon(item.icon, contentDescription = item.label) },
selected = isSelected, label = { Text(item.label) },
onClick = { selected = isSelected,
navController.navigate(item.screen) { onClick = {
// Clear the entire back stack and go to the selected tab's root screen navController.navigate(item.screen) {
popUpTo(0) { // Clear the entire back stack and go to the selected tab's root screen
inclusive = true popUpTo(0) { inclusive = true }
} // Avoid multiple copies of the same destination when
// Avoid multiple copies of the same destination when // reselecting the same item
// reselecting the same item launchSingleTop = true
launchSingleTop = true // Don't restore state - always start fresh when switching tabs
// Don't restore state - always start fresh when switching tabs restoreState = false
restoreState = false }
} }
}
) )
} }
} }
} }
data class FabConfig( data class FabConfig(
val icon: androidx.compose.ui.graphics.vector.ImageVector, val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String, val contentDescription: String,
val onClick: () -> Unit val onClick: () -> Unit
) )

View File

@@ -3,7 +3,6 @@ package com.atridad.openclimb.ui.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -13,6 +12,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.ClimbSession import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.Gym import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.ui.theme.CustomIcons
import kotlinx.coroutines.delay
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
@@ -24,6 +25,16 @@ fun ActiveSessionBanner(
onEndSession: () -> Unit onEndSession: () -> Unit
) { ) {
if (activeSession != null) { if (activeSession != null) {
// Add a timer that updates every second for real-time duration counting
var currentTime by remember { mutableStateOf(LocalDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(1000) // Update every second
currentTime = LocalDateTime.now()
}
}
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -67,7 +78,7 @@ fun ActiveSessionBanner(
) )
activeSession.startTime?.let { startTime -> activeSession.startTime?.let { startTime ->
val duration = calculateDuration(startTime) val duration = calculateDuration(startTime, currentTime)
Text( Text(
text = duration, text = duration,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -84,7 +95,7 @@ fun ActiveSessionBanner(
) )
) { ) {
Icon( Icon(
Icons.Default.Close, imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
contentDescription = "End session" contentDescription = "End session"
) )
} }
@@ -93,18 +104,18 @@ fun ActiveSessionBanner(
} }
} }
private fun calculateDuration(startTimeString: String): String { private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String {
return try { return try {
val startTime = LocalDateTime.parse(startTimeString) val startTime = LocalDateTime.parse(startTimeString)
val now = LocalDateTime.now() val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
val minutes = ChronoUnit.MINUTES.between(startTime, now) val hours = totalSeconds / 3600
val hours = minutes / 60 val minutes = (totalSeconds % 3600) / 60
val remainingMinutes = minutes % 60 val seconds = totalSeconds % 60
when { when {
hours > 0 -> "${hours}h ${remainingMinutes}m" hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
remainingMinutes > 0 -> "${remainingMinutes}m" minutes > 0 -> "${minutes}m ${seconds}s"
else -> "< 1m" else -> "${totalSeconds}s"
} }
} catch (_: Exception) { } catch (_: Exception) {
"Active" "Active"

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")
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More